Skip to content

[32.1/33] A second factor, and sessions that can be asked whose they are - #425

Merged
vikramsoni2 merged 10 commits into
nxzai:mainfrom
cerede2000:upstream-32-signing-in
Sep 26, 2026
Merged

vikramsoni2 merged 10 commits into
nxzai:mainfrom
cerede2000:upstream-32-signing-in

Conversation

@cerede2000

Copy link
Copy Markdown

First half of batch 32, stacked on #424.

A password was the whole of it

From a reused one, a phishing page, a machine left signed in somewhere else. An account can now ask for a code from an authenticator as well, and the recovery codes answer the obvious objection to that.

Two things are the difference between a second factor and the appearance of one:

  • The password step does not sign anybody in. It is not a session with a flag on it: nothing but the account id on the session signs anybody in, and this state does not set it. The session is regenerated at that point for the same reason it is regenerated at the end — an id somebody planted in the browser must not be the one that finishes the sign-in — and it lapses after five minutes.
  • A wrong code counts against the same lockout a wrong password does. The second factor is otherwise a place to guess a million times at six digits while the first one is bounded.

A code is good for thirty seconds and for one sign-in: the step it belonged to is kept, so a code seen over somebody's shoulder is already spent. Recovery codes are hashed like passwords and each works once. Drawing new ones, or turning the whole thing off, asks for the password again — a browser left unlocked is exactly the case that is about.

The secret cannot be hashed: the server has to compute the same six digits the phone does. What it can do is keep it under a key that lives beside the database rather than in it, so a copy of app.db on its own is not a set of working authenticators. That key is drawn once into the config directory and is deliberately not derived from SESSION_SECRET: rotating that one signs everybody out, which is a nuisance, and it would take every authenticator with it, which is a lockout.

Sessions

Moved off connect-sqlite3, for two reasons. It opens its database when the module is required, and it never gives space back: every expired session the cleanup deletes leaves its pages behind, so a burst of sign-ins — a script, a scanner — grew the file for good.

The store here opens on first use, keeps its pages reclaimable, and can answer a question a key-value store cannot: whose is this session. That is what lets changing a password end the sessions opened with the old one — which the account screen already claimed to do and nowhere did.

Signing in by username

Not only by email address. Where a name matches more than one account it identifies nobody and is refused rather than resolved by picking one — which would be choosing whose account a stranger signs into. Those accounts keep their email, unique by schema.

Gates

  • Lint and formatting on the changed files.
  • npm run build; every backend module loads.
  • Whole backend suite: 2,051 pass, against the two that fail on main before any of this.
  • 18 new tests. Every claim fails when the change behind it is put back: the password step signing in outright, a wrong code not counting against the lockout, a code usable twice, the session store sparing nobody.
  • Driven in the built image: the schema migrates, a secret is drawn with the URI an authenticator reads, a code turns it on and ten recovery codes come back once, a second browser is asked for a code after the password, and the code signs it in.

Second half, under the same number

Passkeys, and the language an account reads in.

Benjy added 10 commits September 26, 2026 10:03
The engine that keeps what a save replaces, and the API that reads a history
back, arrived with the batch before this one. Nothing showed them: no mark in a
listing, no panel, no way for an administrator to see what histories cost, and
no way to read an earlier version at all.

This is that half.

A file that has earlier versions carries a small mark in the listing, with how
many; clicking it opens the panel, which lists what was kept, who saved it and
where it came from, and offers reading a version, downloading it, putting it
back, naming it, pinning it and deleting it. Putting one back keeps what it
replaced, so nothing is lost by going back. The mark is counted once per folder
rather than once per row, and a preference turns it off — which takes the query
away as well as the icon.

An administrator gets the list of every file that has a history, wherever it
is, with what each one takes up, and can empty one. Those routes are addressed
by the history's own id rather than by a path, because the ones worth finding
include files that no longer exist: a history whose file was deleted outside
the application has no path left to ask about, and no file to authorise
against. They sit behind the administrator check for the same reason.

