- /send-pass-recovery-email for `admin`/`system` (by username or email)
returns the usual generic message, writes no token, sends no mail, and
raises a critical alarm with the requester's IP, user agent and, when
signed in, their user.
- /verify-pass-recovery-token and /set-pass-using-token reject recovery
tokens for those accounts as no longer valid.
* fix: a signed overwrite lands on the live key only once its completion is accepted
A presigned overwrite URL was signed against the live object key, so the
browser's PUT replaced the owner's bytes directly. That PUT never passes
through Puter, which leaves the access check on `completeWrite` with
nothing left to protect: by the time it runs, what /read serves has
already changed.
The upload now goes to a key of its own. `completeWrite` is already the
authorized step, so that is where the staged object is copied onto the
live key, server-side, and the staged one dropped. The entry keeps its
uuid, so everything written against it still holds. Aborting deletes the
staged object, which the old shared key made unsafe to do.
`copyObjectOfAnySize` carries the copy past the single-request ceiling
by copying part by part, so a large file is no different.
Two behaviours follow from the key no longer being shared: completing an
overwrite that uploaded nothing is refused rather than reported as an
overwrite of unchanged bytes, and abort always drops the session's
object. Only the two presigned paths changed; `prepareBatchWrites`
uploads server-side within one authorized request and still writes in
place.
* fix: the review's findings on the staged overwrite
The batch signed path still signed against the live key, so the route
the SDK's signed upload actually takes was unchanged. It stages now,
which also makes the abort simplification safe: the guard that had
protected a shared key was removed while that path still shared one, so
aborting a batch overwrite would have deleted the owner's object.
The completion is allowed against a path but lands on the uuid captured
at start, and a move between the two puts the bytes somewhere the caller
was never authorized for. The promote refuses unless the entry is still
where the check looked.
The batch promote failing left every session pending with its lease
held; it now fails them as the rest of the function does.
The pending GUI tile and the thumbnail key were keyed on the staged
uuid, which no entry ever carries, so neither correlated with what the
completion emitted. Responses carry `entryUid` for that.
* fix: do not treat a pre-staging session's key as this upload's own
A session signed by the previous release points straight at the live key,
since `objectKey` was `existingEntry?.uuid ?? uuidv4()`. Completing one is
already handled -- the promote returns early when the two keys match -- but
abort deleted `objectKey` unconditionally, which during a rolling deploy
would take the owner's file rather than the upload's litter.
Abort now deletes only a key the upload owns. The multipart branch was
always safe: discarding parts never touches an object already in place.
* fix: settle which default plan an org seat resolves to, and what counts as paid
Default subscription resolvers ran in registration order, which is boot
order: whichever layer registered first answered first. A seat is
provisioned by a team and its address is never confirmed, so the seat
resolver now states a priority and wins over anything a deployment
infers from an address.
The seat cap asked whether the owner's plan was outside the free set,
which a default plan can be without anyone having bought it. Metering
now reports whether an explicit subscription answered, and the cap rests
on that.
* fix: the review's findings on plan resolution
`fromDefault` was read off which resolver answered, not off which policy
applied. An explicit id naming a policy nobody registered falls through
to the default one, and that read as bought. It follows the applied
policy now.
A deployment with unlimited metering has no explicit resolver, so every
owner on it became unpaid and its teams dropped from the paid seat cap
to the free one. Unlimited answers for what a paid plan would, so it
counts as one.
Also: the new constants had been placed between `FREE_SUBSCRIPTION_IDS`
and its docblock, the register docstring still described first-wins, and
prettier had reflowed an untouched licence header.
* feat: a seat confirms the address its team typed in
An address on a seat is the team's claim about someone else's inbox, and
nothing asked the inbox. It is now demanded and sent a code, so the
address counts for nothing until the seat hands it back. Resolvers that
read a domain are the reason this matters.
No address is still fine and demands nothing: the team is the trust
anchor for the account, not for a mailbox it does not hold.
`/confirm-email` allows an unconfirmed actor, so a seat can sign in with
its temporary credential and clear the demand; nothing locks it out.
* fix: clear the confirmation demand with the address it names
`clearUnconfirmedEmail` releases a seat's address so the inbox's owner can
sign up with it. The demand to confirm that address has to go at the same
write: the gate reads `requires_email_confirmation && !email_confirmed`, and
once the address is gone there is nothing left to send a code to.
* fix: keep a seat's address correctable, and only ask what can be answered
Review of the confirmation demand turned up two ways a seat could be left
holding one it had no way to meet:
- With no mail transport configured, the code is never sent and a resend
cannot reach the seat either, so the demand stood with nothing able to
lift it. The seat is now gated only when a code can actually travel.
- An address the owner mistyped gated the seat on an inbox nobody could
read, and the gate itself closes `/user-protected/change-email`, the
route the seat would have fixed it by. `updateSeatEmail` lets the owner
who typed the address retarget it, and refuses once it is confirmed,
when it belongs to whoever proved it.
Also resolves the owner's plan sequentially, so the second read takes the
cache the first wrote and the pair cannot come from two resolutions, and
restores the licence header a jsdoc reflow had rewrapped.
* fix: let the confirm-email window open before the profile has loaded
Booted from a stored token, the window renders before `window.user` is
populated, and reading `.email` off null threw inside the window itself --
so the one screen that could have asked for the code never drew, leaving a
blank page. Reproducible without a team: sign up, then load the GUI again
before confirming.
The address is now encoded rather than interpolated raw, and both forms of
the line are i18n keys; it was hardcoded English before.
* fix: keep a file name to one line in a subject, and the inbound total to the account
**A name reaches the digest subject as the user typed it.** Subjects compile
with `noEscape` on purpose -- a header is not HTML, and escaping would show
entities to the reader -- and the comment there says free-form values collapse
whitespace upstream. A file name does not: `#assertUsableName` refuses only an
empty name, a slash, `.` and `..`, so a name may carry newlines. It is
collapsed to one line and bounded before it goes in.
**`includeTotal` answered an app with the account's figure.** The page is
filtered by what the caller can reach -- an app sees only what its user handed
it -- while `countByHolder` counts every inbound share. The docs already call
`total` an approximation for the filtering; they did not cover an app reading
the size of everything else. It is answered only for a caller acting as the
account now, and the page documents that.
The team-share fixture built its actor as `{ user }` with no `effectiveApp`,
which reads as an app rather than a session. A session acts as the account, so
it says so.
* fix: stop `see` reaching inside a folder, and bound mode changes
**A `see`-only folder recipient was pushed every child's name, size and type.**
The fan-out picked holders by reach -- any live grant on a reaching root -- and
`see` is a grant. But `see` says a node is there; `list` is what says what is
inside it, and the payload carries exactly what `list` would: name, path,
`is_dir`, type, size, modified. Descendants now go only to a holder whose mode
reaches inside. A change to the shared node itself still goes to everyone who
holds it, which is what `see` covers.
**Mode changes skipped the daily quota entirely.** Not spending share budget is
right -- changing a mode is not new reach, and the existing test says so -- but
unbounded it lets one account write a pair as fast as it can ask. They have
their own budget now, ten times the share limit by default and configurable,
so the exemption stays an exemption from *that* budget rather than from any.
* fix: let a half-finished downgrade be finished, and stop Trash answering
**A mode downgrade that failed between its two halves could not be repaired.**
`setUserUser` grants the new mode and then revokes the superseded ones. If the
revokes did not all land, the higher grant stood -- and the retry returned
early, because the mode it was asked for was now present. "Nothing to do" now
means this mode is the only one standing, so asking again is what finishes it.
The same two halves, and the same early return, were in the group variant.
**Trashing someone else's item answered for what is in their Trash.** Moving
into the owner's Trash is deliberately authorized without write on it -- that
is what lets a recipient delete from a shared folder -- but a name collision
there came back as `An entry already exists at /owner/Trash/<name>`. The mover
picks the name, so that is a question they can ask about a folder they cannot
list. It dedupes instead, which is also what the GUI wants: trashing should
not fail over a name.
* perf: record the entry's owner on the share row
The outbound listings answer "shares on things I own that someone else issued"
by joining `share` to `fsentries` and filtering on the owner there. Neither side
of that join bounds the work: driven from `fsentries` it walks every file the
user owns, driven from `share` it walks every share until it has a page. For a
user who owns a lot and delegates nothing, that is the whole table for an empty
result. `#outboundAppsSql` is worse than the ticket says -- it has no LIMIT at
all, so every call aggregates the join across both tables.
The owner now rides on the row, indexed with `id`, so all three read it
directly. An entry cannot change owner -- a move out of the owner's tree is
refused -- so it is written once, with the row.
Set in SQL from `fsentry_id` rather than threaded through callers: four insert
sites, each of which already has the id, and none of which can now forget.
The risk in denormalising is a write path that does: a row with no owner
recorded is a share that silently stops appearing in its owner's listing. The
new test covers exactly that -- it fails when one insert is made to write NULL.
Verified against a real boot: a pre-existing share row, on a database that
genuinely predates the column, comes out with the owner its entry has.
* fix: the review's findings on this branch
**The Trash collision still had both halves open.** `overwrite` was checked
before the trash case, so a caller setting it -- it is wire-supplied -- could
still delete whatever it collided with in the owner's Trash, and the 409
survived for anyone who did not. The trash case is decided first now.
Dedupe alone did not close it either: a name that changes only when there is a
collision answers the same question the refusal did, and the mover sees that
name even though the path is masked. Trashing someone else's item renames it
every time, so the result carries no signal. `original_name` already records
the real name, which is what a restore reads. Two tests pinned the old name;
they say what it is now.
**The owner column had no fallback.** Reads went only through it, so a row
written by anything that predates it -- a pod mid-roll -- would never appear in
its owner's listing again. The three reads now also cover `IS NULL` rows
through the old join, indexed on the null and empty once none remain.
**The grant became conditional on a replica read.** `if (!already)` skipped it
on a stale row while the revokes below stayed unconditional, which could leave
the holder with nothing where the old early return left their access alone. It
grants unconditionally again; only the early return changed.
**The re-issue budget covered one path of four.** Team, link and invite
re-issues reserved nothing, so three ways round it were left open. They share
one `#reserveRemodeQuota` now.
Two docblocks had been pushed off the functions they describe, and two comments
still described the join that is gone.
* chore: one-line the comments added here
* test: measure the prefix walk by what it keys on, not by the clock
`checks many long paths without re-reading every prefix of each` gave
the walk 1500ms of wall clock. It measured nothing useful: the shape it
guards against — keying each step on the accumulated prefix text rather
than on the parent id — costs 29ms against the current 8ms on that
input, so both ends sit far under the bound. Under coverage the same
test ran 1511ms and failed, which is the only thing the number could
ever report on.
`PathPrefixIds` now counts the characters it keys on, and the walk is
asserted on that: proportional to the input, and twice the depth costs
twice as much rather than four times. Keying on the accumulated text
fails both. The original case keeps its real assertion, that a request
this size is refused.
* fix: keep the recorded entry owner current, and the trash guard on the retry
A move into someone else's tree re-owns the whole subtree, which the
migration comment claimed could not happen. The shares on it are
re-pointed at the new owner, or they drop out of the listing the column
keys and stay in the old owner's.
The unique-violation retry re-entered the collision resolver without the
trash flag, which is the one call site the guard was not threaded
through.
* auth: per-account 2FA code limit, single-use otp-login tokens, recovery mail limits
- /login/otp and /login/recovery-code share a per-account bucket charged on
wrong codes; an otp-login token completes one sign-in.
- /send-pass-recovery-email adds an IP bucket, a per-account cap and captcha,
and reuses the outstanding recovery token instead of rotating it.
- Reauth tokens carry the rejection reason; /signup re-attaches a temp
account only from an expired session.
* auth: app-issued access tokens older than their app stop authenticating
An access token whose row or parent session predates the app now holding its
uid is refused, the same check app and worker sessions already get.
* teams: signup can take an address a seat never confirmed
Password signup, save-account and a provider-verified OIDC sign-up take an
address a team seat holds unconfirmed; the seat's copy is cleared at the
write. A seat address that was confirmed still blocks.
* dev-center: encode dropped directory child names
* fix(events): bill reached deliveries only, bound dispatch reads, seal cursors
- A broadcast socket copy is billed only when a connection for its holder
exists on this node, anywhere in this region (connection count) or, for a
durable row, in a region presence names. Without peers it bills as before.
- Compiled match filters are held in a bounded LRU with a TTL, and dropped
when another process reports a subscription ended.
- GET /events/fetch reads the continuation from `cursor`, falling back to
`after`.
- Cursors from /events/fetch, /events/subscriptions and /events/kv-handles
are encrypted with sealCursor/openCursor; plain cursors still read.
- A durable subscribe counts again after its insert and removes its own row
when over the cap; the generation bump for that removal is published.
- Subscribe checks access before reading the anchor's owner, and an anchor
gone in between answers with the subject the caller sent.
- Dispatch reads at most the rows per token routing can use, scanning large
token hashes; a removal still settles every row on the removed anchor.
- puter.events.unsubscribe() stops routing after the server ends the
subscription or reports it gone; fetch() accepts `cursor`.
* fix(events): forward kv values only to regions with rows that ask
- A region counts its session rows asking for kv values per token and
announces the token's value variant (`v#<token>`) over the existing
`watch` item on the first such row, withdrawing it with the last. Add,
remove, reap, reanchor and refresh all keep it, reading `includeValue` off
the row as it is dropped.
- A kv write reads the value variants alongside the watch index and attaches
the value only to forwards bound for a region that announced one.
* fix(pagination): seal id-keyed cursors on every list endpoint
Apps, subdomains, workers, team (members, directory, teams, audit, own
audit), shares (inbound, outbound), the user-to-user permission audit and
readdir/descendant listings now hand out cursors sealed with sealCursor and
read them with openCursor, so a cursor no longer carries a row id. Plain
cursors issued before still read.
* test(events): pass socket ids to noteConnect in the billing tests
* fix(events): keep each region's presence item written only by that region
- A region that receives a forward for a pair it holds no socket for
retires its own presence item through the pin-ordered leave path,
gated by a region-shared claim. The sender no longer retires another
region's item, and the forward reply no longer carries noSocket.
- Presence reads skip items for regions outside the peer list instead
of retiring them.
- removeConnection decrements and deletes the count in one script.
- A failed join releases the join pin, and any connect that finds the
pin free writes the join.
- A single delivery treats a socket on any node of the region as local,
using the region-wide connection count, and picks the next remote
candidate by the regions already tried instead of by index.
- Correct the connection-count TTL comment.
* fix(events): count presence by live socket and honor the leave window region-wide
- A region's presence for a pair is a sorted set of socket ids scored by
last renewal under a new key, replacing the integer count. A socket
counts while renewed within the concurrency-slot window; add, renew and
remove each prune lapsed members and report the live count in one
script, so a node that dies stops holding the pair.
- The last socket going marks the pair as leaving region-wide for the
grace window, in the same script; a connect clears it. A forward that
lands on another node inside the window does not retire the item.
- Add the events.presence.write counter, labelled by op (join, leave,
refresh, retire).
* fix: settle the url file-access gate from a grant held on a folder above
The gate asks `/auth/check-permissions` with `app_uid`, which resolved
through the permission scan. `fs:` access inherits down a tree and only
ACL walks that, so a grant on a folder never answered for a file inside
it and the gate asked again, writing another per-file row on each Allow.
The `app_uid` branch now routes `fs:<uid>:<mode>` through
`ACLService.check` as the app-under-user actor, which is the same
authority the operation itself will face. Everything else keeps the
scan, including `puter.perms.holds`, which sends no `app_uid`.
* fix: the review's findings on the consent gate
A permission that is not a string reached `startsWith` and faulted the
whole request, where it used to answer false inside the catch; both
parsers now refuse a non-string, which also covers the other callers
that read a request's list.
The path spelling of a permission took the scan while the uid spelling
took ACL, so one file could answer two ways. Both spellings resolve to
their entry and take the same route.
The checks run together rather than one after another: the caller gives
up after five seconds and prompts, and a deep path is several reads.
* fix: pin the token sender, scope the grant flag, and stop two stragglers
Five of the SDK auth-plumbing items.
**The global `puter.token` handler pinned origin but not source.** Its three
siblings all pin both, and each says why: the GUI origin is shared by every
frame on that domain, so origin alone identifies a domain, not a sender.
`signIn` pins its popup and binds `msg_id`, the consent dialog pins its popup,
and the reauth wait pins the embedder. This one pinned the embedder only when
`env === 'app'`. Since `authenticateWithPuter` now hands off to `signIn`, which
adopts the token itself, the embedder is the only sender this handler is left
serving, so it pins in every environment.
**`is_grant_user_app_permission` was a request-wide flag held across an await.**
It tells the `app-root-dir` rewriter that a row is being written, and while it
is set that rewriter resolves to a real `fs:` permission instead of
`PERMISSION_FOR_NOTHING_IN_PARTICULAR`. Anything evaluating an `app-root-dir:`
permission beside the grant -- the grant route runs one under `Promise.all`
with fs work -- read the same flag. It now lives in a scope derived for that
call, so what the write needs to say about itself is not also said to its
neighbours. `runInDerivedContext` is new: `runWithContext` starts an empty
store, which would drop the `actor` the rewriter reads.
**`fs.stat` and `fs.readdir` deduped on parameters alone**, so two identities
in one realm could share a result. They now key on origin and token as
`os/user.js` does, which documents the reason.
**`signIn`'s relay poll never stopped.** Closing the popup settles the promise;
the loop kept asking `/login/wait` every second for the life of the page.
**`triggerReauth`'s five-minute timeout was never cleared**, so a successful
reauth left a timer to fire into a settled promise.
The concurrency test parks the rewrite and reads the flag while it is held --
my first attempt read before the flag was even set and passed either way.
* fix: scope the fs cache per identity, and retarget two paths that never ran
The remaining SDK auth-plumbing items.
**`puter._cache` outlived the identity that filled it.** `_clearAuthToken`
dropped the token and the stored session but left the cache and `whoami` as
they were, and its keys were raw paths -- `item:/alice/Documents` and a
`~`-relative key alike, identical whoever asked. A GUI user switch without a
reload could be served the previous user's stat or readdir. Keys now carry the
origin and token they were read as, built in one place so the writers, the
readers, the invalidations and the warm-up probes cannot drift apart, and
signing out drops the cache and `whoami` with the token.
**The driver permission prompt could not fire.** Both paths waited for a 200
carrying `{success:false, error:{code:'permission_denied'}}`. The driver route
answers `success: true` or throws, so a refusal arrives as a 403 -- the shape
the retry now reads. Its two tests synthesised the old envelope, so they passed
against a response the API does not send; they use the real one now, and the
prompt goes from never called to called once.
This is a behaviour change: a driver call refused for permissions will now
prompt, which is what the path was written to do. Happy to delete it instead if
the prompt is not wanted.
The half-written copy of the same idea in `utils.js` is gone -- it prompted for
one hard-coded interface and its retry was a `// todo`.
**The verification-gate single-flight was keyed on nothing**, so a phone-gated
request in flight beside an email-gated one took the email answer, replayed,
failed again and surfaced raw without ever asking. Keyed per gate: same gate
still shares one dialog, a different gate gets its own.
* test: ask the cache the same way the code does
The API suites built `item:<path>` keys by hand, so they stopped finding
anything once the real keys carried the identity they were read as. They go
through `fsCacheKey` now, which is the point: a test that spells the key itself
passes until the moment the spelling changes, and then fails for a reason that
has nothing to do with the behaviour it is guarding.
These run against a live server, so a plain `vitest run` does not reach them --
they were green locally and red in CI.
* fix: the review's findings on this branch
Eight, and three of them were regressions this branch introduced.
**Pinning the token sender to `globalThis.parent` in every environment broke
the case it was meant to protect.** On a top-level page `parent` is the page
itself, and the token arrives from a popup, so the handler stopped accepting
anything. The GUI posts `puter.token` to `window.opener` for the file pickers
too, not only for sign-in, and this handler is their only consumer -- a site
calling `showOpenFilePicker()` would have signed the user in, dropped the token
and run the following read with none. It now pins the embedder when framed and
a window this SDK opened when not; `UI.js` already tracked its picker popups
for the same reason, so that set is shared rather than duplicated.
**The GUI still spelled a cache key by hand.** `updateSubdomainsForItems` built
`item:<path>`, which no longer matches what the SDK writes, so its `get` could
not hit and its `set` wrote an orphan nothing reads and nothing evicts -- a
published website's badge quietly stopped appearing on a cached render. It goes
through `fsCacheKey` now, and that is the last hand-spelled key in the tree.
**Retargeting the driver permission prompt to 403 was wrong and is reverted to
the deletion the ticket asked for.** The route refuses with `forbidden`, not
`permission_denied`, so the retarget still missed it; meanwhile a driver that
raises `permission_denied` for its own reasons would have opened a prompt that
had nothing to do with the refusal. And the prompt asks for
`driver:<iface>:<method>` while the route checks `service:<driver>:ii:<iface>`,
so granting it changes nothing. The path cannot work as written.
The rest were pre-existing or latent:
`cacheWhoami_` wrote its answer without checking the token it was issued under
was still current, so a reauth landing mid-flight could repopulate `whoami`
with the previous identity. `_dropIdentityCaches` also left `whoamiCache_`,
which other call sites await.
`setAuthToken` now drops the identity caches when the token actually changes:
keys carry the token and the store is persisted with no TTL, so a rotation that
did not go through `_clearAuthToken` orphaned a whole key space with nothing to
reclaim it.
The verification-gate entry was registered after the body could already have
deleted it -- a synchronous throw stranded a resolved "not verified" in the map
for the life of the page. It registers first and clears on a microtask.
`signIn`'s poll is bounded as well as settle-checked: on a cross-origin
isolated page opened from a gesture, nothing watches the popup, so closing it
never settled the promise and the loop ran on exactly as before.
`runInDerivedContext` says in its docblock that callee writes do not survive;
only the one flag needs the scope, and a future caller should not expect to
hand a value back through it.
* fix: the review's findings on the SDK auth plumbing
**The relay bound stopped asking without saying so.** The deadline left the
`signIn` promise pending and its listener attached. On an isolated page opened
from a gesture the relay is the only thing that can settle it -- nothing watches
the popup there -- so a user who took longer than the deadline was left with a
promise that never resolved, and `authenticateWithPuter` queues every later
implicit-auth call behind it. It rejects now.
**Pinning the token source left `signIn`'s own popup untracked.** Only the
picker popups registered, so on a top-level page the global handler bailed on a
sign-in it had opened itself: `puter.onAuth` never fired, and
`puterAuthState.authGranted` was never set. `openAuthPopup` is the one place
auth popups are opened, so they register there.
**A closed popup is ours too.** Rejecting one that had already closed loses a
message from a popup that posts and then closes itself, which is why `UI.js`
tolerates the same case. Kept, with the set bounded instead.
**The gate key carried the factors**, so one route naming them and another not
opened two dialogs for the same gate. It keys on the gate, as `ctx.done` does.
**`fsCacheKey` left `~` unresolved**, so a writer and an invalidation spelling
the same place differently missed each other -- the drift the one key builder
was meant to end.
The dead `opts.permission` went with the path that used to read it.
Four workerd-runner failures are left: `ai > chat stream adapter supports
cancellation`, `kv > a GUI boot key written by another client reads fresh` and
two cache-warmer ones. All four fail the same way on `origin/main`, and the node
and browser runners pass.
* chore: one-line the comments added here
* chore: reflow the comments the removed retry cause left ragged
The duplicate-username check in the signup placeholder claim, change-username
and save-account runs before the user-row write, so a concurrent write can
take the name in between and the unique index rejects ours. Each path now
re-reads the holder from the primary on a unique violation and returns the
400 its pre-check returns for a taken name, instead of a 500. In all three,
the home-directory rename, rename budget charge and username-changed event
run after the user-row write, so nothing has happened yet when it fails.
- signup: a username taken by a concurrent signup between the duplicate
check and the insert now returns the pre-check's 400 instead of
surfacing the unique-key error as a 500. The holder is read from the
primary.
- fs move: when the UPDATE trips the (parent_id, name) unique key after
the collision probe missed the occupant, re-read it from the primary and
resolve it the same way the probe would have (overwrite, dedupe, or the
409 item_with_same_name_exists), then retry the move once.
- server health: a check that hits the per-check timeout reports how long
the event loop was blocked during it, like the database-liveness latency
failure already does.
- Chat driver runs each attempt through one helper and checks the abort
signal only on entry and in the attempt catch blocks, where it keeps a
cancelled request from marking the route unhealthy.
- A stream ended after cancellation still emits ai.prompt.cost-calculated;
only ai.prompt.complete is skipped.
- pipeNdjsonStream owns its line buffer and onEnd is optional; drop the
errored guards that can no longer fire.
- Drop the Claude post-loop abort check (finalMessage already throws).
- Skip provider error logs when the caller cancelled.
- Simplify the usage_limited cap condition.
Local testing against a running instance found the service-side fix was not
enough. Both answers were 403, but they did not read the same:
exists -> permission_denied: fs:/other/rd:read
missing -> Cannot create outside your home directory (forbidden)
The second comes from `#planFsPathCreate` in the controller, which probes the
path itself and only calls the create guard once it knows the path is absent.
Its comment said so outright -- checked "only once the path is confirmed
missing, so an existing shared path outside the caller's home is not a new
rejection" -- which is the oracle stated as an intention.
A foreign path now refuses word for word whichever it is. Responses are
byte-identical apart from the caller's own input.
`rewritePermissionForActor` collapses the create guard's `forbidden` the same
way it already collapsed the 404, for the paths that reach it through the
service rather than the controller.
The existing test pinned `forbidden` here; its subject is that nothing is
created in someone else's tree, which still holds. Alongside it is the
invariant itself: refuse a present and an absent foreign path, mask the path,
compare the whole error. Without the controller check that fails.
**The path oracle was not closed.** The rewrite only remapped the *error*, so
success-vs-403 still answered whether a foreign path exists. Narrower than
reported: every other grant path already gates on `canManagePermission`, and
only `grantUserAppPermission` has no authority check -- deliberately, since
granting your own app something you don't hold is inert. It now refuses a
foreign path the actor cannot see, with the same error a missing one gives.
My first test for this passed without the fix: `makeUserActor` creates no
fsentries, so both paths 404'd and nothing distinguished them. It inserts a
real entry now, and the control fails with "expected null to match 403" --
null being the grant that succeeded, which is the oracle.
**`#validateEmail` still failed open** on the main signup / change-email /
confirm path while its OIDC twin was hardened. Same shape as OIDC now: a gate
that did not run is not a pass.
**The 16-permission cap broke `puter.perms.check()`** for longer lists. The
SDK's own docblock promised "one round trip however many are asked about",
which the cap made untrue, so it batches instead. The reported second half --
the UI grant poller burning its timeout -- is not reachable:
`MAX_REQUESTED_PERMISSIONS` already caps that path at the same 16.
**`listener_failed` was set by wildcard listeners.** An unrelated observer on
`email.*` throwing would refuse every signup. Only an exact-key listener
implements the event, so only that one speaks for the gate.
**`whoami()` labelled everything `Unauthorized`.** A 502 with an HTML body is
not a 401; the server's `code` now rides along and the fallback names the
status.
Switching `getSubtreeSize` to `entry.userId` fixed a recipient reading 0 for a
shared folder, but `/stat` is gated on `see` and `return_size` is caller-
supplied. `see` is the weakest level -- the folder is visible, what is under it
is not -- so the sum now covered entries the caller had no way to enumerate.
It is answered only for a caller who passes a `list` check, which is where the
contents become enumerable anyway. Everyone below that gets the directory row's
own size, which is what they got before this branch.
`#assertAccess` grew a non-throwing sibling for this; the descriptor both need
moves into `#aclDescriptor`.
Test grants `see`, asserts 0, grants `list`, asserts the real size. Without the
gate the `see` case reads the file's bytes.
Four, all real.
The gate threw on a non-string. `validator.isEmail` asserts its argument, and
two call sites hand it an unvalidated body value -- `send-pass-recovery-email`
is public, so `{"email": 1}` was a 500 rather than a 400. The check belongs in
the gate, not in each caller, which is the whole premise of having one.
OIDC signup never shape-checked the claim at all. `claims.email` went from a
truthiness check straight into `user.create`, so a provider we do not run could
store exactly the string this branch exists to keep out. `email_verified`
attests the mailbox, not the characters.
The change-email confirm handler promoted the staged column without re-reading
it through anything. A row staged before this deploys still lands on `email`;
one check at promotion closes that window.
The invite path kept its own regex, and it was never the same rule. It admitted
`a..b@x.com`, `.a@x.com`, `a@-x.com`, `a;b@x.com` and `a,b@x.com` -- the last of
which nodemailer reads as two recipients -- each one a permanent pending row
that spent daily quota, which is the failure the comment above the constant
said it existed to prevent. It uses the gate now, and the second regex is gone.
A regression test each, all four confirmed to fail without their fix.
`validator.isEmail` accepts a quoted local part, so an address whose local part
is an arbitrary string passes it. Five call sites relied on that check alone
before writing the address to `user.email`, and the invite path's own shape
regex was equally permissive.
`isStorableEmail` is now the single gate: the library check, plus a refusal of
`<`, `>` and `"`, which are legal in an address only inside the quoted form.
Nothing deliverable uses that form, so the rule costs no real address --
apostrophes and ampersands in a local part still pass, and the suite covers
them.
One function rather than a check each caller has to remember, because the next
place that stores an address will not remember.
Six findings, all real.
`total` described rows the page withheld. The count ran over every live team the
user belongs to, while the app-actor filter ran afterwards on the items only, so
`GET /teams?includeTotal=true` from an app returned `items: []` with a non-zero
`total` -- the exact thing the filter exists to prevent, and newly reachable
because `includeTotal` was inert before this branch. The directory-open
restriction moves into the query, so items, cursor and total all describe the
same set.
The GUI told owners the opposite of what the server now does: "we email the
username and temporary password to it." An owner who believed it would dismiss
the one copy of the password and strand the seat. Rewritten.
`runWithConcurrencyLimit` rejects on the first error but cannot cancel the
workers beside it, so a failed seat let up to seven others finish suspending and
emailing "your team has been closed" for a team that was still live. The page
now settles before the rejection is rethrown, which leaves a state the retry can
resume from instead of an arbitrary one. My earlier claim that failure
semantics were unchanged was wrong.
`#disableSeatForDelete` returned `true` on both paths, so the boolean read as if
it distinguished them. It returns nothing; the counter uses the settled results.
`listTeamsForUser` had picked up a second stacked docblock -- the same defect
this branch removes from `#cached` one file over.
`reissueCredential` sent the provisioning mail, which after the credential
change was a byte-identical duplicate saying nothing about the password it had
just invalidated, while the GUI promises the member hears about it. It has its
own template now.
Regression tests for the count leak and the reissue notice; both confirmed to
fail without their fix.
Both docblocks on the directory route said it carries nothing a colleague
cannot already read through `/members`. It carries `uuid`, and `/members`
withholds `uuid` from everyone but the owner, with its own comment pointing
apps at the directory to get one.
The disclosure itself is the opt-in working as designed: `directory_enabled` is
the owner's consent to expose a roster, every member reaches the route the same
way whether they call it from a session or through an app, and the SDK and the
teams recipes already document the directory as where a stable id comes from.
So the comments are what was wrong, not the route.
Removing `uuid` from the directory would be the other way to make them agree,
but it breaks `listDirectory()` for every caller and contradicts the published
recipes; that wants a decision, not a cleanup commit.
`listTeamsForUser` returned every row and the controller never read `req.query`,
so `limit`, `cursor` and `includeTotal` were accepted and ignored on the one
team route that did not paginate. The docs promise a capped page there, and
every sibling listing already honours it.
Keyset-paginated on `id` per doc/pagination.md, with the envelope the other
routes return. The app-actor directory filter still runs over the page, so an
app sees the same teams it saw before, just a page at a time.
`/auth/check-permissions` took an array of any length. `checkMany` batches the
scan so it is not the fan-out the report describes, but nothing stopped a caller
sending thousands of strings; it now takes the same 16 the grant and revoke
routes take.
`whoami()` returned `resp.json()` without looking at the status, so an expired
token resolved to an error body typed as a `User`. Callers read `username` off
it as undefined — which is how a consent prompt came to ask for
`/undefined/Documents`. It throws the documented `{ status, message }` shape
now, the same one the no-token path already threw.
The signup paths read `allow` off a payload they initialise to `true`, so a
harness listener that throws leaves the verdict untouched and the signup goes
through. The outer try/catch the report points at is not where it happens:
`emitAndWait` already swallows each listener's error and carries on, so that
catch almost never fires and the throw never reaches it.
`emitAndWait` now records the failure on the metadata it was handed, and both
signup paths — OIDC and the ordinary one — refuse when it is set. Other emitters
are unaffected: the flag is only read where somebody is asking for a verdict.
The report named OIDC alone; the ordinary signup had the same hole, and it is
the path that matters more.
Test boots a server, registers a listener that throws, and posts a signup: 200
before, 403 now.
Two unrelated bugs in the same area.
`stat({ returnSize: true })` summed `fsentries` filtered on the *acting* user,
so a recipient asking for the size of a folder shared with them matched no rows
and read 0. The sum is over the owner's rows; the ACL check that let them stat
it is what decides whether they may ask at all.
`fs-access-levels` destructured the mode out of the permission but kept the
`.slice(1)` that used to skip it, so widening `fs:<id>:see:thumbnail` produced
`fs:<id>:read` and dropped the sub-scope. Anything granted below the mode stopped
being implied by a wider one. Latent — nothing ships a sub-scope today — and the
test fails without the fix.
`perms.request('appRootDir', { app: <someone else's> })` could never succeed —
only the app itself may claim its root dir — but the SDK read the refusal as
"not granted yet", prompted the user, took the grant, then polled until it gave
up and resolved undefined. A prompt the user cannot act on, and a live grant for
a directory the app will never reach.
The route now says which kind of refusal it is. `forbidden` alone could not
carry it: the SDK already treats that as the refusal a grant is about to lift,
and a unit test pins exactly that sequence, so a new `reason: 'app_mismatch'`
field rides alongside instead of changing the code clients read.
An entry refused that way is held back from the pooled grant and resolves
undefined without a prompt, which is what it resolved to before.
`fs:/path:mode` resolves through `getEntryByPath` with no ACL check, and a
missing entry answers 404 while an existing one carries on to the authorization
check and answers 403. The pair is a membership test for any path on the
deployment: grant routes take the permission as given, so anyone could ask
whether /someone-else/taxes.pdf is there.
The resolver keeps its 404 for a path inside the caller's own home, where
knowing it is missing is the useful answer and reveals nothing. Everywhere else
it answers with the same `permission_denied` the authorization check gives, so
the two cases are one case. ACLService already collapses them; this is the same
rule reaching the grant and revoke paths, which take an actor and so can tell
whose tree is being named.
Internal resolution still goes through `rewritePermission`: only the paths that
take a caller-supplied permission moved over.
The test grants on an existing foreign path and a missing one and asserts the
outcomes are identical; without the fix they are 403 and 404.
Narrows the wire-shape changes to the callers that need them.
- request-level upstream 4xx stays strict only on the Anthropic route;
/drivers/call and the OpenAI routes fall back on any error again
- outage chains return 400 with the old codes; the Anthropic error
renderer maps them to 502/529 from the carried upstream status
- native Claude results report finish_reason 'stop' again (real reason
in stopReason); OpenAI routes still map the real value, and a stream
that carried tool calls never ends as a plain stop
- Claude usage carries thinking_tokens again, informational only and
added after pricing and the funds cap
- reasoning_effort is clamped to values each OpenAI model accepts;
Hoonify, MiniMax, ZAI and Meta only receive params they support
- OpenAI-form tool_choice from /drivers/call is normalized again
- /responses accepts background:false; mcp_servers is ignored; legacy
media block shapes pass the Anthropic route's validation
- OpenAI web search holds one call; only search actions are billed;
Azure bills Bing grounding requests; usd_cents includes search fees
The Anthropic-compatible route flattened requests into the OpenAI-style
driver shape and rebuilt them for Claude, which dropped system blocks,
thinking, tool_choice, cache_control, betas and stop reasons. Claude
Code requests 400ed upstream and were silently served by a reseller.
- normalized driver args, stream chunks and results gain tool choice,
thinking, output format, stop sequences, caching, stop reasons, usage
details, reasoning signatures, server-tool blocks and safeguard results
- ClaudeProvider builds requests from those fields with a beta, tool and
body-field allowlist; meters fast mode, web search and advisor calls,
and no longer bills thinking tokens twice
- fallback only on route-level failures; outages map to 502/529
- vendor error envelopes on the Anthropic and OpenAI routes
- Anthropic route: native SSE with pings, count_tokens, /v1/models
- OpenAI-family providers and routes map the same fields; Responses web
search is metered; reasoning_effort gating fixed; /responses rejects
org-scoped stateful params and forces store:false
- puter.js: typedefs, new chat options, silent toString for new chunks
* feat: let the account's own API token reach the routes it should
A privileged app is launched with the GUI session token today. Running one on a
full-access personal access token instead costs much less when it leaks, but
several routes those apps depend on refuse every access token, so the swap would
break them.
Admitted, each a read or a write the token's own HTTP reach already covers:
- the realtime socket handshake, which refused access tokens outright; FS
live updates and notifications stop without it
- GET /get-dev-profile and POST /profile, both puter.js surface
- GET /auth/list-permissions — a read of what was granted, not a grant
- GET /share/shared-by-me/apps and GET /share/audit, over grants
/share/shared-by-me already lists for the same credential
- POST /rao, which puter.js calls on every setAuthToken
Gated on `isAccountContext`, not the `full_access` claim alone, so a token with
an app anywhere in its chain is still refused and a socket for one never joins
the user room.
Left session-only: mint, session, 2FA, grant, team and billing, plus two the
list named. /share/blocks is a personal safety control whose only caller is the
desktop's Blocked Senders window. /app-feedback is reachable only from our own
GUI pages, and being unsubmittable programmatically on a user's behalf is the
property it was built with.
The new HTTP suite drives a real server with a minted token and fails on all six
admissions without the change; the block routes' existing metadata check already
pins their refusal.
* test: sort the two token shapes at the events handshake
* fix: carry the account-context invariant into the HTTP gate
`requireUserActorGate` admitted on the raw `full_access` claim while the socket
and /rao use `isAccountContext`, so one shape — a token carrying the claim with
an app in its chain, or an actor built without `makeActor` — would have been
refused in two places and admitted in the third. Unreachable today, since the
mint and the auth-time read both drop the claim when `app_uid` is present, but
it is the invariant this change states and it should hold at every enforcement
point.
The payout address goes back behind a plain session: `/get-dev-profile` is open
to the account's own token now, and the rest of what it answers is a developer's
own name and incentive-program status.
Also drops a cross-reference in EventsService to the socket posture this change
relaxed, and rewords the block-routes comment, which read as if access tokens
were admitted there rather than refused.
* fix: drop a revoked token's sockets, and read the dev-profile columns
Two gaps the review found, both reachable only because this branch lets the
account's own token hold a socket and call /get-dev-profile.
`#revokeAccessTokenTail` soft-revoked the session row and told no one, so a
revoked token kept its connection — and the account's `outer.gui.*` fan with it
— until the five-minute reauth sweep. Sockets now join a per-token room and a
new `auth.access-token.revoked` event drops exactly that room, so the account's
other tabs stay up. Session revoke keeps its account-wide eviction.
`/get-dev-profile` read `first_name`, `last_name`, `paypal` and the two
incentive flags off the user row, which carries none of them: the columns have
always been `dev_`-prefixed, so the endpoint answered nulls to everyone and
`puter.apps.getDeveloperProfile()` has never returned anything. Reads the real
columns, keeping the unprefixed name as a fallback for a deployment carrying
both. The payout address stays behind a plain session, so opening the route to
the account's token hands over a name and incentive status and nothing else.
Both fail without the fix: the socket test waits five seconds on a connection
that should already be gone, and the profile test reads back a seeded row.
* fix: close /open_item to tokens, and stop the node runner leaking sockets
`/open_item` carried `allowFullAccessToken`, but it grants an app a permission
on the user's behalf and mints the app token to go with it — a grant and a mint,
which stay session-only. Pre-existing, and missed by the first sweep here, which
only asked whether routes lacking the flag should gain it and never re-read the
ones that already had it. Refused at the top of the handler too, so a credential
that gets past the gate cannot leave an ACL row behind.
The node runner loads each SDK into a vm context and never closed it, so every
test leaked a socket, and every socket holds a per-(user, origin) connection
slot. Admitting the account's own token to the handshake pushed that past the
cap, and the whole events block failed with `events_connection_failed` while the
browser and workerd runners — which do not leak — passed. Closing the FS socket
and the events channel after each test gives the slots back.
* fix: announce only the revokes that can have a socket to drop
Every access-token revoke broadcast an eviction cluster-wide, including the
read-URL tokens `revokeReadUrl` retires constantly. A scoped token is refused at
the handshake, so none of those broadcasts could ever reach a connection.
The announcement now goes out only for a token the handshake would have
admitted: full access, no app in the chain. `revokeOwnAccessToken` refuses
full-access tokens outright, so that whole path is silent; a revoke by raw uuid
says nothing about the token and still announces, which costs a no-op broadcast
rather than leaving a revoked socket up.
Fails without it: the test revokes a scoped token and a full-access one through
the same entry point and counts the announcements.
* test: pin the mint route against the account's own token
`/auth/create-access-token` carries only `requireAuth`, so it never showed up
in the sweep over `requireUserActor` routes — the refusal lives in
`AuthService.createAccessToken`, which turns away any access-token actor
outright. That holds today, and a leaked full-access token minting itself a
sibling that survives revoking the original is exactly what the token swap is
meant to rule out, so it is worth a test rather than a reading.
* fix: let the recommended-apps test follow the list it tests
Two commits on main rewrote `RECOMMENDED_APP_NAMES` without touching the test,
which seeds `editor` and `camera` and asserts the result contains them. Neither
is on the list any more, so the resolved set came back empty and `test (base)`
has failed on every PR since.
The test now seeds the first two names off the list itself and asserts on those,
so the next edit to it cannot strand the test: prepending two unknown apps keeps
all four cases green.
* feat: let an extension waive a plan gate for one route or driver method
A plan gate about to refuse a caller now emits
`subscription.gate.<surface>` through `emitAndWait` first:
`route.<method>.<path>` for a route, `driver.<iface>.<method>` for a driver
method, `share.anyone` for link shares. The payload carries the actor, the
request and the requirement, and a listener that sets `allow` waives the plan
for that call. Callers the plan already covers never reach the hook, and a
listener that throws leaves the refusal in place.
* feat: mint godmode apps their own full-access token
`/auth/get-user-app-token` answers a desktop launch of a godmode app (the
`godmode` column) with a full-access token instead of an app token. Origin
lookups, the sign-in popups for pages outside the desktop, keep getting
ordinary app tokens, and only a web session may mint one.
The token carries the app in `godmode_app_uid`, read into
`accessToken.godmodeApp` for attribution only: `effectiveApp` stays null, so
it keeps the account's reach and passes the same gates as a personal access
token, including the vendor-compatible AI routes. It is refused once the app
is gone or no longer godmode.
Its row is an `access_token` session parented to the web session that asked,
with `app_uid` set, so signing out, revoking that session or revoking all
sessions takes it along, and the sessions list shows it under the app. Each
mint re-signs onto the same row with a 12h `exp` and moves the row's expiry
out, so the desktop keeps an open app alive while a copy taken earlier still
expires on schedule.
A full-access token may now mint scoped tokens (read URLs), parented to its
own row; it still may not mint full access. Revoking a full-access token
cascades to the tokens it minted.
An access token whose session row has a parent now authenticates only while
that parent is live, so a read URL a godmode app minted lapses with the app's
token, and through it with the desktop session.
* feat: launch godmode apps on their own token and renew it
The desktop asks for a godmode app's token at launch and blocks the launch if
it can't get one, as it does for other apps; a godmode app never runs on the
desktop's session token. While the window is open the desktop re-mints the
token once less than 6h of its 12h remain, checking on a timer and when the
tab becomes visible, and answers the app's `reauth_required` with a fresh one,
posted to the app's origin.
puter.js keeps a godmode token in sessionStorage like a session token, never
in localStorage. In app mode a 401 on a godmode token, background requests
included, asks the desktop for a new token and replays, and a `puter.token`
message is only taken from the embedding frame.
The sessions list shows a godmode token row with its app's title and icon.
* fix: keep a godmode token the same across renewals
Apps hand their launch token to code that never sees a replacement (the
terminal's Drive mount, a CLI's API key), so a token re-signed every renewal
broke those copies once the first one expired. The JWT now carries no `exp`
and a renewal moves only its row's expiry, so every copy keeps working while
the desktop keeps the app alive.
What still bounds it:
- only the desktop's web session can extend it; using the token never does,
and signing out or revoking that session revokes it;
- it lapses 12h after the desktop last asked, and a lapsed row is never
revived: the next launch gets a new token;
- however often it is renewed, a token is replaced after 7 days, and the old
one is revoked on the spot along with the tokens it minted and its sockets.
The desktop no longer re-posts a renewal that kept the same token, so the
app's sockets aren't rebuilt every few hours; an app that reports its token
stopped working is always answered.
* fix: reserve the feedback and no-reply mailbox names again
Signup checks names against `util/reservedUsernames`, but `fbl`, `noreply` and
`no-reply` were added to a second copy of the list in AuthController that
nothing reads any more, so they became claimable. Move them into the live list
and drop the copy.
---------
Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
* docs(agents): no credits or bug-report mentions in commits and PRs
* fix(ai): chat credit gate ceiling, in-flight holds and disconnects
- Cap output at min(max_tokens, context - prompt). The gate used
max_tokens - prompt, so any prompt longer than a model's output limit
was refused with 402 regardless of balance. Gemini 2.5 Pro's output
limit corrected to 65,536.
- Abort the upstream stream when the caller disconnects (driver calls and
the OpenAI/Anthropic-compatible routes), which ends the credit hold.
- A request refused only because the account's own in-flight requests
hold its balance gets 429 `credits_reserved` instead of 402; puter.js
retries it with exponential backoff (1s to 30s, about 2 minutes).
- When the balance caps a completion and it uses the whole cap, the
response carries `usage_limited` (non-stream `metadata`, stream `usage`
line), which puter.js already turns into the upgrade prompt.
* perf(ai): read the balance and its holds once per credit gate
The gate re-read holds, and the whole balance when any were held, to tell
spent from committed. getUsageHeadroom returns both halves from the same
reads getRemainingUsage already did.
* fix(auth): issue reauth tokens only for browser sessions
A rejected token's 401 carried a signed reauth_token, which /signup turns
into a session on a temp account. Any revoked app or access token, a stale
token from a deleted app whose uid was reused, or a worker credential could
get one. Only a GUI/browser session's rejection now carries a reauth_token;
everything else still gets reauth_required without it. The stale-app check
also runs before the session is touched.
* fix(events): refuse worker creation from an events handler
workers.create minted a worker token with no handler depth, so a handler
could deploy a worker and chain writes through it at depth 0. A handler
actor can no longer create a worker; delivery, deploys and hot reload mint
from plain owner actors and are unaffected.
* fix(ai-ocr): bound the page count's parsing and close its under-counts
- Object-stream headers are capped (/N, /First) and read incrementally, and
the inflate budget scales with the file, so a crafted PDF can't spend
seconds and gigabytes before the credit check.
- An escaped /ObjStm type, an indirect /Count, untyped page leaves and an
object redefined to a smaller tree no longer lower the count.
- An account with no balance is refused before the file is parsed.
* fix(fs): add a switch to stop accepting pre-binding signed URLs
Signed URLs issued before owner binding are still accepted, and owner-signed
ones never expire, so the move-to-another-tree attack stays open for them
indefinitely. legacy_file_signatures (default on) can now turn them off, a
rate-limited warning counts their use so it's visible when they've died
out, and a non-UUID uid is refused before the entry lookup.
* fix(fs): hide the issuer's home uid from a scoped token's entries
A token scoped below the home could still learn the home's uid as the
parent_uid of a direct child (e.g. stat on ~/Documents). For access-token
actors that can't list the home, a direct child's parent uid is now null in
the v2 and legacy entry shapes, using the same rule as the root listing.
* fix(gui): ask for the 2FA code on the last step, right before enabling
The setup flow verified a code before the recovery-codes screen and sent
that same code to enable afterwards; by then it had usually expired, and
the user was sent back to the start. The code is now entered last and goes
straight to enable; a wrong code keeps the user on that step, and other
failures show the server's message. Enter submits the password step.
* test(fs): expect the legacy-signature flag in the signing config
* fix: apply the review's follow-ups to the merged-PR fixes
- Reauth tokens also require the session row to be a browser ('web')
session, not just the absence of the worker claim.
- The OCR page count only treats an untyped object as a page when it sits in
a page tree's /Kids, so outline items, widgets and name-tree leaves can't
inflate an honest PDF's count.
- The signed-URL uid guard checks the uuid's shape only, so a legacy row
whose uuid has nonstandard version/variant bits still resolves.
Follow-up to #4048. original_path/original_name decide where a GUI restore
lands, and the owner's restore is unchecked, so whoever sets them can aim a
file at another app's AppData root and block that app's launch. #4048 only
pinned them on move for non-account actors; a write could still set them,
and a share recipient's session counts as account context.
Writes now drop the trash keys for every caller. On move, only the account
that owns both source and destination may set them; anyone else moving into
the owner's Trash gets them recomputed from the entry, and other moves drop
them, including keys already on the row. Copies drop them on every branch.
The GUI only offers Restore for direct children of Trash, since nested rows
never carry legitimate trash metadata. The AppData/Trash checks for
cross-app deletes and create grants now ignore case, like the root guard.
New /sign signatures are an HMAC over uid, action, expiry and the entry's
owner at signing time. /file and /writeFile resolve the entry first and
verify against its current owner, so a move that hands the entry to another
account ends its URLs and stops /file minting child URLs from them. The URL
shape is unchanged; signatures in the previous format still verify, without
the owner check.
The per-account socket cap looked a plan up directly in its map, so
org_seat_free (team seats on a free team) got the paid cap. Route and
events limits already gave an unlisted free plan the default free entry;
that rule now lives in one helper, subscriptionOverride, used by all three.
Also publishes the socket connection caps, corrects the seat-plan line in
rate-limits-and-quotas.md, and makes the isFreeSubscription test assert
literal ids.