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.
Measured first, against a real server, counting DB round trips: a 40-seat
delete was 284 queries, 7.1 per seat, scaling linearly. Four of those seven
answered for the whole page and had no reason to be asked per account.
120x SELECT * FROM `user` WHERE `id` = ?
41x INSERT INTO `audit_team_membership`
40x SELECT COALESCE(SUM(size), 0) FROM fsentries
40x UPDATE `user` SET `suspended` = ?
40x SELECT `uuid` FROM `sessions` WHERE `user_id` = ?
The page now reads who is already suspended, what each holds, and the rows the
notices need in one query each, and writes the audit as one statement. What is
genuinely per account -- the suspension, its billing event, the mail -- stays
per account under the same concurrency bound.
Same 40-seat delete is now 170 queries, 4.3 per seat.
Three new store methods rather than raw SQL at the call site:
`getSuspendedByIds` reads the primary, deliberately not `getByIds`, which is
cache-first. A stale `suspended` is exactly what re-charges a seat on a retry,
which is the thing this path was just fixed to stop doing.
`getHeldBytesForUsers` is `getHeldBytes` with a GROUP BY.
`appendAuditMany` is `appendAudit` as one multi-row insert, still written
before any suspension, so a failed append cannot leave an unlogged one.
What is left is concentrated in `#suspend`: the UPDATE, the forced re-read the
user store does afterwards to refresh its cache, and the session drop. Those
are shared store paths and batching them means owning their cache invalidation,
so they are not in scope here.
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.
`#resolveRecipient` already refuses to treat an unconfirmed address as an
identity, and says why: "resolving it would hand the share to whoever registered
the address first." `#invite` then looked the same address up again and honoured
whatever that row said about blocking -- `blocksAllShares`, or a block on this
particular sender.
So registering an address you do not own, and never confirming it, was enough to
refuse every invite anyone sends to that mailbox. Nothing about that row is
evidence the mailbox is yours, which is the premise resolution already works
from. A team seat reaches this cheaply: those accounts are provisioned with
`requires_email_confirmation: false` and an address the owner typed.
The block is now honoured only for a row that confirmed the address. The alias
reasoning behind the lookup is unchanged -- a confirmed holder still cannot be
reached through a variant they did not type.
Not addressed here: the same unconfirmed seat still holds the address against
the real owner's signup, because `idx_user_owned_email` keys on having a
password rather than on confirmation. Narrowing that is a schema change and
wants its own ticket.
Provisioning a seat lets the owner supply any `email` that passes a format
check. Puter then mailed a working temporary password to it. Nothing confirms
the owner controls that address -- provisioned accounts are created with
`requires_email_confirmation: false`, because the team is the trust anchor for
the account, not for the mailbox. `resendActivation` reissues the credential and
mails it again, up to the 20/day `team:reset-password` budget, so the same
address can be hit repeatedly.
The credential now stays out of the mail on both paths, which is what
`resetMemberPassword` in the same file already does. Nothing is lost: all three
routes return `temporary_password` to the owner, and the SDK has always
documented that as the delivery -- "The returned password is shown once and is
not retrievable afterwards -- deliver it out of band." The mail still goes out,
naming the account and saying the team will supply the password.
The two tests that pinned the old shape are inverted rather than deleted; one of
them carried the rationale "without it this is the only copy", which the return
value contradicts.
`deleteTeam` tore seats down one at a time, each one awaiting a mail send, so a
full 40-seat team was 40 round trips end to end inside a single request. The
per-seat work is independent, so it now runs under the same bounded-concurrency
helper the rest of the backend uses for fan-out sends.
The loop also had no equivalent of the guard `disableMember` carries. That one
checks `suspended` before emitting, with the reason written down: a second
`team.account.disabled` opens a second byte charge that only one `enabled` will
ever close. Deleting a team emitted unconditionally, so any partial failure
followed by a retry double-charged every seat the first attempt had already
disabled. The seat teardown moves into one method both paths can state the same
way, and a retry now resumes instead of re-charging.
Failure semantics are unchanged: a seat that throws still aborts the delete and
leaves the team live, which is what makes the retry meaningful.
Touching this file reformats a few untouched spans -- it carried ten
pre-existing `prettier/prettier` errors that the repo's own pre-commit `eslint
--fix` resolves on any commit that stages it.
The cache docblock claimed `isMember` and `getByUid` were left uncached "for
that reason". Both go through it -- `getByUid` directly, `isMember` through
`getMembership` -- so the comment told a reader the opposite of the code, on the
two reads that decide authorization.
What makes it safe is the busting, not an exemption: every mutation here drops
the keys it touches, and the TTL is the backstop for the one path that does not
come through this store, the ON DELETE CASCADE on the membership row. No stale
window is constructible today; the comment now says why.
Also drops a one-line docblock stranded above it, describing a method that is no
longer in the file.
`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.
Reads as the same enumeration surface `/query/app` refuses to be, so write down
that it is not: `/query/app` answers for apps the caller names, which is why it
must not confirm one it may not see. `select` is the listing a launcher reads,
and a listing that hid every app but your own would not be one. Protected apps
are still filtered by the permission check, and `user-can-edit` narrows it to
the caller's own.
A test already pins the behaviour by name; this is the reason behind it.
The collapse caught every `subject_does_not_exist` the rewriters raise, not just
the path resolver's. `app-root-dir:` 404s when the caller's own app has no
resolvable root directory, and that answer is theirs to have — four of its tests
said so.
Only a permission that parses as an `fs:` path can oracle someone else's tree,
so only that one collapses now.
`/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.
Linking a provider identity already bound elsewhere answered 409 with the raw
provider subject and the other account's internal id in the message. The caller
needs to know there is a conflict, not whose it is; both stay in the log for
whoever is debugging it.
Status and legacy code are unchanged — OIDCService branches on the 409.
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.
A share recipient addresses an entry as /<owner>/<uuid>/<name>, which is the
point of the mask: it says where the item is without naming the folders above
it. The 404 raised when a file's backing object has gone put `entry.path` in the
error fields, so reading or copying a shared file whose object had vanished
handed back the real path.
The fields carry the uuid alone now, on both the read and the copy site. The
caller already has it — it is what they named the entry by.
The existing ghost test asserted the path was present, so it is the regression
pin; the copy one was not checking fields at all and now does.
`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.
* docs: add the medium teams recipe
Built around what an app actually does with a team: an assignee picker
keyed on uuid, paging a large directory, scoping stored data per team,
sharing with everyone at once, and telling the three empty cases apart.
One playground example per section.
* docs: split the teams recipe into the two use cases it covered
The medium teams recipe carried six sections, which is the wrong shape for a
recipe: an agent reading llms.txt to answer "add team sharing" had to find it
inside a page called "Work With a Team", next to paging and team switching.
Two recipes, one use case each:
share-a-file-with-a-team the whole team, or one teammate via the directory
show-a-team-owner-panel gating an admin view on isOwner
Dropped rather than moved: paging and the empty/closed-directory cases, both
already in Add Team Support, and per-team data namespacing, which assumes
multiple teams we do not support yet.
Two corrections while rewriting. A team share names the team in `holder_team`;
`holder` is a username, so the old example printed the wrong field and told the
reader it meant "no one person". And `share()` takes a username for a person,
never a uuid — the picker now shares by username and keeps the uuid for what it
stores, which is what the rename advice was always about.
The old page also linked to /recipes/teams-read-membership/, which does not
exist; the slug is add-team-support.
* refine
---------
Co-authored-by: Reynaldi Chernando <reynaldichernando@gmail.com>
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
* docs: medium kv recipes (PUT-1972)
Adds three recipes:
- Store a Set of Unique Values: an object as a set via update/remove,
quoted paths for values with dots, like button, recent searches
- Run Code Only Once: incr as an atomic claim, once per day, quotas,
sequential numbers, webhook dedupe in a worker
- Keep a Version History: incr version numbers, batch set with expireAt,
reverse list paging, restore, keep last N, delete by prefix
* docs: more medium kv recipes (PUT-1972)
Adds six recipes:
- Keep an Index in Sync: batch set for record + index keys, moving keys
on change, delete order, per-group counts vs includeTotal, search as
you type, rebuild and stale-entry cleanup
- Share Data Between Apps: appData scopes, appUuid on every call,
disableSharing private entries and the set-without-flag gotcha
- Save User Settings: defaults merge, nested paths, reset with remove,
form binding, sets inside settings, renaming a setting
- Split a Large Value Across Keys: size-based read/write cost, shards
numbered by incr, latest/older/all reads, expiring old shards
- Export or Delete Data: stream export, filtered batch import, delete by
prefix, delete before a date, flush
- Build a Shopping Cart: quoted SKUs, incr quantities, TTL via update,
hide-at-zero, server-side prices at checkout
Also adds a high score section to Add Counters, links Query a Collection
to the index recipe, and replaces its stale newest-first advice with
list({ reverse: true }).
* docs: simplify wording in medium kv recipes
Plainer prose throughout: shorter sentences, no bold or step-by-step
scaffolding, fewer defined terms. Code is unchanged apart from comments.
Fast mode has its own quota. Its 429 used to count as a route failure,
so the request was replayed on a reseller and the Claude route for that
model was marked unhealthy. The provider now retries once without speed.