Skip to content

feat: attested account sessions with bearer tokens for Buddies and Notes Import - #49

Merged
leviFrosty merged 25 commits into
mainfrom
account-sessions
Oct 18, 2026
Merged

leviFrosty merged 25 commits into
mainfrom
account-sessions

Conversation

@leviFrosty

@leviFrosty leviFrosty commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

Summary

The app attests once per device, when it opens an account session, and then calls the API with a short-lived bearer token. Endpoints no longer each need a signed payload. The account is now the source of truth for who a User is (witness-work ADR 0027). Buddies and Notes Import identify the caller by the session's account. The old ways of authenticating keep working behind a switch that can be turned off once the app's minimum version includes sessions.

Stacked on #47. The first commit (81e48ba) is #47 rebased onto main; refactor/split-attestation-endpoint itself is unchanged. Either merge #47 first (this PR then shrinks to the rest), or merge this PR and close #47.

App PR: leviFrosty/witness-work#776, which carries the architecture diagram update. The activity log is #50 and leviFrosty/witness-work#777.

Sessions

  • POST /accounts/v1/sessions opens a session. The request is the account-key envelope (op session/open) plus an App Attest assertion or a Play Integrity token for purpose account-session. The challenge comes from POST /attestation/v1/challenge. With no integrity there is no account, as decided.
  • The response is a 15-minute HS256 access token plus a refresh token. Sessions last 90 days, with up to 10 per account; the oldest is replaced.
  • POST /accounts/v1/sessions/refresh rotates the refresh token. A refresh token is <sessionId>.<secret>.<mac>, and the worker checks the MAC before any database read, so a token it didn't issue can't end a session. Reusing an older token revokes the session. Concurrent refreshes of the same token get the same next token, with a 60-second grace.
  • DELETE /accounts/v1/sessions/current signs out, and GET /accounts/v1/account reads the account.
  • requireAccount() in src/accounts/auth.ts gates routes on the bearer token. Sessions are stored in D1 account_sessions (migration 0001_account-sessions.sql).
  • Backup and handoff take bearer tokens. backup/delete still also needs the account-key envelope as a step-up.

