A 403 model_not_found still returns 400 upstream_bad_request, but now
alarms at info so a lost provider entitlement gets noticed. HttpError
gains an alarmSeverity option the terminal alarm gate honors.
* feat: bound provisioning by what an owner may mint, not only what is live
Seat and team caps express how much may exist at one time. This adds the
other half of the rule: how much an owner may create over a window, and the
checks a provisioned account answers to.
- Provisioning puts each seat through `SignupService.runSignupGate`, the same
extension-owned gate a signup answers, and refuses what it blocks. It fails
closed, since a check that could not run is not a check that passed.
- A per-owner budget over a rolling window, sized to every seat the owner may
hold live across every team they may own, times the churn allowed on top.
It is spent on creation and is not returned.
Keyed on the owner's uuid so a recycled id cannot inherit someone else's
spend, and charged after validation so a taken username costs nothing.
* refactor: tighten the provisioning gate and budget after review
- The gate runs before the seat-cap lock. A listener has no deadline and the
lease is ten seconds with nothing to renew it, so the lock must not be held
across one.
- `failOpen: false` rethrows rather than returning false, so a backend outage
is answered 503 instead of escaping the handler.
- An address goes through `validateEmail` first, as it does on every other
path that creates an account.
- The gate always runs; a request only enriches what it is told, so one being
absent is no reason to skip it.
- The gate is told it is looking at a seat, whose address is optional.
- A refusal carries the gate's own code and request code, as a blocked signup
does, so support can correlate it.
- Restored `provisionAccount`'s docblock, which an insert had displaced.
Tests: the spy restores in `finally`, and the cap overrides are taken after
the fixtures, so one failure cannot cascade through the file.
* fix: show a provisioning refusal as a rule, not a failure
The console special-cases the seat cap and sends everything else to
"Something went wrong", which reads as a fault rather than an answer. The
provisioning budget refuses the same way the cap does, so it is presented
the same way.
* fix: count one app open per account per app per day
`app_opens` is read two ways at once: `open_count` is a row count and
`user_count` is distinct accounts. A repeated open moves the first
without the first moving the second, and the incentive report prices
opens.
An open is now marked as it is recorded and skipped for the rest of the
day, before both the row and the event ClickHouse ingests, so the two
stores agree. The marker is set only when absent, so the first open of
the day wins a race; an unreachable marker counts the open, since
counting twice beats not counting.
* fix: count one app open per account per app per day, at the count
Dropped the row-suppression approach. `app_opens` is not only a counter:
`getRecentAppOpens` orders the desktop's recent list by `MAX(_id)`, and
`app.opened` is the socket event other tabs reorder on. Suppressing the
repeat row froze both at the first open of the day.
Every open is recorded as before. `open_count` counts each account once
per day instead, which is the figure `user_count` is read against.
`countDistinctUserDays` folds the pair into one number because
`COUNT(DISTINCT a, b)` is MySQL-only, and `dayBucket` covers MySQL's
decimal division. ClickHouse has uniqExact over the tuple.
Note the figure's meaning changes at deploy: a period spanning it mixes
rows with account-days, so a comparison across that point is not one.
* fix: count one app open per account per app per hour
Review feedback: a day is too long a window for the repeat to collapse
into. The period is now an argument rather than a baked-in 86400, held in
one constant on AppStore beside the other store policy numbers, so the SQL
and ClickHouse sites cannot drift and a future change is one line.
The fold `COUNT(DISTINCT user * N + bucket)` is only injective while the
bucket index stays under N. Hourly buckets are 24x the day index and pass
the old 1000000 in 2084, so N moves to 10000000.
Buckets are clock hours, not a rolling window, so two opens a minute apart
either side of the hour still count twice — tested, and the same property
the day version had at UTC midnight.
The detailed-stats fixture gave its repeat open a 60s offset, which under
hourly buckets would have counted twice whenever the suite ran in the first
minute of an hour. It now shares a timestamp with the open it repeats.
- relay sign-in popups take the opener origin from the referrer only
- session revokes read the primary, batch per user, and tombstone caches in every region
- wisp relay tokens bound to the minting session; verify rechecks the account
- hosted origins keep app identity with the site owner; deleted app uids recorded in deleted_apps
- subdomain cache tombstoned on delete, update, reclaim and app/account cascades
- email lookups require an exact address match after the SQL lookup
- workers deploy only from files the deployer owns
- share_app_data:false enforced at ACL time, plus copy/move/rename/share/recursive listing
- kv remove returns no value without read; kv:expire op grant usable
- fs errors name the entry instead of the owner's path
- shared-by-me drops rows a delegate no longer has authority over
- private apps can't move index_url onto another user's origin
- GUI: default apps saved and launched by uid; save picker accepts only copy/move; permission dialog waits for focus; Publish as Worker hidden on others' files
* fix: give every route behind the user-protected gate a password budget
Five of the six routes behind the gate declare a per-user hourly bucket
for the password check. The sixth, delete-own-user, declared none.
The bucket moves into the gate itself, so it covers every route behind
it and a new one cannot be added without one.
It is charged by the outcome rather than by the request: a wrong answer
spends one and a right answer spends nothing, so a caller who knows the
password never meets the limit.
* fix: the review's findings on the password budget
Reading the budget and spending it on the outcome bounded rounds rather
than attempts: everything already in flight read the same budget and was
answered before any of it was spent. The charge lands before the
comparison now, so a burst is bounded by the same number as a sequence.
The key gained a route component. One bucket across all six meant ten
mistyped passwords on one route closed the other five for the hour,
including the one an account owing its team's 2FA has to reach.
getObjectStream now attaches an 'error' listener to every body it returns.
A body that fails before its consumer attaches (socket timeout, reset, or a
checksum mismatch on a truncated read) used to emit 'error' with no listener
and exit the process, failing every in-flight request on the task. It now
logs a warning; consumers that attach later still receive the error.
* PUT-2012 fix broadcast service drops
* Revert pre-commit lint churn on files merged from main
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix test bug
* Remove other test
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* perf(auth): renew godmode app tokens from cache instead of the primary
Every godmode launch read its token row from the primary and rewrote its
expiry, a few cross-region round trips per launch far from the primary.
- A cache pointer names the row per (user, web session, app). The row is
read through the uuid cache token auth uses and re-checked (owner,
session, app, revoked, expired, max age); anything else falls back to the
primary read. Revoking the row drops the pointer.
- The expiry write is skipped when it would move by under an hour. A
renewal that hits a revoked or lapsed row mints a new one instead.
- The response carries the row's actual expires_at.
* perf(gui): reuse a just-minted godmode token for repeat launches
A launch of a godmode app reuses the token this page minted for it within
the last ten minutes under the same desktop session, instead of asking
again. The cache is dropped when an app reports its token dead, after a
session revoke from the session manager or an uninstall, and across tabs
via a BroadcastChannel that carries token row ids, never tokens. A mint in
flight during a drop is not remembered.
Add interactive-widget=resizes-content to the mobile viewport meta tag
so the layout viewport shrinks when the soft keyboard opens. The app
window's height chain resolves against the layout viewport, so the
iframe folds up above the keyboard instead of clipping behind it.
- TTS sends output_format as the query parameter ElevenLabs reads; the body
field was ignored, so every request came back as MP3. Unknown formats are
rejected before credits are reserved, and the OpenAI-style names mp3, opus,
pcm and wav map to native formats.
- Voice IDs go straight to synthesis. The library listing omits voices that
still synthesize, the default 21m00Tcm4TlvDq8ikWAM among them, and runs to
9 MB per call. Names resolve through /v2/voices search, and premade voices
("Bella - Professional, Bright, Warm") also match their short name.
- speech2speech accepts HTTPS audio URLs through secureFetch, as documented.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Catalog gemini-nano-banana-2.1 (aliases google/gemini-nano-banana-2.1 and
nano-banana-2.1) at Google's standard rates: $1.50/1M input, $7.50/1M text
and thinking output, $30/1M image output, with 1K/2K/4K tiers at
1120/1680/3780 image tokens. 3.1 Flash Image stays the provider default.
Pre-flight credit checks assumed 50 text/thinking output tokens per image
for every Gemini model; live samples run 270-1122 depending on the model, so
each model now carries its own estimate.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(rate-limit): count the newest KV rate rows first
The KV backend's rate check, peek and concurrent acquire list the key's
rows with a limit + 1 cap that the store applies before dropping expired
rows, so unswept rows from past windows (oldest keys) could fill the fetch
and the live count read as zero. They now list newest first.
* fix(broadcast): split webhook flushes under a byte budget, per-peer backlogs
Flushes go out in bodies of at most `broadcast.outboundMaxBodyBytes` (default
4 MiB) instead of one POST, and each peer keeps its own backlog of what it
missed, capped by count and four bodies' worth of bytes, oldest dropped first,
so one failing peer can no longer grow every peer's body past their 50 MB limit.
* fix(ai-chat): classify dropped upstream connections as provider failures
A connection reset or refusal (SDK APIConnectionError, Mistral's
ConnectionError, an undici reset on the cause) carries no status, so a chain
of them fell through to 500 internal_error. Attempts now record
connectionFailed; a chain of only resets and 5xx answers 400
upstream_provider_unavailable, and a reset counts as an upstream signal in a
mixed chain.
* fix(ai-chat): keep SDK retries on Anthropic count_tokens
The Claude client is built with SDK retries off so the driver can fall back,
but count_tokens has no fallback route. It now passes sdkRetryOptions, like
the OpenAI moderation call, so a transient 429 or reset is retried.
* fix(ai-chat): mark a route unhealthy for 60 s after a transient failure
With SDK retries off, one 5xx, 429, timeout or dropped connection kept a
route marked for 10 minutes, sending that model's traffic to resellers. Those
failures now mark the route for 60 s; bad credentials, an exhausted account
and other route-level failures keep 10 minutes.
* fix(metering): don't memoise spent addons inside the force-exact window
The spent-addons memo could cache a pre-grant record read from a lagging
replica right after a credits-changed announcement, so overage skipped the
new credit and requireCredits kept answering 402 for up to 15 s. A spent
record is now only cached once the actor's force-exact window has ended.
* fix(share): send the digest entries a flush claimed when another take fails
Digest entries are taken concurrently, and a single rejected take threw
away every entry the other takes had already removed from KV. The flush
now logs the failed takes and sends what it claimed; an entry whose take
failed stays queued for a later flush.
* fix(fs): announce an overwriting move's replaced entry only when it took its name
/move and v2 /fs/move looked up the entry at the destination name before
moving and always announced its removal. A move into someone else's Trash
renames instead of replacing, so the owner's desktop hid a live entry and
legacy /move reported it as `overwritten`. Both routes now pass the target
on only when the moved entry kept the name it was looked up by.
* fix(ai): name the vendor in upstream error fields from media providers
upstreamFetch now takes the provider id plus a display label, and
UpstreamResponseError carries the id. translateProviderError copies a
string `provider` off the error into `fields`, and the BytePlus video and
Cloudflare image errors set one, so upstream_auth_failed and
upstream_credits_exhausted alarms say which vendor failed again.
* fix(teams): delete a provisioned seat whose home folders could not be created
provisionUser logs a failed default-folder creation and still returns the
account, so provisionAccount went on to add the membership, issue a password,
mail the seat and emit team.account.created for an account with no home. It
now cascade-deletes the new user and returns 500 internal_error before any of
that.
* fix(ai-chat): load model catalogs without holding up server start
onServerStart awaited every gateway catalog (up to its 10s fetch timeout)
while HTTP was already listening, so drivers started after it had no
providers yet and chat requests found an empty model map. The first build
now runs in the background; complete(), models() and token counting wait
on it instead of failing.
* fix(ai-chat): serve direct models while gateway catalogs load at boot
The first model-map build now publishes a fresh map as each provider's
catalog lands, so static catalogs are routable at once. complete() and
token counting wait for the full first build only when the model, or the
provider asked for, isn't in the map yet. Refreshes still swap once.
* fix(ai-chat): split gateway-billed cost across input and output
A completion billed at the cost OpenRouter, Infron or Neuralwatt reports
was quoted to `ai.prompt.cost-calculated` as all input and no output. The
ledger still records one `billedUsage` line; the reported per-key costs now
spread that bill over the usage keys at the catalog's relative rates, in
whole µ¢ that sum to it. Neuralwatt energy units take no share.
* fix(ai-image): hold Replicate credits for the whole prediction
The output and input-surcharge holds took the default 10-minute TTL, the
same as the prediction window alone, so a slow prediction could outlive
them once creation and input measurement were added. They now last
15 minutes, like the video job holds.
* feat(ai-chat): report what a completion was charged as usage.cost
Chat usage (the non-stream result and the stream's final usage line) now
carries `cost`: the µ¢ the ledger records for the request after the cost
factor, from the request's recorded charges and the gate's factor lookup.
`usd_cents` is unchanged. A hooked recording now copies the usage object
first, so keys the driver adds later never land in the ledger.
* fix(ai): charge streamed speech once its first byte arrives
Per-character TTS and the voice changer recorded their charge when the
upstream's response headers arrived, so a body that failed before any audio
was billed in full. A shared relay now charges on the first byte, or when
the caller hangs up, and charges nothing for an upstream that fails or ends
before sending audio.
* fix(ai): release credit holds only after the request's usage lands
A hold was released as soon as its operation returned, while the usage it
stood for was still being written (behind the cost-factor lookup), so a
concurrent request briefly saw neither. AI metering now tracks the
request's usage writes and its holds (chat included) release once those
settle, without delaying the response; a streamed charge chains its own.
A failed write still releases the hold, and the hold's TTL backstops it.
* test(ai-chat): wait for a stream's credit hold to come back after its usage lands
* perf(fs): page subtree delete and copy by parent link, not by depth over the path range
Each page of a recursive delete or copy sorted the whole remaining subtree by a
computed depth, so a walk cost grew with the square of its size. It now pages
the children of up to 100 sibling directories at a time by parent_uid, depth
first, keyset on (parent_uid, id), so a page reads only its own rows; rows with
no stored path are reached too. SQLite gets the parent_uid index the others have.
* fix(fs): find children by parent_uid in the empty check and supplementary-name lookup
Rows the old backend wrote carry parent_uid and no parent_id, so a non-recursive
delete of a directory holding only such rows passed the empty check and orphaned
them, and on MySQL the supplementary-character child lookup missed them, leaving
their subtrees out of listings, counts and renames. Both now key on parent_uid;
the empty check still probes parent_id too, which the delete cascade follows.
* docs(fs): drop the single-POST note from the entry broadcast cap
Renaming or moving a directory rewrote its descendants' paths but left their
cached copies (by id, uuid and old path) live for up to 60 s. A lookup of an
old path kept resolving, and a write beneath one landed at a path its parent
no longer had. updatePathPrefixForUser and renameUserHome now drop those keys,
here and, within the broadcast budget, in peer regions.
puter.js sent `cursor=null` for `listShared`/`listSharedByMe({ cursor: null })`,
which decoded to null and failed every keyset listing with a 500. decodeCursor
reads it as no cursor, and the SDK no longer sends it.
startBatchWrite and startWrite created directory items and missing
parents before (or alongside) the storage reservation, so an over-quota
start paid for every directory write, left the directories behind, and
waited on them before returning 413. File targets are now resolved and
storage reserved first; directories are created only after, and the
reservation is released if that fails.
Upload sessions shared the system namespace's single partition key. They
now go to one of 16 system partitions hashed from the session id, via a
new system-actor-only `systemPartition` KV option; partitions are never
cached and emit no mutation events. Reads fall back to the unpartitioned
key for sessions written before this (max session lifetime is one hour).
listDescendantsInOrder, which drives paged recursive delete and copy, still
used a bare `path LIKE` range, so on MySQL a child whose name starts with a
supplementary character (and its subtree) was skipped: delete left it to the
parent_id cascade and copy dropped it. It now ORs the subtree patterns like the
other subtree queries, paging each pattern group with the same seek and
merging the pages in order.
* refactor(stores): one read-through RowCache for UserStore and AppStore
UserStore and AppStore each carried the same multi-key row cache: key
builder, cache read, tombstone-guarded backfill, broadcast refresh,
invalidate and tombstone-on-delete. They now share RowCache on PuterStore,
which also single-flights concurrent misses for one key and batches
getByIds/getByUids: one pipelined tombstone check for the loaded rows and
one pipelined backfill instead of per-row awaits. Cache keys and TTLs are
unchanged.
* perf(apps): invalidate cached app lists by generation, not by tracker scan
Every app write SMEMBERS'd a global set of every cached list key, parsed
each key back into its filters and deleted the matches. List pages are now
keyed by their owner's generation token (or a shared one for unscoped
lists), and a write replaces the tokens it affects: O(1) per write, and a
page loaded before a write can no longer be cached after it.
* refactor(stores): one keyset page helper for store listings
Thirteen store listings each cut a LIMIT n+1 read to a page and sealed the
last row's position, and each opened an id cursor its own way. They now use
keysetPage() and openIdCursor() from util/pagination. Cursor payloads and
sealing are unchanged; a cursor without a usable id still fails with 400 in
ShareStore and still restarts the listing elsewhere (strict option).
* refactor(stores): delete store methods nothing calls
AppStore.existsByIndexUrl, OIDCStore.unlinkByUserId,
TeamStore.listByOwner/listActiveSeatUuids, SubdomainStore.getByDomain/
listByDomain, FSEntryStore.ensureDirectoriesForUser,
ShareStore.listByIssuer/listByRecipientEmail/deleteByRecipientEmail/
getDailyShareCount/listByHolderAndFsentries and
PermissionStore.invalidateScanCache/deleteUserUserPermsByPermissionPrefix
had no production callers. ShareStore.listByFsentries was a copy of
listReaching; its callers use the latter.
SubdomainStore SMEMBERS'd a per-user prefix-list tracker on every write and
broadcast its deletion, but nothing ever wrote that set or cached a prefix
listing. The reads are gone.
* refactor(stores): shared escapeLike and bumpGeneration helpers
escapeLike replaces the LIKE escaping copied into PermissionStore (twice),
ShareStore and FSEntryStore, and now also guards SubdomainStore's prefix
filters, which passed caller prefixes into LIKE unescaped.
bumpGeneration sends INCR and EXPIRE in one pipeline instead of two awaited
commands in PermissionStore, EventSubscriptionStore and PresenceStore.
* refactor(stores): read affected rows off WriteResult
Every database client returns affectedRows and anyRowsAffected, so the
`affectedRows ?? changes ?? 0` fallbacks in ShareStore, NotificationStore,
UserStore and UserBlockStore read those directly.
* perf(stores): batch flat-permission KV deletes and durable cache drops
PermissionStore deleted each flat KV entry with its own kv.del; one
kv.batchDel now covers the set. DurableSubscriptionStore's suspend and reap
dropped each row from the cache separately; EventSubscriptionStore gains
dropDurables, which drops them per owner in one pass, and dropDurable calls
it. suspend keeps one conditional UPDATE per row, which is how concurrent
settles learn which rows each of them suspended.
* refactor(fs): fold FSEntryStore's duplicate getters, sort maps and cache keys
- getEntryByUuid and getEntryById share one by-column getter.
- listChildren and listChildrenPage read one sort table; the offset listing
now coalesces NULL modified/size like the cursor listing, so both order
NULLs the same way on every engine.
- Entry cache keys come from one builder instead of hand-built strings.
- getEntriesByIds, getEntriesByPaths and the per-user path reader fetch
their cache hits in one pipelined round trip instead of a GET per key,
through readCacheValues, which RowCache.readMany now uses too.
* fix(apps): cache the row a lost createFromOrigin race reads from the primary
Keeps AppStore's private #writeCache as a RowCache write, so code that
still calls it (#4166's read-back) loads in either merge order.