Reading text is now one reader, used by the editor, by a version and by a file
still in the trash — the two the batch before this one left out. That reader
fixes what the editor used to do with it:

  - A UTF-16 file was answered "this file appears to be binary and cannot be
    opened". In UTF-16 every letter of English is accompanied by a zero byte,
    and a zero byte was exactly the test for binary. That is what `Out-File`
    wrote by default until PowerShell 6 and what Notepad still offers as
    "Unicode", so an export or a log from a Windows machine could not be
    opened. A mark is believed when there is one; otherwise the pairing of
    zeros decides, which is what tools that write UTF-16 without a mark leave
    behind.

  - A save wrote UTF-8 over whatever the file was. That reads perfectly well
    here and breaks whatever wrote it, so a save now writes back in the
    encoding the file already had.

  - The size limit was checked when opening and not when saving: a paste larger
    than the limit was written, and the file could then never be opened again.
    It is now checked on the bytes about to be written, which in UTF-16 are
    twice the characters.

  - The editor opens two megabytes and saves through a JSON body, whose limit
    was Express's own default of 100 kB. A file between the two opened and
    could never be saved, answered "request entity too large" — which names
    neither limit. The two are one decision now: the body limit is derived from
    what the editor may open, and a body limit somebody set is a ceiling that
    is never raised from here, so it is the editor that gives way.

The editor's read is also a GET the browser may keep and revalidate, answered
304 while the file is unchanged, so opening the editor from the Markdown
preview no longer downloads the same file twice. The identity is taken from the
file's metadata, including the inode — a save writes a new file and renames it
over the old one, so a save that comes out the same size within one clock tick
still differs.

An earlier version and a file in the trash open in the editor as text to read:
no save, no shortcut that saves, and an editor that does not take typing.
Closing goes back where it came from — the folder with the history open again,
or the trash, inside the deleted folder the file was read from.

Found while wiring this up: `marksForFolder`, `listFilesWithVersions`,
`readFileVersions` and `deleteFileVersions` all called store helpers that were
never added. Nothing called them, so nothing noticed; all four threw. The
queries they need are here, and every one of them now has a test.

Left for later, and named here so it is not lost: taking a version out as a new
file somewhere, and putting one over another file, both need the destination
dialog, which is not here yet; reading a version of an office document needs
the office editors to accept one, which comes with the batch that finishes
them; and the entry an activity log would want for a purge waits for the log.

Gates: lint and formatting on the changed files, `npm run build`, every backend
module loads, and the whole backend suite — 1,949 pass, against the two that
fail on `main` before any of this. Every claim above has a test that fails when
the change behind it is put back; the detection of UTF-16 was mutated twice,
once for the mark and once for the pairing, because they are two mechanisms.

The screens were checked in the built image: two saves, the mark showing 2 in
the listing, the panel opening from it, a version read in the read-only viewer,
a version restored from the panel with the history growing to three, a file
deleted and read from the trash by double-clicking it, and the administrator's
list showing the file, its space and its history. That is also how the last
defect here turned up: the dialog that confirms a restore teleports to the body
and stacked below the panel's own overlay, so its buttons could not be clicked.
No unit test would have seen it.
Which keys are preferences was decided twice: once in the settings service,
which sanitises the value, and once in the settings route, which decides
whether the key is written at all.

They had drifted. The preference added here — the mark on files that have
versions — reached the service's list and not the route's, so the toggle moved
on screen, the save answered success, and nothing was stored. It would have
read as "my setting does not stick", with two lists to find before anyone could
say why.

One list now, named and exported by the service; the route asks it. A test
walks every key the service calls a preference and checks the route keeps it,
so the two cannot drift again.
Two people editing the same document only see each other when the Document
Server was given the same key: a different key is a different document to it,
and it opens a second, independent session on the same file. Whoever saves last
then overwrites the other, with nothing to warn either of them.