Buddies on bearer

  • Owner ops take plain JSON with a bearer token.
  • inbox/register takes the token plus the owner envelope, and the account proof is now required.
  • Writer ops keep the writer signature, and the writer's account is never stored.
  • invite/* and blob/get take the token plus their capability.
  • An owner is authorized only for an inbox link that D1 confirmed. An unconfirmed link is 409 not_linked, so a half-finished registration can't escape suspension.
  • Edge rate limits are keyed per account on bearer requests.
  • The iOS Notification Service Extension's owner-signed inbox/sync (x-buddies-client: notification-service) keeps working after the legacy switch, for confirmed links only.

Notes Import on bearer

  • Metered per account, with a per-import owner hash.
  • Suspended accounts get 403.
  • Supporter status is resolved through isAccountSupporter (src/accounts/supporter.ts), the seam for the RevenueCat link.
  • Empty or whitespace-only notesText is refused.
  • Each synchronous import holds its own concurrency slot. The model call has a 10-minute deadline, inside the slot's 15-minute age-out.

Turning off backward compatibility

  • GET /app/v1/config returns {ok, minAppVersion?} from KV app:min-version (else the APP_MIN_VERSION var). Below it, the app shows a blocking Update Required screen.
  • KV auth:legacy (else the AUTH_LEGACY var, "on") switches the pre-session ways of authenticating. When it's off they get 426 upgrade_required.
  • The runbook is in AGENTS.md under "Turning off backward compatibility":
    1. raise app:min-version
    2. raise notes-import:min-version
    3. set auth:legacy to off
  • The floor must be the first version whose Notification Service Extension sends the x-buddies-client header.

Sentry

  • Events drop the Authorization, admin, dev bypass, x-buddies-*, and x-ww-* envelope/slot headers.
  • Database errors are reported by operation only (captureFailure), never with their bound parameters.
  • token/apiKey query values are cut from events and traces, including percent-encoded parameter names.

Docs

  • AGENTS.md: the account is the source of truth for who a User is; sessions; bearer routes; token-secret rotation; the legacy-switch runbook.
  • OpenAPI covers every new and changed route.

Before deploying to production

  1. Set the token secret, unique to prod, and keep a copy in 1Password: openssl rand -base64 32 | wrangler secret put ACCOUNT_TOKEN_SECRET. Without it the session routes are 503 disabled, and the legacy paths keep working.
  2. The tag deploy applies D1 migration 0001_account-sessions.sql before deploying.
  3. feat: move attestation to /attestation/v1 and add readable link paths #47's attestation routes must be live before an app build with sessions ships.

Testing

Run on 1b825cc:

  • pnpm test: 1,028 passed.
  • pnpm exec tsc --noEmit: clean.
  • wrangler deploy --dry-run and --env dev --dry-run: both build.
  • verify-ww-api loop: doctor ok; pnpm test:e2e 74 passed with 3 skipped (the same skips as main); pnpm fuzz:buddies ok (300 cases, both canaries intact).
  • Deployed to the dev worker (ww-proxy-dev) with D1 migration 0001 applied. The app's interop suite against dev passes (18 passed, 2 paid model runs skipped).
  • Device runs of the app branch on iOS and Android, through the dev bypass (no real App Attest, Play Integrity, or APNs):
    • First pass: sessions, persistence, backup, handoff, Update Required, and the can't-verify screens, against the local and dev workers.
    • Second pass, against a local worker with auth:legacy off: every relay call was 2xx and inbox/live was 101, so all of them were bearer calls.
      • iOS paired with Android over Buddies: invite, accept, a Plan invitation with a note photo (blob/get), a reply, and a reaction. Live updates arrived in about 1–1.6 s.
      • The not_linked → relink → retry path passed.
      • A suspended account got kickoff 403 and the "turned off" UI.
    • Second pass, against the dev worker: one real Notes Import. I killed the app mid-run; on relaunch it got the result, applied it, and deleted it.

Review

Three security review rounds ran on the server (two by a second model). Every finding is fixed in this PR:

  • High: partial inbox registration could escape suspension. Fixed by D1-confirmed links.
  • High: a forged refresh token could revoke a session. Fixed by the token MAC.
  • Medium: reusing an older refresh token didn't revoke. Fixed by self-identifying tokens.
  • Medium: database error parameters reached Sentry. Fixed by captureFailure and scrubbing.
  • Medium: synchronous imports got around the concurrency cap. Fixed by a per-request slot.
  • Medium: ?token= reached Sentry. Fixed by query redaction.
  • Low: refresh race. Fixed by a compare-and-swap.

Known gaps

  • The RevenueCat lookup that runs after the model call has no deadline of its own. It's bounded only by the Worker's limits.
  • Until RevenueCat is linked to the account, Notes Import takes Supporter status from the request's optional revenueCatAppUserId, as it does today. The linking change replaces the body of isAccountSupporter and drops the field.
  • Not built here: the RevenueCat webhook and account link (separate thread), and the account activity log (feat: account activity log and signed-in devices #50).

Sentry 8 drops only cookies with sendDefaultPii off, so every other
request header reached events, the admin token included. Also notes
the account as the source of truth for who a User is (witness-work
ADR 0027).
The app-wide successor to Notes Import's own floor (witness-work ADR 0027,
"Updates and the legacy switch"): every app version from sessions on reads
it at launch and on returning to the foreground, and below `minAppVersion`
shows a blocking Update Required screen. KV `app:min-version`, then the
`APP_MIN_VERSION` var, then no floor; edge-cached for 60 seconds, served
with `Cache-Control: no-store`, and rate-limited per IP under its own
`app` family. The worker never rejects a request for its version.
An account proves it's the app once and then calls with short-lived bearer
tokens (witness-work ADR 0027; "Sessions" in docs/accounts-protocol.md).

- Sessions attest through the shared registry: `account-session` joins
  ATTESTATION_PURPOSES, so POST /attestation/v1/challenge issues its App
  Attest v2 or Play Integrity challenge, with contentHash = requestHash =
  hex(SHA-256("ww-account/v1/session\n" + accountPub)). verifyAttestation
  (src/attestation/http.ts) is requireAttestation's check for routes with
  their own error format.
- POST /accounts/v1/sessions takes an envelope self-signed by the account
  key (op `session/open`) plus that attestation (or, on development only,
  the dev bypass from src/devBypass.ts), creates the account when the key
  has none, and answers 201 with a 90-day session and `Location:
  /accounts/v1/sessions/current`. Optional sealed device label (512
  chars). At most 10 live sessions an account (the oldest is ended) and 20
  opens per key a rolling day, on top of the register edge tier. A refused
  attestation is 403 `attestation_failed` with a `reason`.
- POST /accounts/v1/sessions/refresh rotates the refresh token every time.
  The replaced token still works for a minute; after that, presenting it
  ends the session.
- DELETE /accounts/v1/sessions/current (Bearer) ends the session,
  idempotently. GET /accounts/v1/account (Bearer) reads the account.
- Access tokens are HS256 JWTs (15 minutes, never past the session's end)
  with claims iss, aud, sub, sid, platform, iat, and exp, under
  ACCOUNT_TOKEN_SECRET, with ACCOUNT_TOKEN_SECRET_PREVIOUS verifying
  through a rotation. Without a secret, the session routes answer
  `disabled`.
- account_sessions (migration 0001_account-sessions) stores only SHA-256
  hashes of the current and previous refresh token: no install id, key, or
  IP. The hourly cron deletes sessions a week after they end.
- requireAccount() and bearerOrLegacy() (src/accounts/auth.ts) put the
  verified caller on c.var.account; a bad token is 401 `unauthorized` with
  WWW-Authenticate. account/info, handoff's old-phone ops, and backup
  put/list/get accept a bearer token or, as before, the envelope;
  backup/delete takes both (step-up). account/register and account/info
  are documented as legacy. Handoff and backup stay POST /{op} so their ids
  never reach a URL.
- The legacy switch (KV `auth:legacy`, then AUTH_LEGACY, then on) refuses
  the pre-session ways with 426 `upgrade_required` once it's off.
- Sentry drops the bearer backup put's x-ww-slot-id header.
The launcher writes a generated ACCOUNT_TOKEN_SECRET into the local
worker's vars, and doctor checks that sessions are on (a zero refresh token
gets 401 `unauthorized` from POST /accounts/v1/sessions/refresh). The
accounts e2e suite opens a session through the dev bypass (201 with
Location), reads GET /accounts/v1/account and the legacy account/info with
the bearer token, refreshes, closes with DELETE /accounts/v1/sessions/current,
uses bearer backup and handoff, turns the legacy switch off in local KV to
see 426 `upgrade_required`, and reads /app/v1/config. The verify map gets an
accounts feature file.
AGENTS.md covers the session routes (POST /accounts/v1/sessions, its
refresh, DELETE /accounts/v1/sessions/current, GET /accounts/v1/account),
attesting through /attestation/v1 under `account-session`, storage, tokens,
the dev bypass, secret rotation, the helpers new routes build on,
/app/v1/config, and "Turning off backward compatibility": raise
app:min-version and notes-import:min-version, then turn auth:legacy off,
and how to undo it. README gets the same routes in brief, and the 401/426
answers beside the shared error envelope.
A Buddies request with an Authorization header is a bearer request
(witness-work ADR 0027): its access token must verify (401 unauthorized,
never a fallback to the signature). Without the header it's a legacy
request, served byte for byte as before while auth:legacy is on and 426
upgrade_required once it's off, leaving ops included.

- Owner ops send their fields as plain JSON (no envelope, ts, or nonce);
  inbox/live and blob/put send them in x-buddies-payload with no
  signature header. The inbox DO authorizes them against its stored
  owner_account: another account is 403 forbidden, an inbox registered
  before accounts 409 not_linked (new code). No nonce is checked or
  recorded for them; the owner's request budget is shared by both forms.
- inbox/register keeps its self-signed envelope; in a bearer request the
  account proof is required (400) and must name the token's account
  (403 forbidden), checked before the signature and quota.
- Writer ops keep the writer-signed envelope as the slot capability; the
  token's session is verified but its account is never compared with the
  slot or stored. Invite and blob reads keep today's body.
- The live upgrade reaches the inbox DO with the token's account in an
  internal header the Worker always strips from client requests.
- Bearer requests count the per-caller edge tiers and the inbox/register
  daily quota against a hash of the account instead of the client IP;
  budgets are unchanged.
- Payload parsing splits into BUDDIES_FIELD_PARSERS (shared by bearer
  bodies) and BUDDIES_PAYLOAD_PARSERS (adds ts and nonce), unchanged for
  signed requests.
- OpenAPI documents both forms (bearer first), the bearerAuth scheme,
  not_linked, forbidden, unauthorized, and 426 on every route.
The buddies-sessions e2e suite opens sessions through the dev bypass and
runs a bearer round trip on the local worker: register with a proof,
slot/add, a buddy's event/put with its own token, owner sync (twice, no
replay), blob put/get, invite fetch/claim, and the live socket's hello,
changed, pong, and 4001 close. It checks not_linked until a legacy inbox
links, forbidden for another account's token and proof, 401 for a bad
token and 400 for an envelope or a signature header on a bearer request,
426 for every legacy form once auth:legacy is off while bearer requests
work, and one account's unsigned budget spent from rotating addresses
while another account and a legacy caller from that address still pass.

The fuzzer opens sessions with the recorded dev bypass token, keeps a
second (bearer) canary that must come through intact, and adds nine
bearer generators: bad tokens on every route class, cross-account,
not_linked, malformed plain bodies, register proofs, bearer blob/put
headers, writer ops with any session, a round trip, and per-account edge
limits. Without a bypass token they're skipped and counted as skipped.
Report transcripts redact authorization and x-ww-dev-bypass. The wire
client gains OWNER_OPS and the account envelope and inbox-link helpers,
kept in step with the server by buddies-wire.test.ts.
AGENTS.md's Buddies section gains an Authentication bullet (bearer and
legacy forms per op class, forbidden and not_linked, the 426 once
auth:legacy is off, the live socket's internal account header), keys the
per-caller limits and the registration quota on the hashed account for
bearer requests, and replaces the App Attest "not yet built" item:
bearer requests are attested once, at session open, and only retiring
legacy requests remains.

docs/buddies-protocol.md (ww-api's copy) adds "Authentication
(sessions)" in readable names, the unauthorized, forbidden, not_linked,
and extended upgrade_required codes, the bearer forms of inbox/live and
blob/put, 4003 on the live socket, the account proof on inbox/register,
and per-account limits.
NotesImportRun created its tables in the constructor. deleteAll() drops
them with the rows, so the same instance then threw "no such table" on its
next start(): re-importing notes after destroy, or after the retention
alarm wiped a finished run, failed kickoff with 500 until the object was
evicted. Tables are now created on the first write and the flag resets after
deleteAll(), as the inbox and handoff objects already do.

Reads (getResult, cancel, the SSE replay, a redelivered alarm) no longer
create storage for an import that never started.
Notes Import identifies the caller by an attested session's access token
(witness-work ADR 0027 §1, §4). A request with an Authorization header is a
bearer request, always: kickoff, the synchronous POST /notes-import, and
transcribe take no attestation fields or hashes, meter by the token's
account, and refuse a suspended account. Run routes (events, result, cancel,
destroy) take the same token and serve only imports that account started;
any other import id is the same 404 not_found. Requests without the header
keep the pre-session contract while the legacy switch is on and get 426
upgrade_required once it's off. Every refusal keeps Notes Import's error
shape (message in `error`, stable `code`).

- Supporter status goes through one seam, isAccountSupporter
  (src/accounts/supporter.ts), at kickoff and at settlement. Until
  RevenueCat is linked to the account, it checks the request's optional
  revenueCatAppUserId; absent means free.
- A dev session (the dev bypass) gets the bypass's unlimited allowances on
  the dev worker only; analytics take the platform from the token and
  identify the import by the hashed account id with has_account true.
- The run keeps a hash of its owning account until it's deleted. Bearer
  import ids hash a basis no legacy request can produce, so a legacy kickoff
  can never land on an account's run and get a subscribe token for it.
- Bearer kickoffs mint no subscribe token.
README's Notes Import section gains the bearer wire contract (bodies,
responses, revenueCatAppUserId, run-route ownership and its 404, errors,
fresh allowances, dev sessions) and marks per-request attestation and the
subscribe token as legacy. /docs documents both forms on every route
(bearerAuth or none), the 401/403/404/426/503 codes, and the optional
subscribeToken. AGENTS.md gets a "Notes Import on account sessions" section,
the account id as an admin-reset meter, and the PostHog identity and
platform for bearer requests.
…loop

A dev-bypass session's bearer token runs kickoff → result → SSE events →
destroy, then re-kicks the same notes on the recreated run; another account
gets 404 on every run route; a bad token is 401 even with the bypass header;
and with the legacy switch off, legacy kickoff, transcribe, and ?token= run
routes are 426 while bearer requests work. Without an OpenRouter key the run
ends model_error at no cost; with one the lifecycle needs
WW_API_E2E_ALLOW_PAID=1. The verify skill's Notes Import recipe lists the
new checks.
`inbox/register` wrote the account link into the inbox Durable Object
before linking in D1. If the D1 write failed, bearer owner ops already
passed the inbox's account check while moderation, which finds an
account's inboxes in D1, never reached that inbox. Two accounts claiming
the same unlinked inbox at once could also leave D1 and the inbox naming
different owners.

`owner_account` gains `confirmed`, and bearer owner ops (and the live
socket and `blob/put`) need a confirmed link, else 409 `not_linked`. The
inbox takes a link as confirmed only from D1: an existing link rides
along with the registration, a new one is confirmed with
`confirmAccount` after `linkInbox` wrote it (naming D1's winner, so
concurrent claims agree and the loser gets `conflict`, and carrying the
status read after the write, so a suspension racing the link still
applies), and moderation's status changes confirm the inbox they found
in D1. A registration that fails between the steps leaves `not_linked`
until the app's relink gets through; registering again is idempotent.

Rows from before the column are added in place as unconfirmed, so their
owner's first bearer owner op gets `not_linked` and the relink confirms
them against D1. Legacy requests are unchanged, and the D1 link is still
written only after the inbox accepted the owner-signed registration.
Drizzle's `DrizzleQueryError.message` quotes the failed query and its
bound parameters (account ids, session ids, refresh token hashes, inbox
ids), and the accounts, sessions, and Buddies guards passed such errors
straight to `Sentry.captureException`.

- `captureFailure(error, operation)` in src/sentry.ts reports a
  database error (Drizzle's, D1's `D1_…:` messages, or any error one
  caused) as a `DatabaseError` naming only the operation: the original
  stack frames, no message, query, parameters, or cause. Every accounts,
  sessions, and Buddies capture site uses it (`guarded`, the quota and
  status reads, Buddies' ops, live socket, and blob routes, admin, the
  session prune cron), and the app's `onError` reports and logs
  unguarded routes the same way, labelled with the route pattern. Notes
  Import's account path reads D1 only through `authenticateAccount`.
- `beforeSend` (and `beforeSendTransaction`/`beforeBreadcrumb`) cut
  `Failed query:`, `params:`, and `D1_…:` text from every string in an
  event, and drop the exceptions chained under a redacted one.

Tests run the real Sentry client (linked errors on) with the worker's
config against a synthetic DrizzleQueryError and against real route
failures from a D1 double whose queries fail.
The store kept only the current and immediately previous refresh token
hashes and looked tokens up by hash, so after R0 → R1 → R2 presenting R0
was merely unknown (401) while R2 stayed live, though the contract says
any older token ends the session.

Refresh tokens are now self-identifying, `<sessionId>.<secret>` (b64u of
16 and 32 bytes, each in its canonical spelling; still opaque to the
app). The session is read by id and the SHA-256 of the whole token is
compared, in constant time, with the stored current and previous hashes.
A token for a live session that is neither the current one nor the one
just replaced within its minute ends the session: an older token, the
replaced one after its minute, or one the session never issued (only
someone who knows the session id can try, and its access token can end
the session anyway). Malformed tokens are 401 before any database read;
unknown, ended, or expired sessions are 401 without a write. Only hashes
are stored, as before, with no new table or migration.

Sessions opened before this change hold old-format tokens, which are now
malformed: their next refresh is 401 and the app opens a new session.
The app hasn't shipped sessions.
…freshes

Two refreshes presenting the replaced token within its minute could both
update the row, each issuing a different token, and the reissue
orphaned the token issued just before it. With older tokens now ending
the session, a device that kept the orphaned one (a double refresh, or a
retry racing a lost response) would end its own session on its next
refresh.

- The token that replaces another is derived from it: HMAC-SHA256 of the
  presented token under a rotation key derived from
  `ACCOUNT_TOKEN_SECRET` (`rotationKey`, kept apart from the JWT signing
  key). Every refresh presenting the same token gets the same next one,
  so no response carries a token the session doesn't know.
- Rotating the current token is a compare-and-swap on the hash read
  (`UPDATE … WHERE id = ? AND refresh_hash = <hash read>`): only one
  refresh rotates the row. The loser reads the row once more and finds
  its token just replaced, so it answers with the same new token. If the
  session moved on again in between, it's 401 without ending the
  session, since that token was current a moment ago.
- The replaced token within its minute writes nothing: it gets the token
  it was replaced by (tried under the current, then the previous, token
  secret, so a retry spanning a secret rotation still works while
  `ACCOUNT_TOKEN_SECRET_PREVIOUS` is set; without it the retry is 401 and
  the session lives).

Tests interleave refreshes at the compare-and-swap: both of a double
refresh get the same token and the session lives; a lost race after the
session moved on is 401 without revoking; two grace retries write nothing.
The gate rejected only a missing `notesText`, so `""` or a run of
whitespace passed the body checks and reached attestation, the credit
pre-flight, and possibly the model. Kickoff and the synchronous
`POST /notes-import` now answer 400 `bad_request` ("notesText is empty")
for it, bearer and legacy alike, right after the missing-field checks and
before any attestation, RevenueCat, allowance, or model work.

Documented in the README and the routes' OpenAPI (the field and the 400).
…he legacy switch

The iOS Notification Service Extension fetches events too big for a push
with an owner-signed `inbox/sync`. It can't hold the session's rotating
refresh token (it would race the app and trip reuse revocation), so once
KV `auth:legacy` is off its fetch got 426 and named alerts fell back to
the template.

While the switch is off, the Worker now admits one legacy request: an
`inbox/sync` carrying `x-buddies-client: notification-service`, as
`linkedOnly`. The inbox DO checks the owner signature, `ts`, nonce, and
suspension exactly as before, then serves it only when its `owner_account`
link is confirmed (the owner registered the inbox on a session, and D1
holds the link); otherwise it answers 426 `upgrade_required`, after the
signature check. Every other legacy request stays 426, including other
ops with the header and a wrong header value. While the switch is on, and
on bearer requests, the header changes nothing. Nothing logs it.

Tests cover each branch (confirmed, unlinked, unconfirmed, a pre-column
row, other ops, wrong values, switch on, bearer), and the verify loop's
e2e suite runs the confirmed and unlinked cases against a real worker.
Documented in AGENTS.md (Buddies and the legacy-switch runbook: the floor
must be a version whose extension sends the header), the protocol, and
`inbox/sync`'s OpenAPI.
Bearer kickoff, the bearer synchronous route, and a legacy kickoff with
empty or whitespace-only notesText each answer 400 bad_request against the
local worker.
Refresh tokens were `<sessionId>.<secret>`, and a well-formed token naming
a live session that matched neither the current nor the just-replaced hash
ended the session as reuse. The session id is no secret (the access
token's `sid` claim, the open response), so anyone who learned it could
end someone's session with `<sessionId>.<random>`.

Refresh tokens are now `<sessionId>.<secret>.<mac>`: the MAC is
HMAC-SHA256 over `<sessionId>.<secret>` under its own key from
ACCOUNT_TOKEN_SECRET (label `ww-account/v1/refresh-mac`, apart from the
access-token key and the next-token rotation key), checked under the
current and previous secret with `subtle.verify` (constant time). A token
whose MAC doesn't verify is 401 before any D1 read and ends nothing; only
a MAC-valid token that's neither current nor the replaced one within its
minute counts as reuse and ends the session. The next token is still
derived from the presented one, and signed under the same secret that
derived it, so the same token always gets the same next one, including a
lost-response retry across a secret rotation; the CAS rotation is
unchanged.

Two-part tokens from before the MAC (issued on the dev worker) are
malformed now: 401 without a database read or revocation, and the app
opens a new session. Tokens stay opaque, 110 characters.

Behaviour change for secret rotation: refresh tokens issued under the old
secret refresh only while it's in ACCOUNT_TOKEN_SECRET_PREVIOUS; AGENTS.md
now says to keep it 90 days for a routine rotation (delete at once after a
leak), since deleting it sends every device that hasn't refreshed since
through a new session.

Tests: made-up and moved MACs never read the database or end a session;
MACs under the access-token key, the rotation key, another label, or
another secret are refused, while the same token under the worker's own
MAC key ends it (the MAC is the only difference); genuine older tokens
still end the session, across a rotation too; a token signed under the
previous secret refreshes and its replacement is signed under the new
one; malformed and two-part inputs never touch the database. Removing the
MAC check fails four of them.
… slot

The synchronous POST /notes-import keyed its slot on the content
(`legacy:<importId>`), and the index DO admits every request for a key
it already holds, so N identical requests at once each ran the model
while holding one slot, and the first to finish released it for all.

The index DO now hands each synchronous request a slot of its own
(`acquireSync`, key `legacy:<uuid>`), so a burst of identical requests
is capped like any other. Charging is unchanged: recordUsage still
charges a content hash once however many runs reach it, and a refinement
still counts per run (now at most `cap` at once). The keys still never
collide with a streaming run's importId.

A per-request key can't be reclaimed by a retry the way the content key
was, and the `finally` that releases it may never run (a client that
disconnects mid-run, an evicted isolate), so `acquireSync` first drops
synchronous slots older than 15 minutes (SYNC_SLOT_MAX_AGE_MS), far past
any real run. That also clears content-keyed slots leaked before this
change. Streaming runs' slots are never aged out.

Tests (real index and run DOs): identical bearer, App Attest v1 legacy,
and dev-bypass bursts run exactly `cap` models in distinct slots, refuse
the rest with 429 active_cap while they run, free every slot, and charge
the content once; identical refinements count per run; a synchronous
request never shares a run's slot; abandoned slots age out at exactly 15
minutes, old content-keyed ones too, while a run's slot stays. All six
route tests fail against the previous slot key.
Legacy Notes Import run routes (events, result, cancel, destroy) take
their subscribe token as `?token=`. The Cloudflare SDK records the
request URL whole (event.request.url and query_string, the root span's
`url.full`, the Referer header, spans and breadcrumbs), and Notes Import
traces at 100%, so live subscribe tokens reached Sentry.

`src/sentry.ts` now cuts the value of every `token` and `apiKey` query
parameter (the HERE proxy drops a client-sent `apiKey`; no other route
reads a credential from its query) from every string the existing
scrubbers walk in errors, transactions with their spans, and
breadcrumbs, leaving the rest of the URL (`token=[redacted]`). An object
or pair-list `request.query_string`, which the event format allows, is
redacted by key. Names match without regard to case.

Tests: the scrubber on URLs, query strings, quoted and prose strings;
hand-built errors, transactions, and breadcrumbs; and a real request
through the SDK's own wrapRequestHandler with the worker's config and a
recording transport, whose sent error and transaction carry no token
anywhere. Removing either scrubber fails them.
A made-up token for a live session's id, a real MAC moved to another
secret, and two-part tokens from before the MAC each get 401
unauthorized against the local worker, and the session's own token
still refreshes after them.
…coded

The scrubber matched the literal names `token=` and `apiKey=`, but Hono
decodes parameter names, so a legacy run route reads `?to%6Ben=<subscribe
token>` as `token` while the raw URL reached Sentry unchanged (request.url
and query_string, `url.full`, the Referer header, spans, breadcrumbs). The
HERE proxy's URLSearchParams likewise reads `api%4Bey` as `apiKey`.

`src/sentry.ts` now finds every `name=` at a string's start or after `?`
or `&`, decodes the name as URLSearchParams does (`+` as a space,
percent-escapes decoded, a malformed one kept, never an error), and cuts
the value when the decoded name is a credential name, without regard to
case. The raw name stays (`to%6Ben=[redacted]`) and nothing else in the
string is re-encoded. Names are matched only up to `=`, so a parameter
inside another one's value (`?next=/x?token=…`) is still found, while a
cut value runs to the next `&` whatever it holds. Object and pair-list
`query_string` keys are decoded the same way.

Tests: encoded, mixed-case, nested, and malformed names in strings and
query_string objects and pairs; a cross-check that whenever Hono's
`c.req.query('token')` or URLSearchParams' `get('apiKey')` reads the value
for a spelling of the name, the scrubbed URL holds none of it (and that
names decoding to anything else are left alone); and a real request
through the SDK's wrapRequestHandler with `?to%6Ben=` and an encoded
upstream URL, whose sent error and transaction carry no token. The new
tests fail against the previous scrubber.
…ge out

The index DO ages out synchronous POST /notes-import slots after 15
minutes (SYNC_SLOT_MAX_AGE_MS), since a disconnected client's `finally`
may never release one. The synchronous model call had no deadline, so a
call still running past 15 minutes lost its slot to the age limit and let
another import start past the per-user cap.

The call now runs under SYNC_IMPORT_DEADLINE_MS (10 minutes), defined in
`cap.ts` beside the slot lifetime, with a comment tying the two together.
At the deadline it aborts the model request through its abort signal and
stops waiting at once, even if the call ignores the signal, then takes the
route's existing model-error path: 502 `model_error`, reported to Sentry
(as a ModelDeadlineError) and analytics, nothing charged, and the slot
released in the `finally` as before. The streaming run has no deadline of
its own to reuse, and its slot never ages out. The OpenAPI description,
whose minutes come from the constant, and the design doc say so.

Tests: the deadline stays at least five minutes short of the slot
lifetime; with a fake model that never resolves and ignores its signal in
every slot of the cap, the requests run and hold the cap (a third gets
429) until 1 ms before the deadline, and at it each answers 502
model_error with its signal aborted, frees its slot itself (not by age),
charges nothing, and reports outcome model_error; the next import runs.
The route test fails against the previous route.
@leviFrosty
leviFrosty merged commit 79fd918 into main Oct 18, 2026
1 check passed
leviFrosty added a commit that referenced this pull request Oct 18, 2026
- feat: Supporter status on the server account, kept by RevenueCat webhooks (#51)
- feat: account activity log and signed-in devices (#50)
- feat: attested account sessions with bearer tokens for Buddies and Notes Import (#49)
- feat: move attestation to /attestation/v1 and add readable link paths (#47)
- feat: use readable payload and signature names in signed envelopes (#46)
- docs: trim one-time setup steps from the agent guide (#48)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant