Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ca8bcd6
fix: keep bearer, admin, and dev bypass tokens out of Sentry events
leviFrosty Oct 16, 2026
f6fa67a
feat: GET /app/v1/config serves the app's minimum version
leviFrosty Oct 16, 2026
d8c52dc
feat: attested account sessions and bearer access tokens
leviFrosty Oct 16, 2026
29c55f1
test: drive account sessions through the verify loop
leviFrosty Oct 16, 2026
8ac961e
docs: account sessions, bearer routes, and the legacy switch runbook
leviFrosty Oct 16, 2026
acd3852
feat: Buddies relay on account sessions (bearer tokens)
leviFrosty Oct 16, 2026
2ca3e54
test: drive the bearer Buddies relay through e2e and the fuzzer
leviFrosty Oct 16, 2026
674a891
docs: Buddies on account sessions in AGENTS.md and the protocol
leviFrosty Oct 16, 2026
7ddb8bb
fix(notes-import): create run storage on first write and after deleteAll
leviFrosty Oct 16, 2026
81aac89
feat(notes-import): meter by the session's account on bearer requests
leviFrosty Oct 16, 2026
d0e624d
docs(notes-import): the bearer contract, ownership, and legacy forms
leviFrosty Oct 16, 2026
532e736
test(e2e): drive Notes Import on account sessions through the verify …
leviFrosty Oct 16, 2026
7b411f5
fix(buddies): authorize bearer owners only for links D1 confirmed
leviFrosty Oct 16, 2026
a0cd246
fix(sentry): report database errors by operation, never their parameters
leviFrosty Oct 16, 2026
64801dc
fix(accounts): end the session when any older refresh token returns
leviFrosty Oct 16, 2026
39e2436
fix(accounts): one rotation per refresh token, even for concurrent re…
leviFrosty Oct 16, 2026
d044728
fix(notes-import): refuse empty or whitespace-only notesText
leviFrosty Oct 16, 2026
c36c6e5
feat(buddies): keep the Notification Service Extension's sync after t…
leviFrosty Oct 16, 2026
381e488
test(e2e): empty Notes Import text is a 400 through the verify loop
leviFrosty Oct 16, 2026
fa4bcce
fix(accounts): a forged refresh token can no longer end a session
leviFrosty Oct 16, 2026
e0d0d87
fix(notes-import): every synchronous import holds its own concurrency…
leviFrosty Oct 16, 2026
aa07a53
fix(sentry): cut credential query parameters from events and traces
leviFrosty Oct 16, 2026
aa1df38
test(e2e): a refresh token the worker didn't issue never ends a session
leviFrosty Oct 16, 2026
40eaf21
fix(sentry): cut credentials whose query parameter name is percent-en…
leviFrosty Oct 16, 2026
d9a25b0
fix(notes-import): end a synchronous model call before its slot can a…
leviFrosty Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .agents/skills/verify-ww-api/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ node scripts/verify/dev.mjs up --secrets-from ~/dev/ww-api/.dev.vars # opt in to
- Ready when `up` prints `verify: worker ready (pid N, …)` and then the URL. It polls `GET /health` for up to 90 s and exits nonzero with the last log lines if wrangler dies.
- `up` is idempotent. If `.verify/state.json` names a live, healthy worker, `up` reuses it. If the recorded worker is alive but unhealthy, `up` restarts it.
- Env `dev` gives `APP_ATTEST_ENVIRONMENT=development`, `BUDDIES_ENABLED=true`, and `BUDDIES_PHOTOS=on`. The fresh KV has no `buddies:enabled` or `buddies:photos` key, so the relay and its photo blobs are on with no switch to flip. The `BUDDY_BLOBS` R2 bucket is simulated locally in the persistence dir.
- `.verify/dev.vars.env` (mode 600) holds a random `NOTES_IMPORT_DEV_BYPASS_TOKEN` and `ADMIN_API_TOKEN`, plus `APPLE_TEAM_ID=VERIFY0000` unless a real one is passed. The tokens are reused across restarts and recorded in `state.json` so tests can read them.
- `.verify/dev.vars.env` (mode 600) holds a random `NOTES_IMPORT_DEV_BYPASS_TOKEN`, `ADMIN_API_TOKEN`, and `ACCOUNT_TOKEN_SECRET` (account sessions), plus `APPLE_TEAM_ID=VERIFY0000` unless a real one is passed. The tokens are reused across restarts and recorded in `state.json` so tests can read them.
- The WitnessWork app's verify loop consumes this as `node <ww-api>/scripts/verify/dev.mjs up` followed by `… url`.