The key was recomputed on every open from the file's own modification time. So
it changed under the people already editing — every save of theirs split the
session — and it did not change once they had all left, which is the one moment
it must, because the Document Server caches the prepared document under that key
and serves the stale copy on the next open.

The key is now kept: the same for as long as anyone has the document open,
including across their saves, and dropped when the Document Server reports it
has let go. Restoring a version drops it too, which is the piece the history
batch could not carry: an editor reopened after a restore was getting the copy
the restore had just replaced.

The other half is where the document is. The Document Server is handed a token
when the editor opens and returns it unchanged with every save, so the token
says where the document *was*. Renaming from the title bar makes that stale at
once. There are now editing sessions, stored rather than held in memory — a
restart mid-edit would otherwise forget the rename, and the next save would
recreate the old name beside the new one, silently. Renaming from inside the
editor moves the file through the ordinary rename, with the ordinary permission
checks, and then moves the session, the presence record and the key with it.

Presence starts when the client says the document is really open, not when the
configuration was asked for: asking for a configuration says nothing about
whether the document opened, and a file the editor then refused was shown to
everybody as being edited until it expired.

Four things were wrong in this path and are fixed here:

  - **A read-only folder handed out an editing session.** The decision looked at
    the requested mode and at whether the document came through a read-only
    share, and never at the location's own rights. The backend token now carries
    that decision, and the callback refuses a save made with a token that says
    read-only.

  - **The backend token was untyped and never expired.** It is signed with the
    same secret as the Document Server's own tokens, so one of those would have
    been accepted as ours. It now carries a type claim and a twelve-hour life.

  - **Every unknown extension was announced as a text document.** The Document
    Server validates documentType against the extension and refuses the config
    outright, answering "the file content does not match the file extension" —
    true, unhelpful, and several steps from the setting that caused it. .odg was
    one of them: the Document Server counts a drawing as a presentation. The
    mapping is now the one it ships, and an extension it has no editor for is
    refused here, naming the setting.

  - **Nothing ever expired.** Only a terminal callback released a key, so a
    browser closed on the editor left the row for good — one for every document
    ever opened. There is now an hourly sweep, and it takes with it the two
    cleanups that were already in the tree with no caller at all:
    `cleanupExpiredShares` and `cleanupExpiredSessions`, so an expired share no
    longer sits on disk indefinitely.

The rename itself moved out of its route into a service, because two callers now
need exactly those checks in exactly that order. One thing changed on the way:
`ensureValidName` throws a plain Error, which the error handler could only read
as a server fault, so a name with a separator in it was answered 500 and
appeared in the logs as an unhandled failure. It is a bad request.

Gates: lint and formatting on the changed files, `npm run build`, every backend
module loads, and the whole backend suite — 1,972 pass, against the two that
fail on `main` before any of this. Twenty-three new tests; every claim above
fails when the change behind it is put back, mutated one at a time: the key back
to its inline computation, the type claim removed, the write check removed from
both ends, the save target back to the token, and the editor table back to its
catch-all.

Checked in the built image with a volume: the schema migrates, a document is
handed a key and a session, the heartbeat reports it open, opening it again
while it is open gives the same key, an unknown extension is refused by name,
renaming from the session moves the file and the folder lists the new name, and
ending the session is accepted.

Second half — editing together as the file list shows it, Save as, mentions,
force-save, the history inside the editor, and opening a document in a tab of
its own — follows under the same number.
A folder gave no sign that anybody had a document open. It was copied, moved,
renamed or deleted while an editor was about to write a newer version of it,
and nobody was told.

A file somebody has open now carries a mark in the listing, with their names,
and the folder keeps it current without asking for it: the server holds the
request open until somebody joins or leaves, so presence costs nothing while
nothing happens. Dragging such a document somewhere else asks first — it is
allowed, and it is worth knowing that the editor will write where the file used
to be.

Presence is advisory and never a lock. Nothing here can stop a file operation,
and nothing here survives a restart.

