Commit Graph
6933 Commits
Author SHA1 Message Date
Juan Fernando Castro 27eebc7300 Merge pull request #4086 from HeyPuter/juancastro/permissions-access-control-cleanup
Permissions and access-control cleanup
2026-10-07 10:57:41 -04:00
Juan Castro 6efb96d560 Merge branch 'juancastro/flat-permission-view-scope' into juancastro/permissions-access-control-cleanup 2026-10-07 09:00:40 -04:00
Juan Castro 5596e70f25 Merge branch 'main' into juancastro/flat-permission-view-scope 2026-10-07 09:00:18 -04:00
Daniel Salazar 11e48c68a9 fix: verification for sms (#4101)
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s
2026-10-06 22:03:07 -07:00
Daniel Salazar 464f758a97 fix: verification for sms (#4100) 2026-10-06 22:00:14 -07:00
Daniel Salazar fadee6fac5 fix: verification for sms (#4099) 2026-10-06 21:59:58 -07:00
Daniel Salazar d2cdde5147 fix: verification for sms (#4098)
release-please / release-please (push) Canceled after 0s
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
2026-10-06 19:36:18 -07:00
Daniel Salazar 50ee0818ea feat: trial cleanup (#4097) 2026-10-06 18:20:18 -07:00
Juan Castro ad1be70d06 Merge branch 'juancastro/flat-permission-view-scope' into juancastro/permissions-access-control-cleanup 2026-10-06 19:44:37 -04:00
Juan Castro 6277f106c4 Merge branch 'main' into juancastro/flat-permission-view-scope 2026-10-06 19:44:19 -04:00
Juan Castro 738a8058ef fix: close the path oracle where it actually was
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.
2026-10-06 19:41:55 -04:00
Juan Fernando Castro 0e2f9663b2 Merge pull request #4091 from HeyPuter/juancastro/teams-cleanup
Teams cleanup: pagination, resumable delete, and credentials that stop leaving by mail
2026-10-06 19:27:09 -04:00
Juan Castro b8fcbffe3f fix: the five remaining review findings
**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.
2026-10-06 19:23:40 -04:00
Juan Castro 755b58fd49 fix: don't hand the owner's subtree size to a see-only caller
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.
2026-10-06 19:08:46 -04:00
Juan Castro bd5aa901ab perf: batch the per-page reads in a team delete
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.
2026-10-06 18:55:53 -04:00
Juan Castro b84fc6c158 Merge branch 'juancastro/flat-permission-view-scope' into juancastro/permissions-access-control-cleanup 2026-10-06 18:26:31 -04:00
Juan Castro edb0c2a712 Merge branch 'main' into juancastro/flat-permission-view-scope 2026-10-06 18:13:27 -04:00
Juan Castro 55e29666d0 Merge branch 'juancastro/flat-permission-view-scope' into juancastro/permissions-access-control-cleanup 2026-10-06 18:13:02 -04:00
Juan Castro 66774ff90c Merge branch 'main' into juancastro/permissions-access-control-cleanup 2026-10-06 18:12:39 -04:00
Juan Castro 0fbb6def5a Merge branch 'main' into juancastro/flat-permission-view-scope 2026-10-06 18:12:25 -04:00
Juan Fernando Castro d9ee207ed0 Merge pull request #4092 from HeyPuter/juancastro/storable-email-gate
Gate an email address once, before it is stored
2026-10-06 18:10:19 -04:00
Juan Castro 0495ea0f9d fix: close the holes the review found in the storage gate
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.
2026-10-06 17:20:35 -04:00
Juan Castro 73698be964 fix: one gate for an address we are about to store
`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.
2026-10-06 16:47:15 -04:00
Juan Castro 25f132b28d fix: address code review on the teams cleanup
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.
2026-10-06 16:22:48 -04:00
Juan Castro 36a22b05a2 docs: stop claiming the directory discloses what /members does
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.
2026-10-06 16:02:13 -04:00
Juan Castro 9d8e37dc6d fix: stop an unconfirmed claim on an address from blocking invites to it
`#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.
2026-10-06 16:00:32 -04:00
Juan Castro 6826071156 fix: stop mailing a plaintext team credential to an unconfirmed address
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.
2026-10-06 15:56:33 -04:00
Juan Castro d896250ae2 fix: make team deletion resumable, and stop it serializing its mail
`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.
2026-10-06 15:56:10 -04:00
Juan Castro 665314535e docs: say what TeamStore actually caches
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.
2026-10-06 15:55:40 -04:00
Juan Castro f3b05ca822 fix: page GET /teams like every other team listing
`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.
2026-10-06 15:55:22 -04:00
Filip Kujundžić a1eca46a80 Merge pull request #4087 from HeyPuter/fix/responses-sampling-capabilities
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
fix(ai): omit unsupported sampling options in Responses requests
2026-10-06 20:22:16 +02:00
Daniel Salazar 484c498859 fix: error cleanup (#4089)
* fix: message

* fix: error cleanup
2026-10-06 11:16:04 -07:00
Reynaldi Chernando 71399c5939 sync limits ai, fs, events, networking (#4088) 2026-10-06 23:26:53 +07:00
404oops b1da48b738 fix(ai): omit unsupported Responses sampling options 2026-10-06 18:14:13 +02:00
Filip Kujundžić 9a9972b026 Merge pull request #4079 from HeyPuter/DS/danielsalazar/anthropic-route-fidelity
feat(ai): carry the full Anthropic and OpenAI wire shapes through the chat driver
2026-10-06 18:12:04 +02:00
Juan Castro 4629b544e6 docs: say why AppDriver.select lists apps it does not own
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.
2026-10-06 11:35:20 -04:00
Juan Castro cbb8d708b4 fix: scope the rewrite collapse to fs paths
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.
2026-10-06 11:33:27 -04:00
Juan Castro 95809c7dd5 fix: bound the permission check, and let whoami fail
`/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.
2026-10-06 11:17:43 -04:00
Juan Castro ac5fa20388 fix: keep the other account out of the OIDC link conflict
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.
2026-10-06 11:14:19 -04:00
Juan Castro 3389d3f8a1 fix: treat an abuse hook that could not run as a refusal
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.
2026-10-06 11:13:18 -04:00
Juan Castro 39099e2056 fix: size a shared folder from its owner, and keep sub-scopes when widening
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.
2026-10-06 11:05:52 -04:00
Juan Castro acd6ea3c75 fix: do not spend a consent prompt on an app root dir that is not the caller's
`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.
2026-10-06 10:55:19 -04:00
Juan Castro d6c606fb21 fix: keep the owner's path out of the ghost-file 404
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.
2026-10-06 10:33:59 -04:00
Juan Castro 8c7372adc6 fix: stop the path rewrite confirming entries the caller cannot see
`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.
2026-10-06 10:30:58 -04:00
Juan Fernando CastroandReynaldi Chernando 3711d1e837 docs: medium teams recipe (PUT-1976) (#3976)
* 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>
2026-10-06 20:31:27 +07:00
Daniel Salazar 957784bdb8 fix(ai): keep existing chat callers on their previous behavior
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
2026-10-06 15:22:53 +02:00
Neal ShahandReynaldi Chernando dc029b43a8 Worker and Subdomain Recipes (#4075)
* Worker recipes medium

* subdomain recipes medium

* refine

---------

Co-authored-by: Reynaldi Chernando <reynaldichernando@gmail.com>
2026-10-06 20:09:38 +07:00
Daniel Salazar 985c76c9c3 docs: medium kv recipes (PUT-1972) (#4041)
* 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.
2026-10-06 19:10:27 +07:00
Daniel Salazar f21cd51edf fix(ai): retry a fast-mode 429 at standard speed on Claude
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.
2026-10-05 20:54:01 -07:00
Daniel Salazar c2fc45a8d1 fix(ai): keep the driver's token count off the /drivers/call surface
count_tokens is only for the Anthropic route, so the driver exposes it
under a symbol key that the RPC resolver never lists.
2026-10-05 16:56:48 -07:00