Repository navigation
feat: attested account sessions with bearer tokens for Buddies and Notes Import - #49
Merged
Merged
Conversation
This was referenced Oct 16, 2026
leviFrosty
marked this pull request as ready for review
October 16, 2026 09:36
4 tasks done
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
force-pushed
the
account-sessions
branch
from
October 18, 2026 07:14
1b825cc to
d9a25b0
Compare
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 ontomain;refactor/split-attestation-endpointitself 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/sessionsopens a session. The request is the account-key envelope (opsession/open) plus an App Attest assertion or a Play Integrity token for purposeaccount-session. The challenge comes fromPOST /attestation/v1/challenge. With no integrity there is no account, as decided.POST /accounts/v1/sessions/refreshrotates 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/currentsigns out, andGET /accounts/v1/accountreads the account.requireAccount()insrc/accounts/auth.tsgates routes on the bearer token. Sessions are stored in D1account_sessions(migration0001_account-sessions.sql).backup/deletestill also needs the account-key envelope as a step-up.Buddies on bearer
inbox/registertakes the token plus the owner envelope, and the account proof is now required.invite/*andblob/gettake the token plus their capability.not_linked, so a half-finished registration can't escape suspension.inbox/sync(x-buddies-client: notification-service) keeps working after the legacy switch, for confirmed links only.Notes Import on bearer
isAccountSupporter(src/accounts/supporter.ts), the seam for the RevenueCat link.notesTextis refused.Turning off backward compatibility
GET /app/v1/configreturns{ok, minAppVersion?}from KVapp:min-version(else theAPP_MIN_VERSIONvar). Below it, the app shows a blocking Update Required screen.auth:legacy(else theAUTH_LEGACYvar,"on") switches the pre-session ways of authenticating. When it's off they get 426upgrade_required.app:min-versionnotes-import:min-versionauth:legacytooffx-buddies-clientheader.Sentry
Authorization, admin, dev bypass,x-buddies-*, andx-ww-*envelope/slot headers.captureFailure), never with their bound parameters.token/apiKeyquery values are cut from events and traces, including percent-encoded parameter names.Docs
Before deploying to production
openssl rand -base64 32 | wrangler secret put ACCOUNT_TOKEN_SECRET. Without it the session routes are 503disabled, and the legacy paths keep working.0001_account-sessions.sqlbefore deploying.Testing
Run on
1b825cc:pnpm test: 1,028 passed.pnpm exec tsc --noEmit: clean.wrangler deploy --dry-runand--env dev --dry-run: both build.verify-ww-apiloop:doctorok;pnpm test:e2e74 passed with 3 skipped (the same skips asmain);pnpm fuzz:buddiesok (300 cases, both canaries intact).ww-proxy-dev) with D1 migration0001applied. The app's interop suite against dev passes (18 passed, 2 paid model runs skipped).auth:legacyoff: every relay call was 2xx andinbox/livewas 101, so all of them were bearer calls.blob/get), a reply, and a reaction. Live updates arrived in about 1–1.6 s.not_linked→ relink → retry path passed.Review
Three security review rounds ran on the server (two by a second model). Every finding is fixed in this PR:
captureFailureand scrubbing.?token=reached Sentry. Fixed by query redaction.Known gaps
revenueCatAppUserId, as it does today. The linking change replaces the body ofisAccountSupporterand drops the field.