The second half is writing. Closing the editor relied on the callback the
Document Server sends when it decides the document is finished with, which
arrives seconds after the last keystroke — long after the folder behind the
editor has been listed again with the old content, and often after the tab is
gone. The server now asks the Document Server to write what the editor holds,
on the way out and on a bounded timer while editing, and the save it asked for
on the way out is kept as a state of its own rather than as one of the
automatic ones in between.

Two requests for one session are coalesced: a close arriving while an automatic
save is still assembling queues one final command behind it rather than a
second one beside it, because each one costs a full document conversion. A
Document Server that answers "the editor has not sent its last changes yet" is
retried, four times, with increasing delays — so closing the preview stays
instant and the last edits still land.

Found while checking this in a browser: a listing refresh could not remove
anything. The store merges a new listing into the items already on screen with
`Object.assign`, which overwrites fields and never deletes them, so a mark the
row had stopped carrying stayed for as long as the folder was open — a document
nobody had open any more, and a history that had just been emptied. Only
`supportsThumbnail` had been patched for this, one field at a time. A refresh
now drops whatever the listing no longer sends, keeping only the fields the
store puts there itself.

Gates: lint and formatting on the changed files, `npm run build`, every backend
module loads, and the whole backend suite — 1,986 pass, against the two that
fail on `main` before any of this. Fourteen new tests; every claim above fails
when the change behind it is put back, mutated one at a time: the listing no
longer carrying presence, closing no longer flushing, the two requests no
longer coalesced, and the close flush no longer counting as a deliberate save.

Checked in the built image, which is also where the merge defect turned up: the
mark appears when a document is reported open, without reloading the page,
names the person, and goes when the session ends — and the request log shows
the folder listing itself again because the held request answered, not because
anything polled.
ONLYOFFICE hides the menu entries it has no answer for. Three of them were
hidden because nothing here answered.

**Saving a copy.** The editor does not write anything itself: it converts the
document and hands the integration a URL to fetch the result from. With nothing
to receive it, Download was the only way out of a document — through the
browser, into the person's downloads rather than their volume. Now the server
fetches it and writes the copy beside the original, never over anything: a name
already taken gets the same "(1)" treatment as everywhere else, and the answer
says the name actually used. A title carrying a path separator is refused
rather than trimmed to its last segment — reinterpreting it would turn
"../invoice.pdf" into a silent success in a folder nobody named. Saving a copy
is not a change to the original, so a reader may do it; what it needs is the
right to read the document and to write into the folder, exactly as an upload
would.

**Mentioning somebody in a comment.** The editor asks for the whole list and
filters it itself as the name is typed, and it waits for an answer even when
the list is empty. Only a signed-in account gets it: a visitor through a share
link has no business being handed the user directory. The comment itself is
already written by the editor; the separate "tell them" step has no channel to
deliver on, so the mention is recorded and the answer says plainly that nothing
was sent, rather than leaving the editor waiting on a handler that does nothing.

**The document's own history.** The editor numbers versions from the oldest and
expects the current state to be the last of them; the history this application
keeps comes newest first and does not count the current state as a version. The
two are reconciled here rather than in the editor. Restore is offered only to
somebody who may change the document.

That same piece closes what the history panel could not do: an earlier version
of an office document now opens in the office editor, read only — which is what
the versions panel was missing, since it could only put text in front of
anybody on its own. A version gets a key of its own, so it never touches the one
the people editing the document share, and it is served with no callback at
all: nothing is saved from a version, and the document's own callback would
release that shared key.

Left out on purpose: handing the editor a file from the volume to insert or
compare against. It is a short-lived read-only token over the same route, and
it is useless without somewhere to choose the file — so it travels with the
batch that brings the destination dialog.

Gates: lint and formatting on the changed files, `npm run build`, every backend
module loads, and the whole backend suite — 2,001 pass, against the two that
fail on `main` before any of this. Fifteen new tests; every claim above fails
when the change behind it is put back, mutated one at a time: the document URL
no longer checked against the Document Server, the title trimmed instead of
refused, the copy written straight over the name, the mention list handed to
anybody, and a version config given a callback.

