Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .agents/skills/verify-ww-api/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ The seed decides each case's mutation and its parameters. Ids, keys, and nonces

There are 19 generators:

- **Bad signatures:** tampered signature, tampered payload, wrong key or wrong role, a signature reused on another op, malformed `s`.
- **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.
Expand Down
6 changes: 3 additions & 3 deletions .agents/skills/verify-ww-api/features/buddies-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ Buddies lets two publishers share upcoming Plans through a blind relay. Each per
- `leave` removes the slot, its card, and its events from both inboxes through `slot/remove` and `slot/leave`.
- `delete` wipes the inbox with `inbox/delete`. Sync then returns 404 `not_found`, and only the same key can register the inbox again.
- `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-p`/`x-buddies-s`, `blobId` = SHA-256 of the bytes, at most 1 MiB) writes R2 object `v1/<inboxId>/<blobId>`; `blob/get` (unsigned `{p}` 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-p`/`x-buddies-s`) 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.
- `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.

## How to get to it (user POV)

Expand All @@ -35,7 +35,7 @@ Preconditions:
- **Pair two people.** Run `pnpm test:e2e src/e2e/buddies.e2e.test.ts -t "pairing happy path"`. This registers Levi and Maria, creates, fetches, and claims an invite, confirms, and exchanges cards, a roster, leave, and delete. It passes with a read-back after every write.
- **Ownership after delete-all.** Run `pnpm test:e2e src/e2e/buddies.e2e.test.ts -t "delete-all"`. Other keys racing and following `inbox/delete` get 409 `conflict`, the buddy's `card/put` gets 404 `not_found`, the owner's key registers again (200, empty sync), and the buddy then gets 410 `gone` until the slot is restored. The 180-day wipe can't be reached over HTTP; unit tests in `src/buddies/relay.test.ts` cover it.
- **Unsigned probe.** Fetch an unknown invite. Run `curl -s -X POST $URL/buddies/v1/invite/fetch -H "cf-connecting-ip: 198.18.3.1" -d "{\"p\":\"$(printf '{"inviteId":"AAAAAAAAAAAAAAAAAAAAAA"}' | base64 | tr '+/' '-_' | tr -d '=')\"}"`. The body is `{"ok":false,"error":"not_found","code":"not_found"}` with status 404.
- **Malformed envelope.** Run `curl -s -X POST $URL/buddies/v1/inbox/sync -H "cf-connecting-ip: 198.18.3.2" -d '{"p":"!!"}'`. The body is `{"ok":false,"error":"bad_request","code":"bad_request"}` with status 400.
- **Malformed envelope.** Run `curl -s -X POST $URL/buddies/v1/inbox/sync -H "cf-connecting-ip: 198.18.3.2" -d '{"payload":"!!"}'`. The body is `{"ok":false,"error":"bad_request","code":"bad_request"}` with status 400.
- **Limits and abuse.** Run `pnpm test:e2e src/e2e/buddies.e2e.test.ts -t "relay limits"`. Past 120 `invite/fetch` a minute from one IPv6 /64 (rotating addresses), and past 60 `inbox/register` from one caller, requests get 429 `rate_limited` with `Retry-After: 60`; a flood of bad-signature syncs at someone's inbox gets 429 while its owner still syncs; the 4th open invite gets 429; unknown ops get 404. The stored-event caps, the 2,000-a-day registration quota, and the per-signer budgets need hours or days of writes, so unit tests cover them (`src/buddies/limits.test.ts`).
- **Live socket.** Run `pnpm test:e2e src/e2e/buddies.e2e.test.ts -t "live socket"`. It opens a socket with `openLive` (a raw handshake in `src/test/e2e.ts`, so refusals keep their status and body), reads `hello` and `changed` back against `inbox/sync`, checks `ping`/`pong`, the 4001 close, and the 426/401/409/400 refusals.
- **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`.
Expand Down
54 changes: 45 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,8 @@ whole host, `/b` included, for Android App Links. Clients are iOS and Android
`slot/leave` work even when disabled.
- **Photo blobs** (photos in Plan notes; contract in the protocol's "Photo
blobs"): `POST /buddies/v1/blob/put` takes the raw sealed bytes as its body
with the owner-signed envelope (op `blob/put`) in `x-buddies-p` /
`x-buddies-s`; the Worker reads at most 1 MiB, recomputes
with the owner-signed envelope (op `blob/put`) in `x-buddies-payload` /
`x-buddies-signature`; the Worker reads at most 1 MiB, recomputes
`blobId = b64u(SHA-256(body))`, and hands the bytes to the sender's inbox
DO, which checks the signature, nonce, and caps and writes R2 object
`v1/<inboxId>/<blobId>`. `blob/get` (unsigned; the read token is the
Expand Down Expand Up @@ -317,8 +317,8 @@ whole host, `/b` included, for Android App Links. Clients are iOS and Android
added in place to older inboxes). Invitations and answers (`isImmediatePushKind` in `contracts.ts`)
skip the 60 s per-slot spacing but still count toward the daily cap.
- **Live socket**: `GET /buddies/v1/inbox/live` upgrades to a WebSocket. The
owner-signed envelope (op `inbox/live`) rides in the `x-buddies-p` and
`x-buddies-s` headers. The inbox DO accepts it with the Hibernation API
owner-signed envelope (op `inbox/live`) rides in the `x-buddies-payload`
and `x-buddies-signature` headers. The inbox DO accepts it with the Hibernation API
(`ping` gets `pong` without waking it, at most 10 sockets per inbox) and
sends `{"type":"changed","seq":…}` after each write that changes
`inbox/sync`. Sentry events drop `x-buddies-*` headers. Under `wrangler
Expand Down Expand Up @@ -421,10 +421,10 @@ Shared helpers for the self-signed ops are in `src/accounts/signed.ts`.

- **Handoff** (Set Up Another Device): `POST /accounts/v1/handoff/open|claim|
status|send|receive|done|cancel`, code in `src/accounts/handoff.ts`. The old
phone's ops (`open`, `status`, `send`, and `cancel` with `p`/`s`) are signed
phone's ops (`open`, `status`, `send`, and `cancel` with `payload`/`signature`) are signed
by the account key like `account/register` and need a registered account
(`unknown_account` otherwise; a suspended one may still hand off). The new
phone's (`claim`, `receive`, `done`, and `cancel` without `p`) are plain JSON
phone's (`claim`, `receive`, `done`, and `cancel` without `payload`) are plain JSON
carrying `claimToken`. One `AccountHandoff` SQLite DO per `channelId`
(binding `ACCOUNT_HANDOFF`, migration `v7`, `src/accounts/handoffDO.ts`)
holds the opening account, `verifier = b64u(SHA-256(claimToken))`, the new
Expand All @@ -442,7 +442,8 @@ Shared helpers for the self-signed ops are in `src/accounts/signed.ts`.
tier.
- **Backup**: `POST /accounts/v1/backup/put|list|get|delete`, code in
`src/accounts/backup.ts`. All four are signed; `put` sends its envelope in
`x-ww-p`/`x-ww-s` (Sentry drops both) and the blob as the raw body. R2
`x-ww-payload`/`x-ww-signature` (Sentry drops both, and the legacy
`x-ww-p`/`x-ww-s`) and the blob as the raw body. R2
bucket `ACCOUNT_BACKUPS` (`ww-account-backups` prod,
`ww-account-backups-dev` dev), one object per device slot at
`v1/<accountId>/<slotId>` with `customMetadata.uploadedAt` (epoch ms). `put`
Expand Down Expand Up @@ -583,10 +584,45 @@ need the token).
route's `openapi.ts` in the same PR**.
- `src/openapi/openapi.test.ts` fails on any route without docs, and on any
operation without a tag, summary, or responses.
- Signed Buddies/accounts ops document the envelope `{p, s}` and list the
decoded `p` fields as a table (`signedBody`).
- Signed Buddies/accounts ops document the envelope `{payload, signature}`
and list the decoded `payload` fields as a table (`signedBody`).
- The legacy short names `p`/`s` (and `x-buddies-p`/`-s`, `x-ww-p`/`-s`) are
accepted for shipped builds but appear in the docs only as a note; never
add a new short name.
- The document is built once per isolate. Scalar's UI loads from jsDelivr.

## API design: readable, standard REST

This project is open source. Anyone can read this repo, the app's repo, the
generated `/docs`, and `/openapi.json`, so hiding the API's shape buys no
security. Short or cryptic names (`p`, `s`, single letters, unexplained
abbreviations) only make the API harder to read, debug, and maintain.
Security comes from signatures, attestation, capability secrets, rate limits,
and end-to-end encryption, never from obscure names.

When adding or changing an endpoint:

- **Name things for a human reader.** Spell fields, headers, and query
parameters out in full camelCase (`signature`, not `s`; `payload`, not
`p`; `publicKey`, not `pub`). Abbreviate only where every developer knows
the term (`id`, `url`, `nonce`), and give every number its unit in the
name or its description (`durationSeconds`, `expiresAt` in epoch ms).
Some shipped names predate this (`ts`, `*Pub`, `seq`); don't copy them
into new routes.
- **Follow REST and HTTP conventions.** Resource-oriented paths, the right
method and status code, `Content-Type` and `Retry-After` honoured, JSON
bodies, errors in the shared `{ ok, error, code }` envelope. Where a route
deliberately departs from REST (Buddies' and accounts' `POST /{op}` keeps
ids out of URLs, which production logs), document why in its `openapi.ts`.
- **Document as you go.** Every field gets a description in the route's
`openapi.ts`; `/docs` should be enough to call the API without reading the
source.
- **Renaming a shipped field:** accept both names on the server, document
only the new one (mention the old one as legacy), switch the app, and keep
the alias as long as supported builds send it. Inside signed payloads the
server verifies the exact bytes it got, so an alias there only needs the
parser to read either name.

## Checks before deploy

```bash
Expand Down
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,38 @@

A lightweight Cloudflare Workers api and proxy service for HERE API endpoints, iOS universal links, etc. with Sentry monitoring.

## Open source, open API

WW API is open source, like the [WitnessWork app](https://github.com/leviFrosty/witness-work)
it serves. Anyone can read the code, the protocol docs in [`docs/`](docs/),
and the generated API reference at [`/docs`](https://ww-proxy.leviwilkerson.com/docs).
Since the whole contract is public, there's nothing to gain by obscuring it:
short or cryptic field names, headers, or paths would only make the API harder
to read, debug, and build on.

So the API aims to be standard REST, carefully maintained, and easy for people
to read:

- **Plain names.** Fields, headers, and query parameters are spelled out in
full (`payload` and `signature`, not `p` and `s`). Units go in the name or
the description (`durationSeconds`, `expiresAt` in epoch ms).
- **Standard HTTP.** The right methods and status codes, JSON bodies, one
error envelope (`{ "ok": false, "error", "code" }`), and `Retry-After` on
every 429 and 503. Where a route departs from REST on purpose, its docs say
why. For example, the Buddies and accounts relays use `POST /{op}` so ids
stay out of URLs, which production logs.
- **Documented.** Every route and field is described in `/docs`, generated
from the routes, so you can call the API without reading the source.

Security never depends on the API being hard to read. It comes from Ed25519
signatures, App Attest / Play Integrity, capability secrets, rate limits, and
end-to-end encryption (the Buddies relay only ever sees ciphertext).

Some names shipped before this rule. Those still work as legacy aliases (for
example, signed envelopes still accept `p`/`s` and the `x-buddies-p`/`-s` and
`x-ww-p`/`-s` headers from older app builds), but new code and docs use only
the full names.

## Prerequisites

- [pnpm](https://pnpm.io/) installed
Expand Down Expand Up @@ -115,6 +147,22 @@ GitHub Actions will automatically deploy to Cloudflare Workers. Monitor deployme

Full API reference: [`/docs`](https://ww-proxy.leviwilkerson.com/docs) (Scalar), generated from the routes; the raw OpenAPI 3.1 document is at [`/openapi.json`](https://ww-proxy.leviwilkerson.com/openapi.json). Locally: `http://localhost:8787/docs`.

### Signed requests (Buddies and accounts)

Signed ops send a JSON envelope:

```json
{ "payload": "<base64url of the UTF-8 JSON request fields>", "signature": "<base64url Ed25519 signature>" }
```

`signature` covers the op's context line (`ww-buddies/v1\n<op>\n` or
`ww-account/v1\n<op>\n`) followed by the exact decoded `payload` bytes. Routes
whose body is raw bytes, or that have no body (`blob/put`, `backup/put`, the
live WebSocket), send the same two values in `x-buddies-payload` /
`x-buddies-signature` or `x-ww-payload` / `x-ww-signature`. Each op in `/docs`
lists its decoded `payload` fields. The wire contract is
[`docs/buddies-protocol.md`](docs/buddies-protocol.md).

### `/autocomplete`

Proxies to HERE Autocomplete API.
Expand Down
Loading
Loading