## Doctor
Expand All @@ -37,6 +37,7 @@ Read-only. Each check prints an `ok` or `FAIL` line, and the command exits 1 on
- Buddies is enabled (an unknown `invite/fetch` returns `not_found`, not `disabled`);
- Buddies photos are enabled (an unknown `blob/get` returns `not_found`, not `photos_disabled`);
- the dev bypass works (the value is never printed);
- account sessions are on (an unknown refresh token returns `unauthorized`, not `disabled`);
- which optional credentials are set.

Run it first whenever anything looks off.
Expand Down Expand Up @@ -68,14 +69,15 @@ pnpm fuzz:buddies --seed 42 --case 17 # replay one failing case

The seed decides each case's mutation and its parameters. Ids, keys, and nonces are fresh every run, so you can replay a seed against the same persisted state.

There are 19 generators:
There are 28 generators, 9 of them for bearer requests (skipped, and counted as skipped, without a dev bypass token):

- **Bad signatures:** tampered signature, tampered payload, wrong key or wrong role, a signature reused on another op, malformed `signature`.
- **Replay and time:** sequential and parallel replay, stale `ts`.
- **Bad payloads:** a wrong-typed field, a missing field, malformed envelopes (bad JSON, base64, UTF-8), unicode and huge strings.
- **Size and caps:** blob sizes at and one byte over every limit, envelopes over 256 KiB (declared and chunked), slot, invite, template, kind, and expiry caps.
- **Photo blobs:** `blob/put` → `blob/get` round trip with an idempotent retry, 1 MiB accepted and one byte more `too_large`, a flipped bit (`bad_request`), a stranger's signature, and the same 404 for a wrong token, an unknown blob or inbox, and a deleted blob.
- **Other:** unknown routes and methods, invite claim burn after 5 wrong secrets, parallel `event/put` dedupe, per-caller edge limits (one IPv4, or rotating addresses in one IPv6 /64, until 429 `rate_limited` with `Retry-After: 60`).
- **Bearer requests** (sessions opened through the dev bypass): bad tokens on every route class (forged, truncated, `alg: none`, a refresh token, other schemes; always 401 `unauthorized` with the challenge, never the envelope), another account's token (403 `forbidden`), an inbox registered before accounts (409 `not_linked`), malformed plain-JSON bodies and envelopes sent with a token (400), `inbox/register` proofs (missing, another account's, forged, for another inbox), bearer `blob/put` headers, writer ops with any session's token, a bearer round trip, and per-account edge limits from rotating addresses.

The oracle checks every request:

Expand All @@ -84,7 +86,7 @@ The oracle checks every request:
- Buddies errors are `{error}` JSON (the full body is `{ok: false, error, code}`); only a 200 `blob/get` is raw bytes;
- each mutation gets its documented status (for example, a bad signature is 401 `bad_signature`).

After the run, a canary inbox's full `inbox/sync` must equal its pre-fuzz snapshot, and `/health` must still be ok. When a case fails, the fuzzer prints the seed, a `--case` repro command, and the exact failing request, then exits 1.
After the run, a canary inbox's full `inbox/sync` must equal its pre-fuzz snapshot (and so must the bearer canary's, registered and synced by bearer request), and `/health` must still be ok. The report records secret headers (`authorization`, `x-ww-dev-bypass`) as `<redacted>`. When a case fails, the fuzzer prints the seed, a `--case` repro command, and the exact failing request, then exits 1.

## Evidence

Expand Down Expand Up @@ -116,6 +118,7 @@ Only names are listed here; never print values.
| --- | --- | --- |
| `NOTES_IMPORT_DEV_BYPASS_TOKEN` | Notes Import bypass tests | generated per state dir |
| `ADMIN_API_TOKEN` | admin reset positive tests | generated per state dir |
| `ACCOUNT_TOKEN_SECRET` | account sessions (`/accounts/v1/sessions*`, bearer tokens) | generated per state dir |
| `APPLE_TEAM_ID` | AASA app ids | `VERIFY0000` (synthetic) |
| `HERE_API_KEY` | `/geocode` + `/autocomplete` smoke (one call each) | unset, so skipped; set `WW_API_E2E_SKIP_HERE=1` to skip even when set |
| `OPENROUTER_API_KEY` | real provider health and the paid kickoff | unset; the status probe fails open |
Expand All @@ -131,7 +134,7 @@ Only names are listed here; never print values.
## Helpers

- `scripts/verify/dev.mjs`: `up`, `doctor`, `url`, `kv <get|put|delete> <key> [value]`, `down [--wipe]`. Also available as `pnpm verify <cmd>`.
- `pnpm test:e2e` uses `vitest.e2e.config.ts`. It runs files serially because the kill-switch test flips shared KV. Helpers are in `src/test/e2e.ts`: `http`, `RelayOwner`, `RelayWriter`, `localKv`, `writeTranscript`.
- `pnpm test:e2e` uses `vitest.e2e.config.ts`. It runs files serially because the kill-switch test flips shared KV. Helpers are in `src/test/e2e.ts`: `http`, `RelayOwner`, `RelayWriter`, `openSession`, `BearerRelayOwner`, `sendWriter`, `localKv`, `writeTranscript`.
- `pnpm fuzz:buddies` runs `scripts/verify/fuzz-buddies.mjs`. It uses the plain-Node wire client `scripts/verify/buddies-wire.mjs`, which `scripts/verify/buddies-wire.test.ts` (part of `pnpm test`) keeps in sync with `src/buddies/contracts.ts` and `envelope.ts`.
- CI is `.github/workflows/test.yml`: typecheck, unit tests, `up`, `doctor`, e2e, `fuzz --runs 100`, then `down`. Evidence is uploaded as the `verify-evidence` artifact.
- To keep the feature map honest as routes change, use `/maintain-verification-skill`.
3 changes: 2 additions & 1 deletion .agents/skills/verify-ww-api/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ Keep implementation details out of the map. Name only user paths, stable handles

- [Health and status](./health-and-status.md) covers `/health`, `/notes-import/status`, App Store ratings, and the JSON 404.
- [Notes Import](./notes-import.md) covers the attestation verify probe and challenge (`/attestation/v1/*` and their old `/notes-import/*` paths), kickoff's auth gate, and the opt-in paid kickoff.
- [Buddies relay](./buddies-relay.md) covers pairing end to end, sync read-backs, auth failures, caps, the kill switch, and fuzzing.
- [Buddies relay](./buddies-relay.md) covers pairing end to end, sync read-backs, auth failures, caps, the kill switch, bearer requests on account sessions and the legacy switch, and fuzzing both.
- [Universal link pages](./universal-link-pages.md) covers the AASA, the `/contacts` (`/c`, `/c/:payload`) contact pages, and the `/buddies/invite` (`/b`) invite page.
- [Admin reset](./admin.md) covers the maintainer usage reset and its 404 fail-closed auth.
- [Accounts and sessions](./accounts.md) covers sessions through the dev bypass, `GET /accounts/v1/account`, bearer handoff and backup, the legacy switch, and `/app/v1/config`.

Not mapped yet: supporter route planning (`/route-planning`, needs `HERE_API_KEY`). Map it with `maintain-verification-skill` before relying on it.
36 changes: 36 additions & 0 deletions .agents/skills/verify-ww-api/features/accounts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Accounts and sessions

The app proves it's WitnessWork once per session (`POST /accounts/v1/sessions`, attested through `/attestation/v1/challenge`; the dev bypass locally) and then calls account routes with a 15-minute bearer token it renews with `POST /accounts/v1/sessions/refresh`. Settings → Account reads the account, Set Up Another Device hands the key to a new phone, and backups go to R2. Older app versions keep using signed envelopes until the legacy switch is turned off, and `/app/v1/config` tells every new version its minimum.

## Sub-features

- `session-open` (`POST /accounts/v1/sessions`) answers 201 with `Location: /accounts/v1/sessions/current` and `{ok, accountId, status, sessionId, accessToken, accessExpiresAt, refreshToken, sessionExpiresAt}` for a self-signed envelope plus the dev bypass.
- `bearer-info` returns the token's account from `GET /accounts/v1/account` (and, for older builds, `POST /accounts/v1/account/info` with no body).
- `refresh` (`POST /accounts/v1/sessions/refresh`) replaces both tokens; `sessionExpiresAt` never moves.
- `close` (`DELETE /accounts/v1/sessions/current`) ends the session; its refresh token then gets 401 `unauthorized` with `WWW-Authenticate: Bearer error="invalid_token"`.
- `bearer-backup` puts with `x-ww-slot-id`/`x-ww-sha256`, gets the bytes back, and deletes only with the account key's envelope too.
- `bearer-handoff` opens and reads a channel with the token.
- `legacy-off` answers envelope-authenticated requests with 426 `upgrade_required` once KV `auth:legacy` is `off`, while bearer requests and opening sessions keep working.
- `app-config` returns `{ok}` or `{ok, minAppVersion}` from KV `app:min-version`, uncached.

## How to get to it (user POV)

- Opening the app (with an account) opens or refreshes a session.
- Settings → Account reads the account; Set Up Another Device and Back Up to WitnessWork use handoff and backup.
- Every launch and return to the foreground reads `/app/v1/config`.

## Driving it with verify-ww-api

Preconditions:

- `node scripts/verify/dev.mjs doctor` passes, including `account sessions enabled`.

- **Whole flow.** Run `pnpm test:e2e src/e2e/accounts.e2e.test.ts`. Vitest reports `/accounts/v1/sessions (dev bypass)`, `the legacy switch (local KV)`, and `GET /app/v1/config` as passed, and `.verify/artifacts/e2e-accounts-*.json` holds the exchanges.
- **Sessions on.** Run `curl -s -X POST $URL/accounts/v1/sessions/refresh -H 'content-type: application/json' -H "cf-connecting-ip: 198.18.4.1" -d '{"refreshToken":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}'`. The body is `{"ok":false,"error":"unauthorized","code":"unauthorized"}`; `disabled` means no `ACCOUNT_TOKEN_SECRET`.
- **App config.** Run `node scripts/verify/dev.mjs kv put app:min-version '{"minVersion":"9.8.7"}'`, then `curl -si $URL/app/v1/config`. You get 200, `cache-control: no-store`, and `{"ok":true,"minAppVersion":"9.8.7"}`. Restore it with `node scripts/verify/dev.mjs kv delete app:min-version`.

## Gotchas

- Session envelopes (op `session/open`) are Ed25519-signed; use the e2e helpers, not hand-built curl.
- Restore `auth:legacy` (`kv delete auth:legacy`) after flipping it, or every legacy accounts and handoff test after it gets 426.
- Real attestations need a device and the deployed dev worker. Locally only the dev bypass opens sessions, and it reports platform `dev`.
12 changes: 10 additions & 2 deletions .agents/skills/verify-ww-api/features/buddies-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Buddies lets two publishers share upcoming Plans through a blind relay. Each person owns an inbox signed with their Ed25519 key. A buddy writes cards and events into the other inbox through a per-pair writer key. Pairing runs invite → fetch → claim → confirm, and `inbox/sync` reads everything back. The wire contract is in `docs/buddies-protocol.md`.

Every op takes two forms. A **bearer request** (`Authorization: Bearer` from an account session; app versions with sessions) sends owner ops as plain JSON and is authorized by the account the inbox is linked to; writer ops still carry the writer envelope. A **legacy request** (no header; shipped builds) is the signed envelope, until KV `auth:legacy` is `off`.

## Sub-features

- `register` makes `inbox/register` idempotent per key and returns `conflict` for a different key, even after the inbox was wiped.
Expand All @@ -18,6 +20,8 @@ Buddies lets two publishers share upcoming Plans through a blind relay. Each per
- `kill-switch` makes every op return 503 `disabled` when KV `buddies:enabled` is `false`, except `inbox/delete`, `slot/remove`, and `slot/leave`.
- `photos` stores encrypted note photos: `blob/put` (raw sealed bytes, owner-signed envelope in `x-buddies-payload`/`x-buddies-signature`, `blobId` = SHA-256 of the bytes, at most 1 MiB) writes R2 object `v1/<inboxId>/<blobId>`; `blob/get` (unsigned `{payload}` with `inboxId`, `blobId`, `token`) streams it back with `Cache-Control: no-store`, and every miss is the same 404; `blob/delete` (owner) removes them. `inbox/sync` reports `capabilities.photos`. KV `buddies:photos` (`BUDDIES_PHOTOS=on` in dev) switches them, answering 503 `photos_disabled` when off.
- `live` upgrades `GET /buddies/v1/inbox/live` (signed headers `x-buddies-payload`/`x-buddies-signature`) to a WebSocket that sends `hello`, then `changed` with the inbox `seq` after each write, answers `ping` with `pong`, and closes with 4001 `gone` on `inbox/delete`. A plain GET gets 426 and the kill switch 503.
- `bearer` runs every op class as a bearer request: `inbox/register` with the token and a required `account` proof for the token's account (403 `forbidden` otherwise); owner ops as plain JSON (`inbox/live` and `blob/put` in `x-buddies-payload`, no signature header), 403 `forbidden` for another account's inbox and 409 `not_linked` for one registered before accounts; writer ops with the token plus the writer envelope; invite and blob reads with the token plus today's body. A bad token is 401 `unauthorized` with `WWW-Authenticate`, never a fallback. Bearer limits count against a hash of the account, not the IP.
- `legacy-off` answers every legacy request with 426 `upgrade_required` (no `Retry-After`) once KV `auth:legacy` is `off`, leaving ops included, while bearer requests keep working.

## How to get to it (user POV)

Expand All @@ -41,8 +45,9 @@ Preconditions:
- **Photo blobs.** Run `pnpm test:e2e src/e2e/buddies.e2e.test.ts -t "photo blobs"`. A 200 KiB blob round-trips byte for byte; a retry keeps the first token and only a later expiry moves it; a wrong token, an unknown blob, and an unknown inbox all get the same 404; mismatched bytes are 400, `bytes` or a body past 1 MiB 413 `too_large`, a stranger's signature 401, a resent envelope 409 `replay`; `blob/delete` and `inbox/delete` make reads 404. To see objects really leave R2, count rows in `<persist dir>/v3/r2/miniflare-R2BucketObject/*.sqlite` (`SELECT COUNT(*) FROM _mf_objects`) after a run. Caps (300 blobs, 150 MiB, 100 uploads a day), expiry by the alarm, and R2 failures are unit-tested in `src/buddies/blobs.test.ts`.
- **Photos switch.** Run `pnpm test:e2e src/e2e/buddies.e2e.test.ts -t "photos switch"`. With KV `buddies:photos` `off`, the three blob ops return 503 `photos_disabled` (with `Retry-After: 60`) and `inbox/sync` says `capabilities.photos: false`; the stored blob reads again once the key is deleted.
- **Kill switch.** Run `pnpm test:e2e src/e2e/buddies.e2e.test.ts -t "kill switch"`. `inbox/register` and `inbox/sync` return 503 `disabled`, `inbox/delete` still returns `{"ok":true}`, and the switch is restored afterwards.
- **Fuzz.** Run `pnpm fuzz:buddies --runs 300`. The last lines read `fuzz: canary intact, health after ok` and `fuzz: ok`.
- **Proof.** Keep `.verify/artifacts/e2e-buddies-*.json` (every envelope and response) and `.verify/artifacts/fuzz-*.json`, which must have `failures: []` and `canary.intact: true`.
- **Bearer requests.** Run `pnpm test:e2e src/e2e/buddiesSessions.e2e.test.ts`. Two sessions open through the dev bypass; the owner registers with a proof, adds a slot, the buddy writes an event with its own token, the owner syncs it back (twice: no replay without a nonce), a photo round-trips, an invite is fetched and claimed, and the live socket says `hello`, `changed`, and `pong`, then closes 4001 on `inbox/delete`. It also checks `not_linked` until a legacy-registered inbox links, `forbidden` for another account's token (sync, live, `blob/put`, a proof for another account), and 401 for a bad token. With the local launcher it flips `auth:legacy` off (every legacy form 426, bearer still 200) and spends one account's 120 unsigned requests from fresh addresses (429), while another account and a legacy caller from that address still pass.
- **Fuzz.** Run `pnpm fuzz:buddies --runs 300`. The last lines read `fuzz: canary intact, bearer canary intact, … health after ok` and `fuzz: ok`.
- **Proof.** Keep `.verify/artifacts/e2e-buddies-*.json` and `e2e-buddies-sessions-*.json` (every envelope and response; tokens by header name only) and `.verify/artifacts/fuzz-*.json`, which must have `failures: []`, `canary.intact: true`, and `bearerCanary.intact: true`.

## Gotchas

Expand All @@ -54,3 +59,6 @@ Preconditions:
- The kill-switch and photos-switch tests edit shared local KV. Don't run e2e and the fuzzer at the same time.
- A `blob/put` refused before its body is read (over 1 MiB) can trip the same `wrangler dev` proxy quirk as a cancelled upload; the e2e test and the fuzzer send a resendable POST afterwards to drain it.
- After the server closes a live socket, `wrangler dev` keeps the TCP connection about 10 s, so a standard WebSocket client fires `close` late even though the close frame arrived at once. `openLive` resolves `closed` on the frame.
- A bearer owner body is the plain fields; an envelope (`payload`/`signature`) with a token is 400, not a fallback. Bearer `inbox/live` and `blob/put` take only `x-buddies-payload`: a signature header (either naming) or only the short `x-buddies-p` is 400.
- Access tokens last 15 minutes. Long manual sessions open a new one (`openSession` in `src/test/e2e.ts`; the same account key gets the same account).
- The legacy-switch tests edit shared local KV (`auth:legacy`); restore it with `kv delete auth:legacy` after flipping it by hand.
Loading
Loading