Checked in the built image: a document with two versions answers a history of
three entries ending in itself, one entry hands back a signed location whose
token says it may never be written, and opening that version answers a viewer
with its own key and no editing session.
A document had no address. Opening one filled a panel over the folder it was
in, driven by a store, and the browser knew nothing about it: nothing could be
linked to, nothing kept as a bookmark, the back button did not close it, and
two documents could not be open at once.

`/open/<path>` is that address. Everything the page does the folder listing
also does — the same preview, the same plugins, the same component. What it
adds is the one thing a panel over a folder cannot have: a URL. The browser
then provides the tabs.

Two things about leaving had to be right. Closing the document lands back in
the folder it is in, with the document selected, which is where closing the
panel leaves you — except in a tab opened for this document alone, where the
thing the close button should close is the tab. Closing the tab has to tell the
server what closing the panel tells it: a tab gives one synchronous moment on
its way out, so the plugin is told it is unloading and sends what it has to
send in one beacon. Without it, a document closed by closing its tab would go
on being reported as open by somebody who had left.

Whether a document opens in a tab of its own is an account's preference, off by
default. One decision for every kind of file rather than one per plugin: a
spreadsheet and a photograph open the same way, because a preference that holds
for some files and not others is a preference nobody can predict.

And the tab has a name. It was the last segment of the address whatever the
page was, so every settings section, the search and both share lists read
"Volumes"; a document opened directly read "Explorer". Now each page says what
it is and which instance it belongs to — the name set in Branding, read from
the settings every page already loads, so renaming the instance reaches every
open tab without a reload. Four tabs all reading "Explorer" are four tabs
nobody can tell apart, which is exactly what a page whose point is to be opened
several times at once cannot afford.

Gates: lint and formatting on the changed files, `npm run build`, every backend
module loads, and the whole backend suite — 2,007 pass, against the two that
fail on `main` before any of this.

Checked in the built image: the folder tab reads "Projects | Explorer", the
settings tab "Preferences | Explorer", the trash tab "Trash | Explorer" and the
document tab "notes.md | Explorer"; `/open/Work/Projects/notes.md` shows the
document; a file nothing opens says so and offers the way back; and with the
preference on, a double click in the listing opens a second tab at that
address.
An upload was one request. A reverse proxy refuses it outright once the body
passes whatever limit it enforces — the error names a size and never the
setting — and a connection that drops loses the whole thing, however far it had
got. Both are what somebody sending a film or a disk image over a home
connection meets first, and there was nothing to be done about either.

This adds the tus protocol beside the direct upload. The transfer becomes a
series of requests, each small enough to pass; what has arrived is remembered,
so a client that comes back asks where it got to and carries on from there.
Nothing lands in the folder until the file is whole, and when it does it takes
a free name rather than replacing anything, as every other way a file arrives
here does.

Which one is used is an administrator's decision, in a screen of its own, and
the default is unchanged: nothing about an upload moves until somebody asks.
The chunk size is theirs too, bounded by a ceiling the server states rather
than discovered by having a number refused — a chunk is held whole in memory at
each end, so an unbounded one is a way to run a server out of it.

The parts live in the cache rather than beside the destination: a part file is
not a file anybody asked for, and a volume should never show one. They are
swept once they are past their time, which covers the two ways they are left —
a client that never came back, and a data file whose record never reached the
disk.

Three things in the direct upload were wrong and are fixed here, since this is
the batch that owns it:

  - **A request that announced no size skipped the space check.** The guard
    takes a number and was handed nothing, so a chunked request — which is what
    an API client streaming a body sends — could fill a volume already past its
    reserve, on a machine where a full volume takes the database down with it.
    Zero is what is honestly known about what is coming, and the reserve is
    still held free.

  - **One request can reach several folders, and only the first was checked.**
    Each file carries its own relative path, so on a machine with more than one
    disk that is several disks; the second had its free space never measured
    and its dead uploads never swept.

  - **A refusal left the folders it had invented behind.** No space, a file
    over the limit, a client that went away: empty folders an upload created,
    in somebody's tree, with nothing to say where they came from. What lies
    between the topmost folder `mkdir` had to create and the destination is
    exactly what that file added, and it is removed deepest first, each one
    only while it is still empty — another file of the same request may have
    landed in it.

And one about permissions: the destination is authorized, but the file also
carries a client-supplied relative path, so the folder it actually lands in is
authorized too. Without that, a subfolder an administrator had made read-only
still accepted uploads.

Left for later, and named here: the client learning a chunk size for itself
after a direct upload stalls, which needs the watchdog that notices the stall.
The setting for it is not here either — a setting whose half is missing is
worse than none.

Two dependencies on the server (`@tus/server`, `@tus/file-store`) and one in
the browser (`@uppy/tus`). The store and the protocol server are built on first
use rather than when the module is required: `FileStore` creates its directory
in its constructor, which would turn requiring the file into a filesystem write
— one that fails wherever the cache is not there yet, including the check that
every module loads.

Gates: lint and formatting on the changed files, `npm run build`, every backend
module loads, and the whole backend suite — 2,020 pass, against the two that
fail on `main` before any of this. Twelve new tests; every claim above fails
when the change behind it is put back: the finished file written straight over
the name, a metadata field of the literal "undefined" believed, the root
allowed, and the sweep stopped.

Checked in the built image: an upload of 300 kB was created, a third of it
sent, then stopped; the server answered that it held 100,000 bytes; the folder
showed nothing; the rest was sent from that offset and the file landed whole,
at its full size. The settings screen offers the switch and the chunk size.
A thumbnail was made inside the request that asked for it. A folder of five
hundred pictures is five hundred held requests; a video on a slow disk holds
one for minutes; and nothing could be said about which of them mattered, so the
tile somebody was looking at waited behind the one they had scrolled past.

The request now answers with what is already there, or says it has queued the
work and asks the caller to come back. What is worth doing first is decided in
the queue, where it can be: a thumbnail somebody is waiting for goes ahead of
one being fetched in advance, and a prefetch is admitted only while nothing
interactive is running. The browser asks again, with increasing delays, rather
than holding a connection open per tile.

Making them is the other half. Video thumbnails go through ffmpeg with their
own concurrency, their own seek point, their own thread count and a generous
ceiling — a thumbnail killed early is a thumbnail that never appears. RAW files
hand over the full-size preview their own metadata carries, which is far
cheaper than decoding the raw sensor data and is what every other viewer shows.
Both are bounded: sharp's cache is held to a size, the external processes are
counted and can be given a lower priority, and a job that takes too long says
so in the log rather than disappearing into it.

What they leave behind is bounded too. The cache has a file count, an age and a
cleanup that walks it in batches, shared with the RAW previews so the two are
held to one reading of the same settings rather than two copies that drift. A
temporary file an interrupted write left behind is recognised and removed,
under both the name this version writes and the ones earlier versions did —
and a file this process is still writing is never one of them.

A failure is remembered for a while, so a file that cannot be made into a
thumbnail is not attempted again on every listing; and the cache key carries a
version, so a change in how thumbnails are made invalidates what is there
rather than serving it for ever.

The settings screen was saving something other than what it showed: a value
outside the bounds the server holds it to was sent as typed and brought within
them there, and an emptied field was dropped. The bounds are now the page's as
well, and it will not offer to save what the server would change. It also
offered to save before the settings had loaded, and saving then wrote its own
defaults over what was stored.

Gates: lint and formatting on the changed files, `npm run build`, every backend
module loads, and the whole backend suite — 2,026 pass, against the two that
fail on `main` before any of this. Six new tests, and the thumbnail-token test
from nxzai#411 now waits for the picture the way the browser does. Every claim fails
when the change behind it is put back: the request held again, and thumbnails
that cannot be switched off. One claim was dropped rather than left passing for
the wrong reason — which of the two short-circuits answers a second ask cannot
be told apart from outside, so nothing here says it can.

Checked in the built image: the first ask answered 202 and said so, the picture
appeared on a later ask, the next ask answered at once, a prefetch was taken,
the picture opened with the proof on its URL and was refused 401 without it,
and the folder drew all three.
An archive could only be extracted whole. That is a lot of disk and a lot of
waiting for the one file somebody wanted out of it, and there was no way at all
to see what was in one before deciding.

An archive now opens like a folder. The entries come from the archive's own
index, the folders inside it are walked the same way, and a single file is read
out of it without the rest being written anywhere. Extracting is still there,
and now knows where it is: from inside the archive, a selection comes out
rather than everything.

An entry always arrives as something to save, never something to run. A file
inside somebody's archive is somebody else's HTML as easily as their
photograph, and served inline it would run on this application's origin — that
is a decision about previewing, and it is not made here. The type is still
declared, so a saved file arrives named and typed as what it is, and `nosniff`
stops the browser arguing with it.

What an archive expands to is bounded before anything is written: an entry
count and a total size, read from the archive's own declaration. A few
kilobytes of nested, highly compressible entries can otherwise fill a volume.
The decompressed copy a compound archive needs — a `.tar.gz` is a tar inside a
gzip, and 7-Zip peels one layer per run — lives in a cache with an age and a
size of its own, swept by the same cleanup that bounds the thumbnails.

Which formats can be opened is the server's answer, not a list kept in the
browser: 7-Zip is asked once what it was built with, so an image without the
RAR codec does not offer to open a RAR. An archive with a password asks for it
rather than failing with a message about a corrupt file.

Two things found while wiring this up, both in code merged earlier:

  - `routes/trash.js` imports `sanitizeClientMessage` from the error handler,
    which does not export it. It is `undefined` until a restore-into-a-folder
    fails, where the line meant to explain the failure throws instead. It is
    exported now, and the export says why it exists.

  - `predictAvailableName` was called by the compression route and did not
    exist — the route answered 500 to every request. It is the read-only twin
    of `placeWithoutOverwrite`: the name that would be taken if nothing changed
    meanwhile, for telling somebody what to expect and never to place anything
    under.

Left out on purpose: extracting to a folder somebody chooses, which needs the
destination dialog, and the archive preview's own entry preview, which is the
picker again. Both travel with the batch that brings it.

Gates: lint and formatting on the changed files, `npm run build`, every backend
module loads, and the whole backend suite — 2,033 pass, against the two that
fail on `main` before any of this. Seven new tests, which find 7-Zip by name
rather than assuming it and say so when there is none: a suite that quietly
passes on a machine without the tool proves nothing. Every claim fails when the
change behind it is put back: an entry served inline, a file that is not an
archive accepted, and the access check dropped.

Checked in the built image: the formats the build supports are published, a zip
lists its top and the inside of one of its folders, one file comes out of it
with the right content and as an attachment, and double-clicking the archive in
the file browser shows what is in it.
A password was the whole of what stood between somebody's files and whoever
had that password — from a reused one, a phishing page, a machine left signed
in somewhere else. An account can now ask for a code from an authenticator as
well, and the recovery codes answer the obvious objection to that.

Two things here are the difference between a second factor and the appearance
of one:

  - **The password step does not sign anybody in.** It is not a session with a
    flag on it: nothing but the account id on the session signs anybody in, and
    this state does not set it. The session is regenerated at that point for
    the same reason it is regenerated at the end — an id somebody planted in
    the browser must not be the one that finishes the sign-in — and it lapses
    after five minutes, so a machine walked away from is not a sign-in waiting
    to be finished by whoever sits down next.

  - **A wrong code counts against the same lockout a wrong password does.** The
    second factor is otherwise a place to guess a million times at six digits
    while the first one is bounded.

A code is good for thirty seconds and for one sign-in: the step it belonged to
is kept, so a code seen over somebody's shoulder is already spent. Recovery
codes are hashed like passwords and each works once. Drawing new ones, or
turning the whole thing off, asks for the password again — a browser left
unlocked is exactly the case that is about.

The secret cannot be hashed: the server has to compute the same six digits the
phone does. What it can do is keep it under a key that lives beside the
database rather than in it, so a copy of `app.db` on its own — a backup, a
support ticket, a file read through some other hole — is not a set of working
authenticators. That key is drawn once into the config directory and is
deliberately not derived from SESSION_SECRET: rotating that one signs everybody
out, which is a nuisance, and it would take every authenticator with it, which
is a lockout.

Sessions moved off `connect-sqlite3`, for two reasons. It opens its database
when the module is required, and it never gives space back: every expired
session the cleanup deletes leaves its pages behind, so a burst of sign-ins — a
script, a scanner — grew the file for good. The store here is opened on first
use, keeps its pages reclaimable, and can answer a question a key-value store
cannot: whose is this session. That is what lets changing a password end the
sessions opened with the old one, and it is why the account was able to be
signed out everywhere else and nowhere was.

Signing in also takes a username now, not only an email address. Where a name
matches more than one account it identifies nobody and is refused, rather than
picking one — which would be choosing whose account a stranger signs into;
those accounts keep their email, which is unique by schema.

Gates: lint and formatting on the changed files, `npm run build`, every backend
module loads, and the whole backend suite — 2,051 pass, against the two that
fail on `main` before any of this. Eighteen new tests. Every claim above fails
when the change behind it is put back: the password step signing in outright, a
wrong code not counting against the lockout, a code usable twice, and the
session store sparing nobody.

Checked in the built image: the schema migrates, a secret is drawn with the URI
an authenticator reads, a code turns it on and ten recovery codes come back
once, a second browser is asked for a code after the password, and the code
signs it in.

Passkeys and the language an account reads in follow under the same number.
@vikramsoni2
vikramsoni2 merged commit 370d1e8 into nxzai:main Sep 26, 2026
1 check passed
vikramsoni2 pushed a commit that referenced this pull request Sep 27, 2026
…de a volume

`POST /api/permissions/chown` built a command line and gave it to a shell:

    chown "<owner>:<group>" "<path>"

`owner` and `group` come from the request body. It is an administrator's route,
but that is the difference between "may change ownership on this volume" and
"may run anything as the user this server runs as", which is not a difference to
leave to a pair of quotes. `chmod -R` and the two `id` lookups did the same with
the path and the numeric ids.

They take their arguments as an array now, through execFile, with no shell
anywhere. And because an argument list is not a free pass either, an account
name has to look like one: `--reference=/etc/shadow` is not a name, and chown
would have read it as an option and copied another file's ownership.

A rule that hides a folder now hides what is inside it consistently. The listing
said one thing and the rule another in several places: a read-only rule left an
administrator the Create button, a hidden rule left the folder's name in a
search result, and a symbolic link out of a share was followed as if it were
part of it. Access is one question asked in one place, and the answer carries
the reason it was refused.

A personal folder name outlives the account it belonged to, while its folder is
still on disk. Deleting an account freed its name, and the next account deriving
the same one was handed a folder full of somebody else's files. Schema 23 adds
the reservations table and, at the upgrade, removes the rows that accounts
deleted before it left behind — `folder_preferences` and `recent_destinations`
point at users through no foreign key, so nothing ever removed them.

One file this batch deliberately does not take from the fork. `routes/volumes.js`
here already drops the volumes a caller may not reach, which #425 added and
the fork has not caught up with; taking the fork's would have put every hidden
volume's name back in the sidebar. It stays.

tests/routes/permissions.test.js covers what may reach chown, including
`--reference=/etc/shadow` and `root;id`. Against the previous route ten of its
tests fail.

Whole suite: 2859 passed, 2 failed — the two that already fail on main.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants