diff --git a/AGENTS.md b/AGENTS.md index 8c1ee5c..3249b16 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -533,6 +533,29 @@ route". Code lives in `src/routePlanning/`; README documents the contract. The first deploy applies the `v5` migration (prod and `--env dev`). +## API docs (OpenAPI + Scalar) + +`GET /docs` is the Scalar API reference and `GET /openapi.json` its OpenAPI +3.1 document, on both workers (prod: https://ww-proxy.leviwilkerson.com/docs). +The repo is public, so the docs are too, admin routes included (they still +need the token). + +- `hono-openapi` builds the document from the routes themselves: each route + carries a `describeRoute(...)` middleware, kept in an `openapi.ts` beside its + handlers (`src/notesImport/`, `src/buddies/`, `src/accounts/`, + `src/routePlanning/`; routes registered in `src/app.ts` use + `src/openapi/coreDocs.ts`). Shared helpers and the error envelope are in + `src/openapi/schemas.ts`; tags, servers, and the Scalar config in + `src/openapi/route.ts`. +- The schemas describe the wire format for readers; they don't validate. + Handlers keep their own parsing, so **a contract change means updating the + 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`). +- The document is built once per isolate. Scalar's UI loads from jsDelivr. + ## Checks before deploy ```bash diff --git a/README.md b/README.md index b0a7127..4563b22 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,8 @@ GitHub Actions will automatically deploy to Cloudflare Workers. Monitor deployme ## API Endpoints +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`. + ### `/autocomplete` Proxies to HERE Autocomplete API. diff --git a/package.json b/package.json index 57e505a..c642850 100644 --- a/package.json +++ b/package.json @@ -24,17 +24,19 @@ }, "packageManager": "pnpm@10.33.0", "dependencies": { + "@hot-updater/cloudflare": "1.0.2", + "@hot-updater/server": "1.0.2", "@openrouter/ai-sdk-provider": "^2.9.1", "@peculiar/x509": "^2.0.0", + "@scalar/hono-api-reference": "^0.12.12", "@sentry/cloudflare": "^8.55.1", "ai": "^6.0.208", "cbor2": "^2.3.0", "drizzle-orm": "0.45.4", "hono": "^4.12.12", + "hono-openapi": "^1.3.5", "reflect-metadata": "^0.2.2", - "zod": "^4.4.3", - "@hot-updater/cloudflare": "1.0.2", - "@hot-updater/server": "1.0.2" + "zod": "^4.4.3" }, "devDependencies": { "@cloudflare/workers-types": "^4.20260411.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83c297e..047a0bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@peculiar/x509': specifier: ^2.0.0 version: 2.0.0 + '@scalar/hono-api-reference': + specifier: ^0.12.12 + version: 0.12.12(hono@4.12.12) '@sentry/cloudflare': specifier: ^8.55.1 version: 8.55.1 @@ -35,6 +38,9 @@ importers: hono: specifier: ^4.12.12 version: 4.12.12 + hono-openapi: + specifier: ^1.3.5 + version: 1.3.5(hono@4.12.12)(zod@4.4.3) reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -715,6 +721,12 @@ packages: cpu: [x64] os: [win32] + '@hono/standard-validator@0.4.0': + resolution: {integrity: sha512-MxOm1asDh9j7c1D0KiWwppSbFgAQxTRO4YEhjMrJn3lF1BIcXenPdgjSGKDRfQmZdaTaMA8slQLETgVOKyJxFw==} + peerDependencies: + '@standard-schema/spec': ^1.0.0 + hono: '>=4.11.2' + '@hot-updater/cli-tools@1.0.2': resolution: {integrity: sha512-32odueqPPlfvlG/BMBzM1mhu7sfZJBqY5l5842j2nbVOj+Nq6T/jSTOSB7FRsYzibUMa9em8NDYqZyC8IK5dDA==} engines: {node: '>=20.19.0'} @@ -929,12 +941,6 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@napi-rs/wasm-runtime@1.1.5': - resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - '@napi-rs/wasm-runtime@1.2.5': resolution: {integrity: sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -1360,6 +1366,32 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@scalar/client-side-rendering@0.4.11': + resolution: {integrity: sha512-2k75dLAR6Ft3Yf73sdKEjOUQzZ7nmpWgNswjnGCbPO4P0iDOKBXJXmnuNVmaOpFOZl+u4HM4lay0QuyihheTmw==} + engines: {node: '>=22'} + + '@scalar/helpers@0.16.3': + resolution: {integrity: sha512-He0CySU4Iv4DwyVF40EoVpvPneRQ1zqC/g1eBfR2i2G9RE/BEq/F5AiUrjIkbtTPjSbIiIL1AXMkXvBvgHVdsA==} + engines: {node: '>=22'} + + '@scalar/hono-api-reference@0.12.12': + resolution: {integrity: sha512-JlOt8mo/tskQDnvriULUxnsYYKDQBp+t571bXFiaPw7oijfCL5bykwXpimket5Ukeg5PAkJm9O5i4JBAGcH17A==} + engines: {node: '>=22'} + peerDependencies: + hono: ^4.12.5 + + '@scalar/schemas@0.13.2': + resolution: {integrity: sha512-T4dTbpphpDVHugS2ETd6cHOgF0FUBy58qDB6I3UviIr2HNsJQRG2RB1MX1+XQCT9md7igAzXw9lBJV12hw3NVA==} + engines: {node: '>=22'} + + '@scalar/types@0.23.2': + resolution: {integrity: sha512-sya+RIpIODwcvem7SYRnPB0OdVZtQ15mSpVdPwB5qltYry7xgyp9skdc3PuxTDbyEmLuZ4ZmkSVybpaFMICZiQ==} + engines: {node: '>=22'} + + '@scalar/validation@0.6.7': + resolution: {integrity: sha512-xvmeYb0LOg9B598oVA/1fs9MvnMdtWjQYG3v2Wgftewizr7tQjsdDYT1TQahlDzqRG+r+ml6zlSkPrFiCE5jHQ==} + engines: {node: '>=20'} + '@sentry/cli-darwin@3.6.0': resolution: {integrity: sha512-C2SWHKaEP8NoYkLDr5jwrzklTwlJkzPIx7lu2LZrwBuHG/sNhWdili0ED2mD86b6Q90hlcMtuBm5IHUwcZ6FTQ==} engines: {node: '>=18'} @@ -1475,12 +1507,69 @@ packages: '@speed-highlight/core@1.2.15': resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==} + '@standard-community/standard-json@0.3.6': + resolution: {integrity: sha512-tuXKz1Ps4al0W4xhfwii7v4lskKtOv3QE7mcouRVThrBdOR6JzY4Vea3nsxcEnte84bOJvMvXGmOMWE3IBbHyA==} + peerDependencies: + '@standard-schema/spec': ^1.0.0 + '@types/json-schema': ^7.0.15 + '@valibot/to-json-schema': ^1.3.0 + arktype: ^2.1.20 + effect: ^3.20.0 + sury: ^10.0.0 + typebox: ^1.0.17 + valibot: ^1.4.2 + zod: ^3.25.0 || ^4.0.0 + zod-to-json-schema: ^3.24.5 + peerDependenciesMeta: + '@valibot/to-json-schema': + optional: true + arktype: + optional: true + effect: + optional: true + sury: + optional: true + typebox: + optional: true + valibot: + optional: true + zod: + optional: true + zod-to-json-schema: + optional: true + + '@standard-community/standard-openapi@0.2.10': + resolution: {integrity: sha512-whaCN9eu5zv5gu6Kyeh5am/S3AyS1nqo57/wAxs/KPS7+94UhBUZmkIetmXzm22qIV0zMrHt3y+2VfjkbkYYTA==} + peerDependencies: + '@standard-community/standard-json': ^0.3.5 + '@standard-schema/spec': ^1.0.0 + arktype: ^2.1.20 + effect: ^3.20.0 + openapi-types: ^12.1.3 + sury: ^10.0.0 + typebox: ^1.0.0 + valibot: ^1.4.2 + zod: ^3.25.0 || ^4.0.0 + zod-openapi: ^4 + peerDependenciesMeta: + arktype: + optional: true + effect: + optional: true + sury: + optional: true + typebox: + optional: true + valibot: + optional: true + zod: + optional: true + zod-openapi: + optional: true + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@tybys/wasm-util@0.10.2': - resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} - '@tybys/wasm-util@0.10.4': resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} @@ -1493,6 +1582,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node-fetch@2.6.13': resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} @@ -1946,6 +2038,11 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + hono-openapi@1.3.5: + resolution: {integrity: sha512-t1YpXno9oXa6400xi9rX6i1jKB0496VvikcVKpN/UY3s7yDDQqeCgnKlDr3rfkqxGvykq3+bgX/64eCm7UnUMA==} + peerDependencies: + hono: ^4.11.2 + hono@4.12.12: resolution: {integrity: sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==} engines: {node: '>=16.9.0'} @@ -2084,6 +2181,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.16: + resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==} + engines: {node: ^18 || >=20} + hasBin: true + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -2102,6 +2204,9 @@ packages: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + oxc-parser@0.141.0: resolution: {integrity: sha512-uFkGGr1KMWd6aWv9UAqooYrN78trw8MWWmoPvgWokfBEUq1+eiIQ+qfj3wokhy0fxtZWZk+0dHoS7/yRTJtd6w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2141,6 +2246,9 @@ packages: resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} engines: {node: '>=16.0.0'} + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} @@ -2204,6 +2312,10 @@ packages: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -2237,6 +2349,10 @@ packages: resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} engines: {node: '>= 6.0.0'} + type-fest@5.10.0: + resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==} + engines: {node: '>=20'} + typescript@7.0.1-rc: resolution: {integrity: sha512-drEP77wK7CCDlPfXZH4e008UUQOsw1DFmHmZOZjuNA+yoDLLnSNMZRXi90NbV/1LVo7SbNLq1bs3jjvk49TEqQ==} engines: {node: '>=16.20.0'} @@ -2967,6 +3083,11 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@hono/standard-validator@0.4.0(@standard-schema/spec@1.1.0)(hono@4.12.12)': + dependencies: + '@standard-schema/spec': 1.1.0 + hono: 4.12.12 + '@hot-updater/cli-tools@1.0.2(drizzle-orm@0.45.4(@cloudflare/workers-types@4.20260411.1)(@opentelemetry/api@1.9.1))': dependencies: '@hot-updater/plugin-core': 1.0.2 @@ -3143,11 +3264,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.2 + '@tybys/wasm-util': 0.10.4 optional: true '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': @@ -3445,7 +3566,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true '@rolldown/binding-win32-arm64-msvc@1.0.3': @@ -3456,6 +3577,34 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@scalar/client-side-rendering@0.4.11': + dependencies: + '@scalar/helpers': 0.16.3 + '@scalar/schemas': 0.13.2 + '@scalar/types': 0.23.2 + '@scalar/validation': 0.6.7 + + '@scalar/helpers@0.16.3': {} + + '@scalar/hono-api-reference@0.12.12(hono@4.12.12)': + dependencies: + '@scalar/client-side-rendering': 0.4.11 + hono: 4.12.12 + + '@scalar/schemas@0.13.2': + dependencies: + '@scalar/helpers': 0.16.3 + '@scalar/validation': 0.6.7 + + '@scalar/types@0.23.2': + dependencies: + '@scalar/helpers': 0.16.3 + nanoid: 5.1.16 + type-fest: 5.10.0 + zod: 4.4.3 + + '@scalar/validation@0.6.7': {} + '@sentry/cli-darwin@3.6.0': optional: true @@ -3571,12 +3720,23 @@ snapshots: '@speed-highlight/core@1.2.15': {} - '@standard-schema/spec@1.1.0': {} + '@standard-community/standard-json@0.3.6(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(zod@4.4.3)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/json-schema': 7.0.15 + quansync: 0.2.11 + optionalDependencies: + zod: 4.4.3 - '@tybys/wasm-util@0.10.2': + '@standard-community/standard-openapi@0.2.10(@standard-community/standard-json@0.3.6(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3)': dependencies: - tslib: 2.8.1 - optional: true + '@standard-community/standard-json': 0.3.6(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(zod@4.4.3) + '@standard-schema/spec': 1.1.0 + openapi-types: 12.1.3 + optionalDependencies: + zod: 4.4.3 + + '@standard-schema/spec@1.1.0': {} '@tybys/wasm-util@0.10.4': dependencies: @@ -3592,6 +3752,8 @@ snapshots: '@types/estree@1.0.9': {} + '@types/json-schema@7.0.15': {} + '@types/node-fetch@2.6.13': dependencies: '@types/node': 18.19.130 @@ -3972,6 +4134,26 @@ snapshots: dependencies: function-bind: 1.1.2 + hono-openapi@1.3.5(hono@4.12.12)(zod@4.4.3): + dependencies: + '@hono/standard-validator': 0.4.0(@standard-schema/spec@1.1.0)(hono@4.12.12) + '@standard-community/standard-json': 0.3.6(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(zod@4.4.3) + '@standard-community/standard-openapi': 0.2.10(@standard-community/standard-json@0.3.6(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3) + '@standard-schema/spec': 1.1.0 + '@types/json-schema': 7.0.15 + hono: 4.12.12 + openapi-types: 12.1.3 + transitivePeerDependencies: + - '@valibot/to-json-schema' + - arktype + - effect + - sury + - typebox + - valibot + - zod + - zod-openapi + - zod-to-json-schema + hono@4.12.12: {} hono@4.12.34: {} @@ -4071,6 +4253,8 @@ snapshots: nanoid@3.3.14: {} + nanoid@5.1.16: {} + node-domexception@1.0.0: {} node-fetch@2.7.0: @@ -4079,6 +4263,8 @@ snapshots: obug@2.1.3: {} + openapi-types@12.1.3: {} + oxc-parser@0.141.0: dependencies: '@oxc-project/types': 0.141.0 @@ -4151,6 +4337,8 @@ snapshots: pvutils@1.1.5: {} + quansync@0.2.11: {} + quansync@1.0.0: {} readable-stream@3.6.2: @@ -4247,6 +4435,8 @@ snapshots: supports-color@10.2.2: {} + tagged-tag@1.0.0: {} + tinybench@2.9.0: {} tinyexec@1.2.4: {} @@ -4274,6 +4464,10 @@ snapshots: dependencies: tslib: 1.14.1 + type-fest@5.10.0: + dependencies: + tagged-tag: 1.0.0 + typescript@7.0.1-rc: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.1-rc diff --git a/src/accounts/admin.ts b/src/accounts/admin.ts index 2bf58db..e92d7e7 100644 --- a/src/accounts/admin.ts +++ b/src/accounts/admin.ts @@ -19,6 +19,7 @@ import { setAccountStatus, type AccountRecord, } from './store' +import { accountLookupDocs, accountStatusDocs } from './openapi' /** * Maintainer-only account moderation, authorized by `x-ww-admin-token` @@ -128,7 +129,7 @@ const handleStatus = async (c: AppContext): Promise => { /** Mounted at `/admin/accounts` (behind the `admin` rate limit). */ export const createAccountsAdminRoutes = () => { const routes = new Hono<{ Bindings: Environment }>() - routes.post('/lookup', handleLookup) - routes.post('/status', handleStatus) + routes.post('/lookup', accountLookupDocs, handleLookup) + routes.post('/status', accountStatusDocs, handleStatus) return routes } diff --git a/src/accounts/openapi.ts b/src/accounts/openapi.ts new file mode 100644 index 0000000..9e99ac2 --- /dev/null +++ b/src/accounts/openapi.ts @@ -0,0 +1,116 @@ +import { z } from 'zod' +import { describeRoute } from 'hono-openapi' +import { + staleErrorSchema, + b64u, + buddiesId, + errorResponse, + jsonBody, + jsonResponse, + signedBody, +} from '../openapi/schemas' +import { ACCOUNT_STATUS_REASONS, ACCOUNT_STATUSES } from './schema' + +const accountId = buddiesId('The account id') +const status = z.enum(ACCOUNT_STATUSES) + +export const accountRegisterDocs = describeRoute({ + tags: ['Accounts'], + summary: 'Register an account', + description: `Returns the account for a signing key, creating it on first use. Self-signed by the account key under the \`ww-account/v1\` context, and idempotent: the same key always gets the same \`accountId\`. + +Limits: the Buddies register tier (60 a minute per caller IP) and 2,000 registrations per caller per rolling day, shared in kind with \`inbox/register\`. Answers \`disabled\` on a worker without the accounts database.`, + requestBody: signedBody( + z.object({ + accountPub: b64u( + 'Ed25519 public key of the account signing key, 32 bytes' + ), + ts: z.number().int().meta({ + description: + 'Client time in epoch milliseconds; must be within 5 minutes of the server’s.', + }), + nonce: buddiesId('A fresh random nonce'), + }), + 'A signed envelope; `s` is the account key’s signature over `ww-account/v1\\naccount/register\\n` followed by `p`’s bytes.' + ), + responses: { + 200: jsonResponse( + 'The account for this key.', + z.object({ ok: z.literal(true), accountId, status }) + ), + 400: errorResponse('Malformed envelope or payload.', ['bad_request']), + 401: errorResponse( + 'Bad signature, or `ts` too far from the server clock. `stale` carries `serverTime` (epoch ms) and a `Date` header.', + ['bad_signature', 'stale'], + staleErrorSchema + ), + 429: errorResponse('Over the per-caller rate or daily quota.', [ + 'rate_limited', + ]), + 500: errorResponse('Unexpected failure.', ['internal']), + 503: errorResponse('Accounts are off on this worker.', ['disabled']), + }, +}) + +const adminErrors = { + 400: errorResponse('Invalid JSON or field.', ['bad_request']), + 404: errorResponse( + 'Missing or wrong admin token, no accounts database, or no such account.', + ['not_found', 'unknown_account'] + ), + 429: errorResponse('Too many admin requests from this IP.', ['rate_limited']), +} + +const accountSummary = z.object({ + id: accountId, + status, + inboxIds: z.array(buddiesId('An inbox linked to the account')), +}) + +export const accountLookupDocs = describeRoute({ + tags: ['Admin'], + summary: 'Look up an account', + description: + 'Finds an account, its status, and its inboxes. Abuse reports name the buddy’s `inboxId`; this finds the account behind it. Send `inboxId` or `accountId`.', + security: [{ adminToken: [] }], + requestBody: jsonBody( + z.union([ + z.object({ inboxId: buddiesId('The reported inbox') }), + z.object({ accountId }), + ]) + ), + responses: { + 200: jsonResponse( + 'The account.', + z.object({ ok: z.literal(true), account: accountSummary }) + ), + ...adminErrors, + }, +}) + +export const accountStatusDocs = describeRoute({ + tags: ['Admin'], + summary: 'Suspend or reinstate an account', + description: + 'Sets the account’s status, records the change in `account_status_events`, and tells each of its inboxes. A suspended account’s inboxes refuse owner ops with 403 `suspended` and close their live sockets. If `inboxesFailed` is above 0, send the same request again.', + security: [{ adminToken: [] }], + requestBody: jsonBody( + z.object({ + accountId, + status: status.meta({ description: '`active` reinstates.' }), + reason: z.enum(ACCOUNT_STATUS_REASONS), + }) + ), + responses: { + 200: jsonResponse( + 'Status recorded. `ok` is false when some inboxes weren’t updated.', + z.object({ + ok: z.boolean(), + account: accountSummary, + inboxesUpdated: z.number().int(), + inboxesFailed: z.number().int(), + }) + ), + ...adminErrors, + }, +}) diff --git a/src/accounts/route.ts b/src/accounts/route.ts index 8e1c28b..91f88ba 100644 --- a/src/accounts/route.ts +++ b/src/accounts/route.ts @@ -20,6 +20,7 @@ import { } from '../buddies/limits' import { accountSignedMessage, parseAccountRegister } from './contracts' import { accountsDb, registerAccount } from './store' +import { accountRegisterDocs } from './openapi' const randomAccountId = (): string => bytesToBase64Url(crypto.getRandomValues(new Uint8Array(16))) @@ -108,6 +109,6 @@ const handleRegister = async (c: AppContext): Promise => { /** Mounted at `/accounts/v1` by the Worker entry point. */ export const createAccountsRoutes = () => { const routes = new Hono<{ Bindings: Environment }>() - routes.post('/account/register', handleRegister) + routes.post('/account/register', accountRegisterDocs, handleRegister) return routes } diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..604dcf2 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,202 @@ +/** + * The Hono app: every HTTP route the Worker serves. `src/index.ts` wraps it + * with Sentry and the cron handler, and re-exports the Durable Objects. + */ +import { Hono } from "hono"; +import type { Environment, AppContext, HealthCheckResponse } from "./types"; +import { HERE_API, HTTP_STATUS } from "./config"; +import { apiError, dateHeader } from "./errors"; +import { rateLimit } from "./rateLimit"; +import { proxyRequestToHereApi } from "./proxy"; +import { + handleAasaRequest, + handleContactLinkRequest, + handleLegacyContactLinkRequest, +} from "./contactLink"; +import { + handleChallengeRequest, + handleAttestRequest, + handleNotesImportVerifyRequest, + handleNotesImportRequest, + handleNotesImportStatusRequest, + handleNotesImportKickoffRequest, + handleNotesImportTranscribeRequest, + handleNotesImportEventsRequest, + handleNotesImportResultRequest, + handleNotesImportCancelRequest, + handleNotesImportDestroyRequest, + handleNotesImportAdminResetRequest, +} from "./notesImport/route"; +import { + attestDocs as notesImportAttestDocs, + cancelDocs as notesImportCancelDocs, + challengeDocs as notesImportChallengeDocs, + destroyDocs as notesImportDestroyDocs, + eventsDocs as notesImportEventsDocs, + kickoffDocs as notesImportKickoffDocs, + legacyImportDocs as notesImportLegacyDocs, + resultDocs as notesImportResultDocs, + statusDocs as notesImportStatusDocs, + transcribeDocs as notesImportTranscribeDocs, + verifyDocs as notesImportVerifyDocs, +} from "./notesImport/openapi"; +import { handleAppStoreRatingsRequest } from "./appStoreRatings/route"; +import { Sentry } from "./sentry"; +import { createBuddiesRoutes } from "./buddies/route"; +import { createAccountsRoutes } from "./accounts/route"; +import { createAccountsAdminRoutes } from "./accounts/admin"; +import { handleBuddyInvitePage } from "./buddies/invitePage"; +import { createRoutePlanningRoutes } from "./routePlanning/route"; +import { mountApiDocs } from "./openapi/route"; +import { + aasaDocs, + appStoreRatingsDocs, + autocompleteDocs, + buddyInvitePageDocs, + contactLinkDocs, + geocodeDocs, + healthDocs, + legacyContactLinkDocs, + notesImportAdminResetDocs, +} from "./openapi/coreDocs"; + +export const app = new Hono<{ Bindings: Environment }>(); + +async function handleGeocodeRequest(context: AppContext) { + return proxyRequestToHereApi(context, HERE_API.GEOCODE_URL); +} + +async function handleAutocompleteRequest(context: AppContext) { + return proxyRequestToHereApi(context, HERE_API.AUTOCOMPLETE_URL); +} + +/** + * Liveness plus the server clock. Clients calibrate against `Date` (and + * `serverTime`, epoch ms) before signing time-bound requests (Buddies `ts`), + * so it is never cached anywhere: a cached copy would carry an old clock. No + * I/O, no rate limit. + */ +function handleHealthCheckRequest(context: AppContext) { + const now = Date.now(); + const response: HealthCheckResponse = { + status: "ok", + timestamp: new Date(now).toISOString(), + serverTime: now, + versionId: context.env.CF_VERSION_METADATA.id, + versionTag: context.env.CF_VERSION_METADATA.tag || undefined, + deployedAt: context.env.CF_VERSION_METADATA.timestamp, + }; + return context.json(response, 200, { + "Cache-Control": "no-store", + ...dateHeader(now), + }); +} + +function handleNotFound() { + return apiError(HTTP_STATUS.NOT_FOUND, "not_found"); +} + +function handleApplicationError(error: Error) { + Sentry.captureException(error); + console.error("Application error:", error); + return apiError(HTTP_STATUS.INTERNAL_SERVER_ERROR, "server_error"); +} + +// Each family counts in its own per-IP bucket (src/rateLimit.ts). +app.use("/geocode", rateLimit("places")); +app.use("/autocomplete", rateLimit("places")); +app.use("/notes-import", rateLimit("notes-import")); +app.use("/notes-import/*", rateLimit("notes-import")); +app.use("/admin/*", rateLimit("admin")); +app.use("/route-planning/*", rateLimit("route-planning")); + +app.get("/geocode", geocodeDocs, handleGeocodeRequest); +app.get("/autocomplete", autocompleteDocs, handleAutocompleteRequest); +app.get("/health", healthDocs, handleHealthCheckRequest); +// Paywall social proof (App Store ratings), edge-cached; refreshed by cron. +app.get( + "/app-store/ratings", + appStoreRatingsDocs, + handleAppStoreRatingsRequest +); +// Notes Import: availability probe, App Attest handshake + the metered call. +app.get( + "/notes-import/status", + notesImportStatusDocs, + handleNotesImportStatusRequest +); +app.post( + "/notes-import/challenge", + notesImportChallengeDocs, + handleChallengeRequest +); +app.post("/notes-import/attest", notesImportAttestDocs, handleAttestRequest); +// Attested no-op: dev-tools diagnostics prove the assertion path server-side. +app.post( + "/notes-import/verify", + notesImportVerifyDocs, + handleNotesImportVerifyRequest +); +// Streaming import: attested kickoff → SSE progress stream → result snapshot. +app.post( + "/notes-import/kickoff", + notesImportKickoffDocs, + handleNotesImportKickoffRequest +); +app.post( + "/notes-import/transcribe", + notesImportTranscribeDocs, + handleNotesImportTranscribeRequest +); +app.get( + "/notes-import/:importId/events", + notesImportEventsDocs, + handleNotesImportEventsRequest +); +app.get( + "/notes-import/:importId/result", + notesImportResultDocs, + handleNotesImportResultRequest +); +app.post( + "/notes-import/:importId/cancel", + notesImportCancelDocs, + handleNotesImportCancelRequest +); +app.post( + "/notes-import/:importId/destroy", + notesImportDestroyDocs, + handleNotesImportDestroyRequest +); +// Legacy synchronous path (fallback during cutover). +app.post("/notes-import", notesImportLegacyDocs, handleNotesImportRequest); +// Maintainer-only usage reset (secret auth inside handler; 404 fail-closed). +app.post( + "/admin/notes-import/reset", + notesImportAdminResetDocs, + handleNotesImportAdminResetRequest +); +// Account moderation: look up and suspend accounts (token auth; 404 fail-closed). +app.route("/admin/accounts", createAccountsAdminRoutes()); +// Universal-link support for WitnessWork contact sharing. AASA must be served +// at this exact path with Content-Type application/json and no redirects. +app.get("/.well-known/apple-app-site-association", aasaDocs, handleAasaRequest); +// Shared contacts ride in the fragment (`/c#`), which never reaches +// the worker. `/c/:payload` keeps links from older app versions working. +app.get("/c", contactLinkDocs, handleContactLinkRequest); +app.get("/c/:payload", legacyContactLinkDocs, handleLegacyContactLinkRequest); +// Buddies relay: POST /buddies/v1/{op}, ids only in bodies. Every op is +// rate-limited per caller inside the handler (src/buddies/limits.ts). +app.route("/buddies/v1", createBuddiesRoutes()); +// Anonymous accounts (witness-work ADR 0024): POST /accounts/v1/{op}, signed +// like Buddies ops and rate-limited per caller inside the handler. +app.route("/accounts/v1", createAccountsRoutes()); +// Buddy invite no-app fallback; the invite secret stays in the URL fragment. +app.get("/b", buddyInvitePageDocs, handleBuddyInvitePage); +// Supporter-only route ordering (HERE Waypoints Sequence); coordinates only, +// with per-account limits in the RoutePlanningQuota DO. +app.route("/route-planning", createRoutePlanningRoutes()); +// The OpenAPI document and Scalar reference for every route above. +mountApiDocs(app); +app.notFound(handleNotFound); +app.onError(handleApplicationError); diff --git a/src/buddies/openapi.ts b/src/buddies/openapi.ts new file mode 100644 index 0000000..2388766 --- /dev/null +++ b/src/buddies/openapi.ts @@ -0,0 +1,965 @@ +import { z } from 'zod' +import { describeRoute, type DescribeRouteOptions } from 'hono-openapi' +import type { MiddlewareHandler } from 'hono' +import { + apiErrorSchema, + staleErrorSchema, + b64u, + buddiesId, + errorResponse, + fieldTable, + jsonBody, + jsonResponse, + signedBody, +} from '../openapi/schemas' +import { + BUDDIES_ERROR_STATUS, + BUDDIES_LIMITS, + BUDDIES_LIVE_CLOSE, + type BuddiesErrorCode, + type BuddiesOp, +} from './contracts' +import { BUDDIES_ABUSE_LIMITS, type BuddiesEdgeTier } from './limits' + +/** + * OpenAPI descriptions for the Buddies relay (`/buddies/v1`). The wire + * contract is docs/buddies-protocol.md; the payload schemas below are for + * readers only and mirror `BUDDIES_PAYLOAD_PARSERS` in contracts.ts, which + * stays the authority. Update both together. + */ + +type Responses = NonNullable + +const TAGS = ['Buddies'] +const KIB = 1_024 +const kib = (bytes: number) => `${bytes / KIB} KiB` +const count = (n: number) => n.toLocaleString('en-US') + +// --- Payload fields ------------------------------------------------------- + +const ts = z.number().meta({ + description: + 'Signing time, epoch ms (a fraction is floored). More than 5 minutes from the relay clock is `stale`.', +}) +const nonce = buddiesId( + 'Random 16 bytes; reusing one for the same inbox within 10 minutes is `replay`' +) +const inboxId = buddiesId('The target inbox') +const slotId = buddiesId('The writer slot in that inbox') + +/** 32 bytes as base64url: keys, hashes, blob ids, and read tokens. */ +const bytes32 = (description: string) => + z + .string() + .regex(/^[A-Za-z0-9_-]{43}$/) + .meta({ description: `${description} (43-character base64url, 32 bytes)` }) + +const sealed = (what: string, maxBytes: number) => + b64u(`${what}, sealed by the app; 1 byte to ${kib(maxBytes)} decoded`) + +/** A signed payload: the op's fields plus `ts` and `nonce`. */ +const signedPayload = (fields: z.ZodRawShape) => + z.object({ ...fields, ts, nonce }) + +const okSchema = z.object({ ok: z.literal(true) }) +const seqSchema = z.object({ + ok: z.literal(true), + seq: z.number().int().meta({ + description: 'The inbox `seq` this write took (per-inbox, monotonic).', + }), +}) + +// --- Descriptions --------------------------------------------------------- + +const TIER_NAMES: Record = { + unsigned: 'unsigned invite', + register: 'registration', + read: 'read', + write: 'write', + blobPut: 'photo upload', + blobGet: 'photo download', +} + +const perMinute = (tier: BuddiesEdgeTier) => + `${count(BUDDIES_ABUSE_LIMITS.edge[tier].perMinute)} a minute` + +/** The per-caller tier sentence every op's description carries. */ +const tierLine = (tier: BuddiesEdgeTier) => + `**Rate limit:** the ${TIER_NAMES[tier]} tier, ${perMinute(tier)} per caller (client IP, IPv6 by /64), checked before any Durable Object wakes.` + +const OWNER_BUDGET = `the owner key's ${count(BUDDIES_ABUSE_LIMITS.ownerRequests)} requests that take effect per 10 minutes` +const WRITER_BUDGET = `the slot's ${count(BUDDIES_ABUSE_LIMITS.writerRequests)} requests that take effect per 10 minutes` + +const KILL_SWITCH_OFF = + '**Kill switch:** refused with `disabled` while Buddies is off.' +const KILL_SWITCH_EXEMPT = + '**Kill switch:** keeps working while Buddies is off, so people can always leave.' +const PHOTOS_SWITCH = + '**Photos switch:** also needs photo blobs on (KV `buddies:photos`, else `BUDDIES_PHOTOS`); off, it answers `photos_disabled`. Buddies’ own switch wins.' + +const SIGNED_BY = { + owner: + '**Signed by** the inbox owner key (the `ownerPub` stored for `inboxId`).', + writer: + '**Signed by** the writer key registered for (`inboxId`, `slotId`) with `slot/add`.', + self: '**Self-signed** by `ownerPub` in the payload (proof of possession).', + unsigned: + '**Unsigned:** send `{ "p": … }` only, no `s`, `ts`, or `nonce`. Knowing the ids and secrets is the capability.', +} as const + +const paragraphs = (...lines: string[]) => lines.join('\n\n') + +// --- Errors --------------------------------------------------------------- + +const DEFAULT_REASONS: Partial> = { + bad_request: `Malformed envelope or payload (missing fields, wrong sizes, bad ids), or a body over ${kib(BUDDIES_LIMITS.requestBytes)}.`, + stale: + '`ts` is more than 5 minutes from the relay clock. The body carries `serverTime` (epoch ms) and the response a `Date` header; correct the clock offset and re-sign once with a fresh `ts` and `nonce`.', + replay: 'This `nonce` was already used for the inbox in the last 10 minutes.', + not_found: + 'Unknown inbox: never registered, deleted, or wiped after 180 days without an owner op.', + suspended: + 'The inbox’s account is suspended (only `inbox/delete` and `slot/remove` still work).', + disabled: 'The Buddies kill switch is off. `Retry-After: 60`.', + photos_disabled: + 'Photo blobs are off (KV `buddies:photos`), or this worker has no blob bucket. `Retry-After: 60`.', +} + +const RETRY_AFTER_HEADER = { + 'Retry-After': { + description: + 'Seconds to wait; repeated as `retryAfter` in the body. Absent on `limit`.', + schema: { type: 'integer' as const }, + }, +} + +const STALE_HEADERS = { + Date: { + description: 'The relay clock (on `stale`).', + schema: { type: 'string' as const }, + }, +} + +const STATUS_SUMMARY: Record = { + 400: 'Bad request.', + 401: 'Not authenticated.', + 403: 'Forbidden.', + 404: 'Not found.', + 409: 'Conflict.', + 410: 'Gone.', + 413: 'Body too large.', + 426: 'Not a WebSocket upgrade.', + 429: 'Too many requests, or a cap was hit.', + 503: 'Switched off.', +} + +/** + * The error responses an op can return, grouped by status: each code with + * why this op returns it (`DEFAULT_REASONS` unless given). Every op can also + * fail unexpectedly with 500 `internal`. + */ +const refusals = ( + reasons: Partial> +): Responses => { + const byStatus = new Map() + for (const code of Object.keys(reasons) as BuddiesErrorCode[]) { + const status = BUDDIES_ERROR_STATUS[code] + byStatus.set(status, [...(byStatus.get(status) ?? []), code]) + } + const responses: Responses = {} + for (const [status, codes] of [...byStatus].sort(([a], [b]) => a - b)) { + const lines = codes.map((code) => { + const reason = reasons[code] + const text = reason === true ? DEFAULT_REASONS[code] : reason + return `- \`${code}\`: ${text}` + }) + const hasStale = codes.includes('stale') + const response = errorResponse( + `${STATUS_SUMMARY[status]}\n\n${lines.join('\n')}`, + codes, + hasStale ? staleErrorSchema : apiErrorSchema + ) + responses[status] = + status === 429 || status === 503 + ? { ...response, headers: RETRY_AFTER_HEADER } + : hasStale + ? { ...response, headers: STALE_HEADERS } + : response + } + responses[500] = errorResponse( + 'Unexpected failure. The body never names the request.', + ['internal'] + ) + return responses +} + +const edgeLimited = (tier: BuddiesEdgeTier) => + `More than ${perMinute(tier)} from this caller in the ${TIER_NAMES[tier]} tier (\`Retry-After: 60\`)` + +/** The refusals every owner op shares, before op-specific ones. */ +const ownerRefusals = ( + tier: BuddiesEdgeTier, + { killSwitch = true, suspendable = true } = {} +) => ({ + bad_request: true as const, + bad_signature: 'Signature missing, malformed, or not by the owner key.', + stale: true as const, + ...(suspendable ? { suspended: true as const } : {}), + not_found: true as const, + replay: true as const, + rate_limited: `${edgeLimited(tier)}, or ${OWNER_BUDGET} are spent (\`Retry-After\` until the window ends).`, + ...(killSwitch ? { disabled: true as const } : {}), +}) + +/** The refusals every writer op shares. */ +const writerRefusals = (extraRateLimit = '') => ({ + bad_request: true as const, + bad_signature: + 'Signature missing, malformed, or not by the slot’s writer key.', + stale: true as const, + not_found: true as const, + gone: 'The slot doesn’t exist: the inbox owner removed you (or never added you).', + replay: true as const, + rate_limited: `${edgeLimited('write')}, ${WRITER_BUDGET} are spent${extraRateLimit}.`, + disabled: true as const, +}) + +// --- Ops ------------------------------------------------------------------ + +const syncSchema = z.object({ + ok: z.literal(true), + seq: z.number().int().meta({ + description: + 'The inbox head, or, when a page stopped early, the cursor it reached: pass it as the next `since`.', + }), + slots: z + .array( + z.object({ + slotId: z.string(), + createdAt: z.number().int(), + }) + ) + .meta({ + description: + 'Always the complete current list (≤ 5), so a missing slot means that buddy left.', + }), + cards: z + .array( + z.object({ + slotId: z.string(), + blob: z.string().meta({ description: 'Sealed Buddy Card.' }), + seq: z.number().int(), + updatedAt: z.number().int(), + }) + ) + .meta({ description: 'Cards with `seq > since`.' }), + events: z + .array( + z.object({ + eventId: z.string(), + slotId: z.string().meta({ + description: '`""` for events the relay wrote (`invite.claimed`).', + }), + kind: z.string(), + blob: z.string().meta({ description: 'Sealed event.' }), + seq: z.number().int(), + createdAt: z.number().int(), + }) + ) + .meta({ + description: `Events with \`seq > since\`, in \`seq\` order. A page stops at ${count(BUDDIES_ABUSE_LIMITS.syncEvents)} events or 4 MiB of blobs.`, + }), + roster: z + .object({ blob: z.string(), seq: z.number().int() }) + .nullable() + .meta({ description: 'The sealed roster, or `null` when unchanged.' }), + capabilities: z + .object({ + photos: z.boolean().meta({ + description: + 'Photo blobs are on. Read a missing `capabilities` (older relays) as false.', + }), + }) + .meta({ description: 'Optional relay features available right now.' }), +}) + +export const buddiesOpDocs: Record = { + 'inbox/register': describeRoute({ + tags: TAGS, + summary: 'Register an inbox', + description: paragraphs( + 'Creates the inbox, or refreshes it. Idempotent for the same `ownerPub`; a different key is `conflict`, even after the inbox was deleted or wiped (the relay keeps a hash of the owner key forever).', + SIGNED_BY.self, + 'With `account`, the inbox is linked to that anonymous account (written only after the inbox accepted the registration, so the link takes both keys). The answer carries `accountId` once the inbox belongs to an account. Without the accounts database the proof is ignored.', + `${tierLine('register')} Validly signed registrations also count toward ${count(BUDDIES_ABUSE_LIMITS.registrationsPerDay)} per caller per rolling day.`, + KILL_SWITCH_OFF, + 'App Attest is not required yet: a reserved `attest` field is accepted and ignored.' + ), + requestBody: signedBody( + signedPayload({ + inboxId: buddiesId('The inbox to register'), + ownerPub: bytes32('Ed25519 owner public key'), + account: z + .object({ + accountId: buddiesId('The account'), + signature: b64u( + 'Ed25519 signature (64 bytes) by the account signing key over `ww-account/v1\\ninbox/link\\n\\n\\n`' + ), + }) + .optional() + .meta({ + description: + 'Links the inbox to an account. Absent from builds before accounts.', + }), + }), + 'Self-signed: `s` verifies against the payload’s own `ownerPub`.' + ), + responses: { + 200: jsonResponse( + 'Registered (or refreshed).', + z.object({ + ok: z.literal(true), + accountId: buddiesId('The account the inbox belongs to') + .optional() + .meta({ description: 'Present once the inbox is linked.' }), + }) + ), + ...refusals({ + bad_request: true, + bad_signature: + 'Signature not by `ownerPub`, or the `account` signature is not by that account.', + stale: true, + suspended: + 'The inbox, or the account named in `account`, is suspended.', + unknown_account: '`account.accountId` doesn’t exist.', + replay: true, + conflict: + 'The inbox is registered (or was, before a wipe) with a different `ownerPub`, or is linked to a different account.', + rate_limited: `${edgeLimited('register')}, or the caller’s ${count(BUDDIES_ABUSE_LIMITS.registrationsPerDay)} registrations a day are spent (\`Retry-After\` until the oldest leaves the window).`, + disabled: true, + }), + }, + }), + + 'inbox/sync': describeRoute({ + tags: TAGS, + summary: 'Sync the inbox', + description: paragraphs( + 'Returns everything with `seq > since` (cards, events, the roster) plus the complete slot list, and which optional relay features are on. Counts as owner activity for the 180-day retention.', + SIGNED_BY.owner, + tierLine('read'), + KILL_SWITCH_OFF + ), + requestBody: signedBody( + signedPayload({ + inboxId, + since: z.number().int().min(0).meta({ + description: + 'The last `seq` this device has applied; `0` for everything.', + }), + }), + 'Owner-signed (op `inbox/sync`).' + ), + responses: { + 200: jsonResponse('What changed since `since`.', syncSchema), + ...refusals(ownerRefusals('read')), + }, + }), + + 'inbox/delete': describeRoute({ + tags: TAGS, + summary: 'Delete the inbox', + description: paragraphs( + 'Wipes the inbox (slots, cards, events, devices, roster, photo blobs) and every open invite it created, closing its live sockets with 4001. Only a hash of the owner key is kept, so only that key can register the `inboxId` again.', + SIGNED_BY.owner, + `${tierLine('write')} Works for a suspended account.`, + KILL_SWITCH_EXEMPT + ), + requestBody: signedBody( + signedPayload({ inboxId }), + 'Owner-signed (op `inbox/delete`).' + ), + responses: { + 200: jsonResponse('Deleted.', okSchema), + ...refusals( + ownerRefusals('write', { killSwitch: false, suspendable: false }) + ), + }, + }), + + 'device/register': describeRoute({ + tags: TAGS, + summary: 'Register a device for pushes', + description: paragraphs( + `Upserts a push device by \`deviceId\`. At most ${BUDDIES_LIMITS.devices} per inbox; past that the least recently registered is evicted. One device per token: a token another \`deviceId\` holds moves here. Apps re-register about daily, which bumps eviction order.`, + '`pushService` picks the fields: APNs (iOS; also when absent) takes `apnsToken`, `apnsEnvironment`, and `apnsTopic`; FCM (Android) takes `fcmToken` and `appAlerts`. The other service’s fields are ignored.', + SIGNED_BY.owner, + tierLine('write'), + KILL_SWITCH_OFF + ), + requestBody: signedBody( + signedPayload({ + inboxId, + deviceId: buddiesId('The device'), + pushService: z.enum(['apns', 'fcm']).optional().meta({ + description: 'How the relay reaches the device. Default `apns`.', + }), + apnsToken: z + .string() + .regex(/^(?:[0-9A-Fa-f]{2}){16,128}$/) + .optional() + .meta({ + description: 'APNs only, required: the device token (hex).', + }), + apnsEnvironment: z + .enum(['sandbox', 'production']) + .optional() + .meta({ description: 'APNs only, required.' }), + apnsTopic: z.string().optional().meta({ + description: + 'APNs only: the app’s bundle id. Must be `IOS_BUNDLE_ID` or one of `IOS_ADDITIONAL_BUNDLE_IDS`, else `bad_request`; absent means `IOS_BUNDLE_ID`.', + }), + fcmToken: z + .string() + .regex(/^[A-Za-z0-9_:-]{32,4096}$/) + .optional() + .meta({ + description: 'FCM only, required: the registration token.', + }), + appAlerts: z.boolean().optional().meta({ + description: + 'FCM only: the app posts its own named alerts, so the template is sent as fallback text expo-notifications doesn’t show.', + }), + templates: z + .record(z.string(), z.object({ title: z.string(), body: z.string() })) + .meta({ + description: `\`{ [kind]: { title, body } }\`, already localized: at most ${BUDDIES_LIMITS.templates} kinds (\`^[a-z][a-z0-9.]*$\`, ≤ ${BUDDIES_LIMITS.kindChars} chars), strings ≤ ${BUDDIES_LIMITS.templateChars} characters. A kind without a template gets no push on this device.`, + }), + }), + 'Owner-signed (op `device/register`).' + ), + responses: { + 200: jsonResponse('Registered.', okSchema), + ...refusals({ + ...ownerRefusals('write'), + bad_request: `${DEFAULT_REASONS.bad_request} Also an \`apnsTopic\` this worker doesn’t serve.`, + }), + }, + }), + + 'device/unregister': describeRoute({ + tags: TAGS, + summary: 'Unregister a device', + description: paragraphs( + 'Removes a push device. Idempotent.', + SIGNED_BY.owner, + tierLine('write'), + KILL_SWITCH_OFF + ), + requestBody: signedBody( + signedPayload({ inboxId, deviceId: buddiesId('The device') }), + 'Owner-signed (op `device/unregister`).' + ), + responses: { + 200: jsonResponse('Unregistered.', okSchema), + ...refusals(ownerRefusals('write')), + }, + }), + + 'slot/add': describeRoute({ + tags: TAGS, + summary: 'Add a buddy’s writer slot', + description: paragraphs( + `Registers a buddy’s writer key in this inbox. Idempotent for the same key. At most ${BUDDIES_LIMITS.slotsPlusOpenInvites} slots plus open invites. Signals live sockets.`, + SIGNED_BY.owner, + tierLine('write'), + KILL_SWITCH_OFF + ), + requestBody: signedBody( + signedPayload({ + inboxId, + slotId: buddiesId('The new slot'), + writerPub: bytes32('The buddy’s Ed25519 writer public key'), + }), + 'Owner-signed (op `slot/add`).' + ), + responses: { + 200: jsonResponse('Added.', okSchema), + ...refusals({ + ...ownerRefusals('write'), + conflict: 'The slot exists with a different `writerPub`.', + limit: `Slots plus open invites are already ${BUDDIES_LIMITS.slotsPlusOpenInvites}. No \`Retry-After\`: only removing a buddy or an invite frees a spot.`, + }), + }, + }), + + 'slot/remove': describeRoute({ + tags: TAGS, + summary: 'Remove a buddy’s slot', + description: paragraphs( + 'Deletes the slot, its card, and its events. Idempotent. Signals live sockets when a slot was removed.', + SIGNED_BY.owner, + `${tierLine('write')} Works for a suspended account.`, + KILL_SWITCH_EXEMPT + ), + requestBody: signedBody( + signedPayload({ inboxId, slotId: buddiesId('The slot to remove') }), + 'Owner-signed (op `slot/remove`).' + ), + responses: { + 200: jsonResponse('Removed (or already gone).', okSchema), + ...refusals( + ownerRefusals('write', { killSwitch: false, suspendable: false }) + ), + }, + }), + + 'roster/put': describeRoute({ + tags: TAGS, + summary: 'Replace the roster', + description: paragraphs( + 'Replaces the encrypted roster (last writer wins; clients merge first). Takes the next `seq` and signals live sockets.', + SIGNED_BY.owner, + tierLine('write'), + KILL_SWITCH_OFF + ), + requestBody: signedBody( + signedPayload({ + inboxId, + blob: sealed('The roster', BUDDIES_LIMITS.rosterBytes), + }), + 'Owner-signed (op `roster/put`).' + ), + responses: { + 200: jsonResponse('Stored.', seqSchema), + ...refusals(ownerRefusals('write')), + }, + }), + + 'invite/create': describeRoute({ + tags: TAGS, + summary: 'Create an invite', + description: paragraphs( + `Uploads a sealed invite card under \`inviteId\` for a buddy to fetch and claim. Per inbox: at most ${BUDDIES_LIMITS.openInvites} open invites and ${BUDDIES_LIMITS.slotsPlusOpenInvites} slots plus open invites (\`limit\`), and ${BUDDIES_LIMITS.inviteCreations} creations per rolling 24 h (\`rate_limited\`).`, + SIGNED_BY.owner, + tierLine('write'), + KILL_SWITCH_OFF, + 'App Attest is not required yet: a reserved `attest` field is accepted and ignored.' + ), + requestBody: signedBody( + signedPayload({ + inboxId, + inviteId: buddiesId('The new invite, derived from the link secret'), + claimVerifier: bytes32('`b64u(SHA-256(claimSecret))`'), + blob: sealed('The invite card', BUDDIES_LIMITS.inviteBytes), + expiresAt: z.number().int().meta({ + description: + 'Epoch ms, after now and at most 7 days + 5 minutes ahead of the relay clock.', + }), + }), + 'Owner-signed (op `invite/create`).' + ), + responses: { + 200: jsonResponse('Created.', okSchema), + ...refusals({ + ...ownerRefusals('write'), + bad_request: `${DEFAULT_REASONS.bad_request} Also an \`expiresAt\` in the past or more than 7 days + 5 minutes ahead.`, + not_found: `${DEFAULT_REASONS.not_found} Also when the inbox was deleted while the invite was being created.`, + conflict: 'An invite with this `inviteId` exists.', + limit: `The inbox has ${BUDDIES_LIMITS.openInvites} open invites, or slots plus open invites are ${BUDDIES_LIMITS.slotsPlusOpenInvites}. No \`Retry-After\`.`, + rate_limited: `${edgeLimited('write')}, ${OWNER_BUDGET} are spent, or the inbox created ${BUDDIES_LIMITS.inviteCreations} invites in the last 24 h (\`Retry-After\` until the oldest leaves the window).`, + }), + }, + }), + + 'invite/delete': describeRoute({ + tags: TAGS, + summary: 'Delete an invite', + description: paragraphs( + 'Deletes an invite this inbox created (cancel, confirm, or reject). Idempotent; an invite another inbox created is left alone.', + SIGNED_BY.owner, + tierLine('write'), + KILL_SWITCH_OFF + ), + requestBody: signedBody( + signedPayload({ inboxId, inviteId: buddiesId('The invite') }), + 'Owner-signed (op `invite/delete`).' + ), + responses: { + 200: jsonResponse('Deleted (or already gone).', okSchema), + ...refusals(ownerRefusals('write')), + }, + }), + + 'blob/delete': describeRoute({ + tags: TAGS, + summary: 'Delete photo blobs', + description: paragraphs( + 'Deletes photo blobs from the sender’s own inbox. Reads stop at once; the stored objects go right after (retried from the inbox alarm if storage fails). Idempotent: unknown ids succeed.', + SIGNED_BY.owner, + tierLine('write'), + KILL_SWITCH_OFF, + PHOTOS_SWITCH + ), + requestBody: signedBody( + signedPayload({ + inboxId, + blobIds: z + .array(bytes32('A blob id')) + .min(1) + .max(BUDDIES_LIMITS.blobDeleteIds) + .meta({ + description: `1 to ${BUDDIES_LIMITS.blobDeleteIds} canonical blob ids; duplicates are fine.`, + }), + }), + 'Owner-signed (op `blob/delete`).' + ), + responses: { + 200: jsonResponse('Deleted.', okSchema), + ...refusals({ ...ownerRefusals('write'), photos_disabled: true }), + }, + }), + + 'card/put': describeRoute({ + tags: TAGS, + summary: 'Replace your Buddy Card', + description: paragraphs( + `Replaces the writer slot’s card in the recipient’s inbox. Takes the next \`seq\` and signals live sockets. Counts toward the slot’s ${BUDDIES_LIMITS.writesPerSlot} writes per hour (with \`event/put\`).`, + SIGNED_BY.writer, + tierLine('write'), + KILL_SWITCH_OFF + ), + requestBody: signedBody( + signedPayload({ + inboxId: buddiesId('The recipient’s inbox'), + slotId, + blob: sealed('The Buddy Card', BUDDIES_LIMITS.cardBytes), + }), + 'Writer-signed (op `card/put`).' + ), + responses: { + 200: jsonResponse('Stored.', seqSchema), + ...refusals( + writerRefusals( + `, or the slot’s ${BUDDIES_LIMITS.writesPerSlot} writes this hour are spent (\`Retry-After\` until the oldest ages out)` + ) + ), + }, + }), + + 'event/put': describeRoute({ + tags: TAGS, + summary: 'Append an event', + description: paragraphs( + 'Appends a sealed event to the recipient’s inbox, deduplicated on `eventId`: a repeat returns the original `seq` and never pushes. Takes the next `seq` and signals live sockets. Events are deleted 30 days after creation.', + `With \`push: true\` the relay alerts every device in the inbox that registered a template for \`kind\`, within the slot’s push budget: ${BUDDIES_LIMITS.pushesPerSlot} per 24 h, at least 60 s apart except for immediate kinds (invitations and answers). An alert over budget is dropped; the event is stored either way.`, + `**Caps:** ${BUDDIES_LIMITS.writesPerSlot} writes per slot per hour (with \`card/put\`); stored events per slot ${count(BUDDIES_ABUSE_LIMITS.slotEvents)} and 16 MiB, per inbox 128 MiB. Past a cap nothing is stored and nothing stored is dropped.`, + SIGNED_BY.writer, + tierLine('write'), + KILL_SWITCH_OFF + ), + requestBody: signedBody( + signedPayload({ + inboxId: buddiesId('The recipient’s inbox'), + slotId, + eventId: buddiesId('The event, for deduplication'), + kind: z + .string() + .max(BUDDIES_LIMITS.kindChars) + .regex(/^[a-z][a-z0-9.]*$/) + .meta({ + description: `Event kind, e.g. \`plan.invite\` (\`^[a-z][a-z0-9.]*$\`, ≤ ${BUDDIES_LIMITS.kindChars} chars). Picks the push template.`, + }), + blob: sealed('The event', BUDDIES_LIMITS.eventBytes), + push: z.boolean().meta({ description: 'Whether to alert.' }), + }), + 'Writer-signed (op `event/put`).' + ), + responses: { + 200: jsonResponse('Stored (or already stored).', seqSchema), + ...refusals( + writerRefusals( + `, the slot’s ${BUDDIES_LIMITS.writesPerSlot} writes this hour are spent, or a stored-event cap is full (\`Retry-After\` until the oldest write or event ages out)` + ) + ), + }, + }), + + 'slot/leave': describeRoute({ + tags: TAGS, + summary: 'Leave a buddy’s inbox', + description: paragraphs( + 'The writer removes its own slot (with its card and events) from the recipient’s inbox, when ending a connection. Idempotent: an unknown inbox or slot succeeds, since there is nothing left to leave.', + SIGNED_BY.writer, + tierLine('write'), + KILL_SWITCH_EXEMPT + ), + requestBody: signedBody( + signedPayload({ + inboxId: buddiesId('The recipient’s inbox'), + slotId: buddiesId('Your slot in it'), + }), + 'Writer-signed (op `slot/leave`).' + ), + responses: { + 200: jsonResponse('Left (or nothing to leave).', okSchema), + ...refusals({ + bad_request: true, + bad_signature: + 'Signature missing, malformed, or not by the slot’s writer key.', + stale: true, + replay: true, + rate_limited: `${edgeLimited('write')}.`, + }), + }, + }), + + 'invite/fetch': describeRoute({ + tags: TAGS, + summary: 'Fetch an invite', + description: paragraphs( + 'Reads an invite’s sealed card and status. Expired, deleted, and burned invites are `not_found`.', + SIGNED_BY.unsigned, + tierLine('unsigned'), + KILL_SWITCH_OFF + ), + requestBody: jsonBody( + z.object({ + p: b64u('The payload JSON, UTF-8 encoded'), + }), + `Unsigned envelope.\n\n**Decoded \`p\`:**\n\n${fieldTable( + z.object({ inviteId: buddiesId('The invite, from the link secret') }) + )}` + ), + responses: { + 200: jsonResponse( + 'The invite.', + z.object({ + ok: z.literal(true), + blob: z.string().meta({ description: 'The sealed invite card.' }), + expiresAt: z.number().int().meta({ description: 'Epoch ms.' }), + status: z.enum(['open', 'claimed']), + }) + ), + ...refusals({ + bad_request: true, + not_found: 'Unknown, expired, deleted, or burned invite.', + rate_limited: `${edgeLimited('unsigned')}.`, + disabled: true, + }), + }, + }), + + 'invite/claim': describeRoute({ + tags: TAGS, + summary: 'Claim an invite', + description: paragraphs( + 'Claims an invite with its secret and a sealed claim card. The relay compares `SHA-256(claimSecret)` to the stored verifier in constant time. The first valid claim wins; the winner’s exact retry (same secret, byte-identical `blob`) gets `{ ok }` again without a second event or push. On success the relay appends `{ slotId: "", kind: "invite.claimed", eventId: inviteId, blob }` to the creator’s inbox and pushes it.', + `${BUDDIES_LIMITS.badClaims} wrong secrets burn the invite.`, + SIGNED_BY.unsigned, + tierLine('unsigned'), + KILL_SWITCH_OFF, + 'App Attest is not required yet.' + ), + requestBody: jsonBody( + z.object({ + p: b64u('The payload JSON, UTF-8 encoded'), + }), + `Unsigned envelope.\n\n**Decoded \`p\`:**\n\n${fieldTable( + z.object({ + inviteId: buddiesId('The invite'), + claimSecret: bytes32('The claim secret derived from the link'), + blob: sealed('The claim card', BUDDIES_LIMITS.claimBytes), + }) + )}` + ), + responses: { + 200: jsonResponse('Claimed.', okSchema), + ...refusals({ + bad_request: true, + bad_signature: `Wrong \`claimSecret\`. The ${BUDDIES_LIMITS.badClaims}th wrong one burns the invite.`, + not_found: + 'Unknown, expired, deleted, or burned invite, or the creator cancelled it while the claim was delivered.', + conflict: + 'Already claimed (by someone else, with a different `blob`, or the first claim is still being delivered).', + rate_limited: `${edgeLimited('unsigned')}.`, + disabled: true, + }), + }, + }), +} + +// --- Header-envelope routes ----------------------------------------------- + +const envelopeHeaders = (op: string, payload: z.ZodType) => [ + { + name: 'x-buddies-p', + in: 'header' as const, + required: true, + schema: { type: 'string' as const }, + description: `The envelope’s \`p\`: the payload JSON, UTF-8, base64url without padding.\n\n**Decoded:**\n\n${fieldTable(payload)}`, + }, + { + name: 'x-buddies-s', + in: 'header' as const, + required: true, + schema: { type: 'string' as const }, + description: `The envelope’s \`s\`: Ed25519 signature by the owner key over \`ww-buddies/v1\\n${op}\\n\` followed by the decoded \`x-buddies-p\` bytes (base64url, no padding).`, + }, +] + +const { gone, replaced, suspended } = BUDDIES_LIVE_CLOSE + +export const buddiesLiveDocs = describeRoute({ + tags: TAGS, + summary: 'Live socket (WebSocket)', + description: paragraphs( + 'Upgrades to a WebSocket that says *that* the owner’s inbox changed, so the app syncs within a second while in the foreground. It never carries content. The owner-signed envelope (op `inbox/live`) rides in headers, since an upgrade has no body and ids never go in URLs. Same `stale` and `replay` checks as a POST owner op. Opening a socket is not owner activity for the 180-day retention.', + '**Server → app messages:**\n\n- `{"type":"hello","seq":N}` right after the upgrade, `N` being the inbox head.\n- `{"type":"changed","seq":N}` after every committed write that changes what `inbox/sync` returns: new events, cards, the roster, a delivered claim, and slot changes. Slot changes don’t advance `seq`, so sync on every `changed`.', + '**Keepalive:** send the text `ping` about every 25 s; the relay answers `pong` without waking the inbox. Anything else the app sends is ignored.', + `**Close codes:** \`${gone.code} "${gone.reason}"\` the inbox was deleted or wiped (sync, which restores it); \`${replaced.code} "${replaced.reason}"\` an ${BUDDIES_LIMITS.liveSockets + 1}th socket opened and closed the oldest (at most ${BUDDIES_LIMITS.liveSockets} per inbox); \`${suspended.code} "${suspended.reason}"\` the inbox’s account was suspended. Reconnect with backoff while in the foreground.`, + `${tierLine('read')} The upgrade also counts toward ${OWNER_BUDGET}.`, + `${KILL_SWITCH_OFF} Turning it off refuses new sockets but doesn’t close open ones.` + ), + parameters: [ + { + name: 'Upgrade', + in: 'header' as const, + required: true, + schema: { type: 'string' as const, enum: ['websocket'] }, + description: 'Must be `websocket`, else `426 upgrade_required`.', + }, + ...envelopeHeaders( + 'inbox/live', + signedPayload({ inboxId: buddiesId('Your inbox') }) + ), + ], + responses: { + 101: { description: 'Switching protocols: the socket is open.' }, + ...refusals({ + ...ownerRefusals('read'), + bad_request: 'Missing or malformed `x-buddies-p`, or a bad payload.', + upgrade_required: 'Not a WebSocket upgrade (`Upgrade: websocket`).', + }), + }, +}) + +const binary = (description: string) => ({ + description, + content: { + 'application/octet-stream': { + schema: { type: 'string' as const, format: 'binary' }, + }, + }, +}) + +export const buddiesBlobPutDocs = describeRoute({ + tags: TAGS, + summary: 'Upload a photo blob', + description: paragraphs( + `Stores one sealed photo in the sender’s own inbox for buddies to fetch with \`blob/get\`. The body is the raw sealed bytes (not base64), 1 to ${count(BUDDIES_LIMITS.blobBytes)}; the owner-signed envelope (op \`blob/put\`) rides in headers. The relay recomputes \`blobId = b64u(SHA-256(body))\`, so the signature covers the bytes too. Only an inbox with at least one buddy (slot) can store one.`, + 'Idempotent per `blobId`: a repeat keeps the first `readTokenHash` (retries must reuse the token) and moves the expiry to the later of the two; one that doesn’t move it stores nothing and costs no upload.', + `**Caps per inbox:** ${BUDDIES_ABUSE_LIMITS.inboxBlobs} live blobs and 150 MiB live; ${BUDDIES_ABUSE_LIMITS.blobUploads} uploads (object writes) per rolling 24 h. Blobs are deleted at \`expiresAt\`, by \`blob/delete\`, or with the inbox.`, + 'If storing the object fails the answer is a 500; a retry finishes the upload.', + `${tierLine('blobPut')} The upload also counts toward ${OWNER_BUDGET}.`, + KILL_SWITCH_OFF, + PHOTOS_SWITCH + ), + parameters: envelopeHeaders( + 'blob/put', + signedPayload({ + inboxId: buddiesId('Your (the sender’s) inbox'), + blobId: bytes32('`b64u(SHA-256(body))`'), + bytes: z + .number() + .int() + .min(1) + .meta({ + description: `The body’s exact length, 1 to ${count(BUDDIES_LIMITS.blobBytes)}. \`Content-Length\`, when sent, must match.`, + }), + expiresAt: z.number().int().meta({ + description: + 'Epoch ms, after now and at most 90 days ahead of the relay clock.', + }), + readTokenHash: bytes32( + '`b64u(SHA-256(readToken))`, the 32-byte read capability the app shares with recipients' + ), + }) + ), + requestBody: { + required: true, + ...binary( + `The sealed photo bytes, \`0x01 ‖ nonce[12] ‖ ciphertext ‖ tag[16]\`, at most ${kib(BUDDIES_LIMITS.blobBytes)}.` + ), + }, + responses: { + 200: jsonResponse( + 'Stored (or already stored).', + z.object({ + ok: z.literal(true), + expiresAt: z.number().int().meta({ + description: 'The stored expiry, epoch ms (the later of any puts).', + }), + }) + ), + ...refusals({ + ...ownerRefusals('blobPut'), + bad_request: + 'Missing or malformed headers or payload, an `expiresAt` out of range, a body whose length isn’t `bytes`, or whose SHA-256 isn’t `blobId`.', + not_found: `${DEFAULT_REASONS.not_found} Also when the blob or inbox was deleted while it uploaded.`, + no_buddies: 'The inbox has no slots, so nobody could read the photo.', + too_large: `\`bytes\`, \`Content-Length\`, or the streamed body is over ${kib(BUDDIES_LIMITS.blobBytes)}.`, + rate_limited: `${edgeLimited('blobPut')}, ${OWNER_BUDGET} are spent, or a per-inbox cap is hit (\`Retry-After\` until the soonest live blob expires or the oldest upload leaves the day).`, + photos_disabled: true, + }), + }, +}) + +export const buddiesBlobGetDocs = describeRoute({ + tags: TAGS, + summary: 'Download a photo blob', + description: paragraphs( + 'Returns the exact sealed bytes put, for a recipient holding the reference from a share event. Changes nothing and isn’t owner activity.', + `${SIGNED_BY.unsigned} The read token is the capability; the relay stores only its hash and compares in constant time.`, + 'Unknown inboxes and blobs, deleted, expired, or unfinished blobs, and wrong tokens all get the same `404 not_found`.', + tierLine('blobGet'), + KILL_SWITCH_OFF, + PHOTOS_SWITCH + ), + requestBody: jsonBody( + z.object({ p: b64u('The payload JSON, UTF-8 encoded') }), + `Unsigned envelope.\n\n**Decoded \`p\`:**\n\n${fieldTable( + z.object({ + inboxId: buddiesId('The sender’s inbox'), + blobId: bytes32('The blob'), + token: bytes32('The read token from the reference'), + }) + )}` + ), + responses: { + 200: { + ...binary( + 'The sealed bytes. The app may check `b64u(SHA-256(body)) = blobId`; the AEAD tag catches tampering either way.' + ), + headers: { + 'Cache-Control': { + description: '`no-store`.', + schema: { type: 'string' as const }, + }, + 'Content-Length': { schema: { type: 'integer' as const } }, + 'X-Content-Type-Options': { + description: '`nosniff`.', + schema: { type: 'string' as const }, + }, + }, + }, + ...refusals({ + bad_request: true, + not_found: + 'Unknown inbox or blob, a deleted, expired, or unfinished blob, or a wrong token.', + rate_limited: `${edgeLimited('blobGet')}.`, + disabled: true, + photos_disabled: true, + }), + }, +}) diff --git a/src/buddies/route.ts b/src/buddies/route.ts index b2251af..7960afc 100644 --- a/src/buddies/route.ts +++ b/src/buddies/route.ts @@ -54,6 +54,12 @@ import { } from './limits' import { defaultApnsDependencies } from '../apns' import { deliverPushJob, type PushDependencies } from './push' +import { + buddiesBlobGetDocs, + buddiesBlobPutDocs, + buddiesLiveDocs, + buddiesOpDocs, +} from './openapi' /** * `POST /buddies/v1/{op}` — the Buddies relay (docs/buddies-protocol.md) — @@ -503,10 +509,12 @@ export const createBuddiesRoutes = ( ) => { const routes = new Hono<{ Bindings: Environment }>() for (const op of BUDDIES_OPS) { - routes.post(`/${op}`, (c) => handleBuddiesOp(c, op, deps)) + routes.post(`/${op}`, buddiesOpDocs[op], (c) => + handleBuddiesOp(c, op, deps) + ) } - routes.get(`/${BUDDIES_LIVE_OP}`, handleLive) - routes.post(`/${BUDDIES_BLOB_PUT_OP}`, handleBlobPut) - routes.post(`/${BUDDIES_BLOB_GET_OP}`, handleBlobGet) + routes.get(`/${BUDDIES_LIVE_OP}`, buddiesLiveDocs, handleLive) + routes.post(`/${BUDDIES_BLOB_PUT_OP}`, buddiesBlobPutDocs, handleBlobPut) + routes.post(`/${BUDDIES_BLOB_GET_OP}`, buddiesBlobGetDocs, handleBlobGet) return routes } diff --git a/src/index.ts b/src/index.ts index 3137df8..dcf7c30 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,36 +1,7 @@ -import { Hono } from "hono"; -import type { Environment, AppContext, HealthCheckResponse } from "./types"; -import { HERE_API, HTTP_STATUS } from "./config"; -import { apiError, dateHeader } from "./errors"; -import { rateLimit } from "./rateLimit"; -import { proxyRequestToHereApi } from "./proxy"; -import { - handleAasaRequest, - handleContactLinkRequest, - handleLegacyContactLinkRequest, -} from "./contactLink"; -import { - handleChallengeRequest, - handleAttestRequest, - handleNotesImportVerifyRequest, - handleNotesImportRequest, - handleNotesImportStatusRequest, - handleNotesImportKickoffRequest, - handleNotesImportTranscribeRequest, - handleNotesImportEventsRequest, - handleNotesImportResultRequest, - handleNotesImportCancelRequest, - handleNotesImportDestroyRequest, - handleNotesImportAdminResetRequest, -} from "./notesImport/route"; -import { handleAppStoreRatingsRequest } from "./appStoreRatings/route"; +import type { Environment } from "./types"; import { runRatingsSweepStep } from "./appStoreRatings/ratings"; import { Sentry, createSentryConfig } from "./sentry"; -import { createBuddiesRoutes } from "./buddies/route"; -import { createAccountsRoutes } from "./accounts/route"; -import { createAccountsAdminRoutes } from "./accounts/admin"; -import { handleBuddyInvitePage } from "./buddies/invitePage"; -import { createRoutePlanningRoutes } from "./routePlanning/route"; +import { app } from "./app"; // Durable Object classes must be re-exported from the entry module so Wrangler // can bind them (see wrangler.toml [[durable_objects.bindings]] + migrations). @@ -43,101 +14,6 @@ export { BuddyInvite } from "./buddies/inviteDO"; export { RoutePlanningQuota } from "./routePlanning/quotaDO"; export { BuddyRegistrationQuota } from "./buddies/registrationQuota"; -const app = new Hono<{ Bindings: Environment }>(); - -async function handleGeocodeRequest(context: AppContext) { - return proxyRequestToHereApi(context, HERE_API.GEOCODE_URL); -} - -async function handleAutocompleteRequest(context: AppContext) { - return proxyRequestToHereApi(context, HERE_API.AUTOCOMPLETE_URL); -} - -/** - * Liveness plus the server clock. Clients calibrate against `Date` (and - * `serverTime`, epoch ms) before signing time-bound requests (Buddies `ts`), - * so it is never cached anywhere: a cached copy would carry an old clock. No - * I/O, no rate limit. - */ -function handleHealthCheckRequest(context: AppContext) { - const now = Date.now(); - const response: HealthCheckResponse = { - status: "ok", - timestamp: new Date(now).toISOString(), - serverTime: now, - versionId: context.env.CF_VERSION_METADATA.id, - versionTag: context.env.CF_VERSION_METADATA.tag || undefined, - deployedAt: context.env.CF_VERSION_METADATA.timestamp, - }; - return context.json(response, 200, { - "Cache-Control": "no-store", - ...dateHeader(now), - }); -} - -function handleNotFound() { - return apiError(HTTP_STATUS.NOT_FOUND, "not_found"); -} - -function handleApplicationError(error: Error) { - Sentry.captureException(error); - console.error("Application error:", error); - return apiError(HTTP_STATUS.INTERNAL_SERVER_ERROR, "server_error"); -} - -// Each family counts in its own per-IP bucket (src/rateLimit.ts). -app.use("/geocode", rateLimit("places")); -app.use("/autocomplete", rateLimit("places")); -app.use("/notes-import", rateLimit("notes-import")); -app.use("/notes-import/*", rateLimit("notes-import")); -app.use("/admin/*", rateLimit("admin")); -app.use("/route-planning/*", rateLimit("route-planning")); - -app.get("/geocode", handleGeocodeRequest); -app.get("/autocomplete", handleAutocompleteRequest); -app.get("/health", handleHealthCheckRequest); -// Paywall social proof (App Store ratings), edge-cached; refreshed by cron. -app.get("/app-store/ratings", handleAppStoreRatingsRequest); -// Notes Import: availability probe, App Attest handshake + the metered call. -app.get("/notes-import/status", handleNotesImportStatusRequest); -app.post("/notes-import/challenge", handleChallengeRequest); -app.post("/notes-import/attest", handleAttestRequest); -// Attested no-op: dev-tools diagnostics prove the assertion path server-side. -app.post("/notes-import/verify", handleNotesImportVerifyRequest); -// Streaming import: attested kickoff → SSE progress stream → result snapshot. -app.post("/notes-import/kickoff", handleNotesImportKickoffRequest); -app.post("/notes-import/transcribe", handleNotesImportTranscribeRequest); -app.get("/notes-import/:importId/events", handleNotesImportEventsRequest); -app.get("/notes-import/:importId/result", handleNotesImportResultRequest); -app.post("/notes-import/:importId/cancel", handleNotesImportCancelRequest); -app.post("/notes-import/:importId/destroy", handleNotesImportDestroyRequest); -// Legacy synchronous path (fallback during cutover). -app.post("/notes-import", handleNotesImportRequest); -// Maintainer-only usage reset (secret auth inside handler; 404 fail-closed). -app.post("/admin/notes-import/reset", handleNotesImportAdminResetRequest); -// Account moderation: look up and suspend accounts (token auth; 404 fail-closed). -app.route("/admin/accounts", createAccountsAdminRoutes()); -// Universal-link support for WitnessWork contact sharing. AASA must be served -// at this exact path with Content-Type application/json and no redirects. -app.get("/.well-known/apple-app-site-association", handleAasaRequest); -// Shared contacts ride in the fragment (`/c#`), which never reaches -// the worker. `/c/:payload` keeps links from older app versions working. -app.get("/c", handleContactLinkRequest); -app.get("/c/:payload", handleLegacyContactLinkRequest); -// Buddies relay: POST /buddies/v1/{op}, ids only in bodies. Every op is -// rate-limited per caller inside the handler (src/buddies/limits.ts). -app.route("/buddies/v1", createBuddiesRoutes()); -// Anonymous accounts (witness-work ADR 0024): POST /accounts/v1/{op}, signed -// like Buddies ops and rate-limited per caller inside the handler. -app.route("/accounts/v1", createAccountsRoutes()); -// Buddy invite no-app fallback; the invite secret stays in the URL fragment. -app.get("/b", handleBuddyInvitePage); -// Supporter-only route ordering (HERE Waypoints Sequence); coordinates only, -// with per-account limits in the RoutePlanningQuota DO. -app.route("/route-planning", createRoutePlanningRoutes()); -app.notFound(handleNotFound); -app.onError(handleApplicationError); - export default Sentry.withSentry( (environment: Environment) => createSentryConfig(environment), { diff --git a/src/notesImport/openapi.ts b/src/notesImport/openapi.ts new file mode 100644 index 0000000..bc2c227 --- /dev/null +++ b/src/notesImport/openapi.ts @@ -0,0 +1,1108 @@ +import { z } from 'zod' +import { describeRoute } from 'hono-openapi' +import { + apiErrorSchema, + errorResponse, + json, + jsonBody, + jsonResponse, + rateLimitedResponse, +} from '../openapi/schemas' +import { notesImportResultValidator } from './schema' + +/** + * OpenAPI descriptions for the Notes Import routes (`src/notesImport/route.ts`). + * Doc-only: the handlers keep their own parsing, so update this file with any + * change to the wire contract. + */ + +const TAG = 'Notes Import' + +// --- Shared fields ------------------------------------------------------- + +const sha256Hex = (description: string) => + z + .string() + .regex(/^[a-f0-9]{64}$/) + .meta({ description: `${description} (lowercase hex SHA-256)` }) + +const installId = z + .string() + .regex(/^[A-Za-z0-9_-]{8,64}$/) + .meta({ + description: + 'The install id (`uuid`). App Attest keys are pinned to it; usage is metered on it when no `accountId` is sent.', + }) + +const accountId = z + .string() + .regex(/^[A-Za-z0-9_-]{8,64}$/) + .optional() + .meta({ + description: + 'Optional shared account id. When present, Supporter status, allowances, the concurrency cap, and replay all key on it instead of `uuid`. A malformed value is a 400, never ignored.', + }) + +const operationId = z + .string() + .regex(/^[A-Za-z0-9_-]{8,128}$/) + .meta({ + description: + 'Client-chosen id for one challenge → request round trip. Reusing it with different data is `operation_conflict`.', + }) + +const keyId = z + .string() + .regex(/^[A-Za-z0-9+/]{43}=$/) + .meta({ + description: 'The App Attest key id (standard base64 of 32 bytes).', + }) + +const challenge = z + .string() + .regex(/^[A-Za-z0-9_-]{43}$/) + .meta({ + description: + 'The one-time server challenge from `POST /notes-import/challenge` (43-character base64url). Consumed on use.', + }) + +const purpose = z + .enum([ + 'notes-import-kickoff', + 'notes-import-verify', + 'notes-import-transcribe', + ]) + .meta({ + description: + 'Which protected request the challenge is for. It must match the route that consumes it.', + }) + +const expiresAt = z.number().int().meta({ + description: 'When the challenge expires (epoch ms; 5 minutes after issue).', +}) + +const playIntegrityProvider = z.literal('play-integrity').meta({ + description: + 'Opts the request into the Android Play Integrity path. Bodies without it take the App Attest path.', +}) + +const devBypassHeader = { + name: 'x-ww-dev-bypass', + in: 'header' as const, + required: false, + schema: { type: 'string' as const }, + description: + 'Development worker only: the `NOTES_IMPORT_DEV_BYPASS_TOKEN` secret. A match skips attestation and the RevenueCat check and uses the Supporter allowances. Production never sets the token, so the header is ignored there.', +} + +// --- Shared bodies and responses ---------------------------------------- + +const creditsSchema = z + .object({ + remaining: z.number().int().nullable().meta({ + description: 'Imports left in the current window; `null` is unlimited.', + }), + limit: z.number().int().nullable().meta({ + description: 'Imports allowed per window; `null` is unlimited.', + }), + resetsAt: z.string().nullable().meta({ + description: + 'When the current import window ends (ISO 8601). `null` before the first charged import or when unlimited.', + format: 'date-time', + }), + isSupporter: z.boolean().meta({ + description: + 'The real RevenueCat entitlement. It never implies an allowance value (the dev bypass raises allowances without setting it).', + }), + refinements: z + .object({ + remaining: z.number().int().nullable(), + limit: z.number().int().nullable(), + }) + .meta({ + description: + 'Refinements left and allowed for this content, a lifetime allowance per import; `null` is unlimited.', + }), + }) + .meta({ + id: 'NotesImportCredits', + description: + 'Usage snapshot, shared by kickoff, `done`, the legacy response, and allowance denials.', + }) + +const notesImportErrorSchema = apiErrorSchema + .extend({ + detail: z.string().optional().meta({ + description: + 'On `unavailable`, why (`disabled`, `no_provider`, or an operator message). On the development worker, a debug detail for `server_error` and `model_error`.', + }), + reason: z.string().optional().meta({ + description: + 'Stable App Attest or Play Integrity reason, on attestation failures: `invalid_request`, `unsupported_protocol`, `operation_conflict`, `too_many_challenges`, `challenge_not_found`, `challenge_expired`, `identity_not_bound`, `key_not_active`, `recovery_not_enrolled`, `recovery_token_mismatch`, `attestation_invalid`, `assertion_invalid`, `counter_not_increasing`, `storage_unavailable`; Android adds `integrity_unavailable`, `integrity_token_invalid`, `app_not_recognized`, `device_integrity_failed`.', + }), + action: z + .enum([ + 'none', + 'retry', + 'request_challenge', + 'start_new_operation', + 'bind', + 'enroll_recovery', + 'use_active_key', + ]) + .optional() + .meta({ + description: + 'What the client should do next, beside `reason`. Transient failures never ask for key rotation.', + }), + credits: creditsSchema.optional().meta({ + description: 'On `limit_reached` and `refinement_limit`.', + }), + }) + .meta({ + id: 'NotesImportError', + description: + 'The shared error envelope; `error` is a human-readable message. Attestation failures add `reason`/`action`, allowance denials add `credits`.', + }) + +const niError = (description: string, codes: readonly string[]) => + errorResponse(description, codes, notesImportErrorSchema) + +const V1_CODES = [ + 'device_verification_failed', + 'assertion_failed', + 'challenge_expired', + 'counter_conflict', +] as const + +const ATTESTATION_NOTE = + 'App Attest v1 (legacy) failures are always 401 with `device_verification_failed`, `assertion_failed`, `challenge_expired`, or `counter_conflict`, which never ask the client to rotate its key. App Attest v2 and Play Integrity failures are `attestation_failed` at the reason’s own status; branch on `reason`/`action`.' + +const killSwitchUnavailable = + '`unavailable`: the kill switch is off or no allowlisted model host is healthy (`detail` says which; `Retry-After: 60`). ' + +const storageUnavailable = + '`server_error`: attestation storage or Google’s decode service is unavailable (`Retry-After: 30`).' + +const attestationResponses = { + 401: niError( + `Attestation failed: an unknown, consumed, or expired challenge, an invalid assertion or token, or an identity whose key isn’t bound or active. ${ATTESTATION_NOTE}`, + ['attestation_failed', ...V1_CODES] + ), + 403: niError( + 'Play Integrity only: the app isn’t the Play-distributed build (`app_not_recognized`) or the device fails the required integrity verdict (`device_integrity_failed`).', + ['attestation_failed'] + ), + 409: niError( + 'App Attest v2 / Play Integrity: `operationId` was used with different data (`operation_conflict`), or the assertion’s sign count didn’t increase (`counter_not_increasing`). Start a new operation.', + ['attestation_failed'] + ), +} + +// --- Attested request fields -------------------------------------------- + +/** App Attest v2 assertion fields shared by verify, kickoff, and transcribe. */ +const appAttestV2AssertionFields = (forPurpose: string) => ({ + protocolVersion: z.literal(2), + operation: z.literal('assert'), + operationId, + keyId, + purpose: z.literal(forPurpose), + challenge, + assertion: z.string().max(65_536).meta({ + description: + 'App Attest assertion (base64) over SHA-256 of the client data `witnesswork.app-attest|2|assert|||||||`.', + }), +}) + +/** Play Integrity fields shared by verify, kickoff, and transcribe. */ +const playIntegrityFields = (forPurpose: string) => ({ + attestationProvider: playIntegrityProvider, + protocolVersion: z.literal(1), + operation: z.literal('assert'), + operationId, + purpose: z.literal(forPurpose), + challenge, + integrityToken: z.string().max(32_768).meta({ + description: + 'A Play Integrity standard-request token whose `requestHash` is the lowercase hex SHA-256 of `witnesswork.play-integrity|1|assert|||||||`. Never send `keyId` or `assertion` with it.', + }), +}) + +// --- GET /notes-import/status -------------------------------------------- + +const allowancePair = z.object({ + free: z.number().int().nullable(), + supporter: z.number().int().nullable(), +}) + +const capabilitiesSchema = z.object({ + appAttest: z.object({ + protocolVersions: z.array(z.number().int()).meta({ + description: 'App Attest protocol versions accepted: `[1, 2]`.', + }), + }), + playIntegrity: z + .object({ + protocolVersions: z.array(z.number().int()).meta({ + description: 'Play Integrity protocol versions accepted: `[1]`.', + }), + cloudProjectNumber: z.string().meta({ + description: + 'The Google Cloud project number the app passes to the Play Integrity standard-request provider.', + }), + }) + .optional() + .meta({ + description: + 'Present only when this worker can verify Android requests. Android keeps Notes Import closed while it is absent.', + }), + transcribe: z + .object({ + maxPages: z + .number() + .int() + .meta({ description: 'Pages per Import from Paper batch (10).' }), + maxBytes: z.number().int().meta({ + description: + 'Largest decoded page image `POST /notes-import/transcribe` accepts.', + }), + }) + .meta({ description: 'Import from Paper limits.' }), +}) + +const statusSchema = z.object({ + available: z.boolean().meta({ + description: + 'Whether Notes Import can run. Advisory: the import routes enforce the same check themselves.', + }), + reason: z.string().optional().meta({ + description: + 'Only when unavailable: `disabled` (kill switch), `no_provider` (no healthy allowlisted host), or an operator-written message to show.', + }), + limits: z + .object({ + imports: allowancePair.meta({ + description: 'Imports per window; `null` is unlimited.', + }), + refinements: allowancePair.meta({ + description: 'Refinements per import; `null` is unlimited.', + }), + windowDays: z.number().meta({ + description: 'Length of the import window in days (may be fractional).', + }), + }) + .optional() + .meta({ + description: + 'The public allowance schedule. Omitted when unavailable, and when the provider probe failed open (available but unconfirmed).', + }), + capabilities: capabilitiesSchema, + minAppVersion: z + .string() + .regex(/^\d+\.\d+\.\d+$/) + .optional() + .meta({ + description: + 'Oldest app version (`major.minor.patch`) that may use Notes Import. Older apps disable the composer and ask for an update; the worker does not enforce it. Omitted when no floor is set.', + }), +}) + +export const statusDocs = describeRoute({ + tags: [TAG], + summary: 'Availability, limits, and capabilities', + description: `A cheap probe the app calls before showing its import entry points, so it can disable them before attesting. No attestation and no inference. + +Availability checks the KV kill switch (\`notes-import:enabled\`) first, then whether any allowlisted model host is healthy on OpenRouter (a free metadata call, cached 60 seconds). Fails open: if the probe itself errors, the response says available without \`limits\`. + +Completed responses are reused in each Worker isolate for up to 30 seconds (KV reads add up to 60 seconds more); the response itself is \`Cache-Control: no-store\`.`, + responses: { + 200: jsonResponse('The current status.', statusSchema), + ...rateLimitedResponse, + }, +}) + +// --- POST /notes-import/challenge ------------------------------------------ + +const challengeRequestSchema = z.union([ + z + .object({ + protocolVersion: z.literal(1).optional(), + }) + .meta({ + title: 'App Attest v1 (legacy)', + description: + 'An empty body, `{}`, or `{ "protocolVersion": 1 }`. Returns a bare challenge for a v1 attestation or assertion.', + }), + z + .object({ + protocolVersion: z.literal(2), + operation: z.enum(['bind', 'enroll']), + operationId, + uuid: installId, + keyId, + }) + .meta({ + title: 'App Attest v2 bind / enroll', + description: + 'A challenge for `POST /notes-import/attest`: `bind` attests a new key, `enroll` adds a recovery token with the active key. No assertion fields allowed.', + }), + z + .object({ + protocolVersion: z.literal(2), + operation: z.literal('assert'), + operationId, + uuid: installId, + keyId, + purpose, + accountId, + contentHash: sha256Hex( + 'The content hash the protected request will carry' + ), + requestHash: sha256Hex( + 'The request hash the protected request will carry' + ), + }) + .meta({ + title: 'App Attest v2 assert', + description: + 'A challenge bound to one protected request (verify, kickoff, or transcribe). The later request must repeat these values exactly.', + }), + z + .object({ + attestationProvider: playIntegrityProvider, + protocolVersion: z.literal(1), + operation: z.literal('assert'), + operationId, + uuid: installId, + accountId, + purpose, + contentHash: sha256Hex( + 'The content hash the protected request will carry' + ), + requestHash: sha256Hex( + 'The request hash the protected request will carry' + ), + }) + .meta({ + title: 'Play Integrity', + description: + 'Android. Same binding as the App Attest v2 assert challenge, without `keyId`.', + }), +]) + +const challengeResponseSchema = z.union([ + z.object({ challenge: z.string() }).meta({ title: 'App Attest v1 (legacy)' }), + z + .object({ + protocolVersion: z.literal(2), + operation: z.enum(['bind', 'enroll', 'assert']), + operationId, + challenge, + expiresAt, + }) + .meta({ title: 'App Attest v2' }), + z + .object({ + attestationProvider: z.literal('play-integrity'), + protocolVersion: z.literal(1), + operation: z.literal('assert'), + operationId, + challenge, + expiresAt, + }) + .meta({ title: 'Play Integrity' }), +]) + +export const challengeDocs = describeRoute({ + tags: [TAG], + summary: 'Issue an attestation challenge', + description: `Step one of every attested call. Returns a one-time challenge (5-minute lifetime) for App Attest v1, App Attest v2 (\`bind\`, \`enroll\`, or \`assert\`), or Play Integrity. + +v2 and Play Integrity challenges are bound to their request: the descriptor (operation, \`operationId\`, \`uuid\`, \`keyId\`, purpose, \`accountId\`, and both hashes) is stored, and the consuming request must match it. Re-requesting the same \`operationId\` with the same data returns the outstanding challenge. At most 32 unexpired challenges per install. + +**Hashes.** \`contentHash\` is SHA-256 of \`notesText\` (kickoff), of the base64 \`image\` string as sent (transcribe), or any value the client picks (verify). \`requestHash\` is SHA-256 of the sorted-key canonical JSON \`{"context","notesText","refinement"}\` (kickoff; \`refinement\` is \`null\` when absent), of \`{"imageHash","mimeType"}\` (transcribe), or equal to \`contentHash\` (verify).`, + requestBody: { + required: false, + description: 'Optional; an empty body asks for a v1 challenge.', + content: json(challengeRequestSchema), + }, + responses: { + 200: jsonResponse('A challenge.', challengeResponseSchema), + 400: niError( + 'Invalid JSON, an invalid challenge request (`reason: invalid_request`), or an unsupported `protocolVersion` (`reason: unsupported_protocol`). v2 requests the lifecycle rejects answer `attestation_failed`.', + ['bad_request', 'attestation_failed'] + ), + 409: niError( + '`operationId` was already used with different data (`operation_conflict`).', + ['attestation_failed'] + ), + 429: niError( + 'Too many requests from this IP (`rate_limited`, `Retry-After: 60`), or 32 unexpired challenges for this install (`attestation_failed`, `reason: too_many_challenges`; Play Integrity sends `Retry-After: 300`).', + ['rate_limited', 'attestation_failed'] + ), + 500: niError('Unexpected failure.', ['server_error']), + 503: niError( + 'Challenge storage is unavailable, or Play Integrity isn’t configured on this worker (`reason: integrity_unavailable`). `Retry-After: 30`.', + ['server_error'] + ), + }, +}) + +// --- POST /notes-import/attest --------------------------------------------- + +const recoveryToken = z + .string() + .regex(/^[A-Za-z0-9_-]{43}$/) + .meta({ + description: + 'A 256-bit secret the app keeps (43-character base64url). Only a verifier is stored; it lets a reinstall or a new key take over the install id later.', + }) + +const attestRequestSchema = z.union([ + z + .object({ + protocolVersion: z.literal(1).optional(), + keyId: z.string(), + attestation: z.string().meta({ + description: 'App Attest attestation object (base64).', + }), + challenge: z.string(), + uuid: z.string(), + }) + .meta({ + title: 'App Attest v1 (legacy)', + description: 'Binds a key to the install id.', + }), + z + .object({ + protocolVersion: z.literal(2), + operation: z.literal('bind'), + operationId, + uuid: installId, + keyId, + challenge, + attestation: z.string().meta({ + description: + 'App Attest attestation object (base64) whose client data hash is SHA-256 of `witnesswork.app-attest|2|bind||||`, where `tokenHash` is base64url SHA-256 of the decoded recovery token.', + }), + recoveryToken, + }) + .meta({ + title: 'App Attest v2 bind', + description: + 'Attests a key for the install id with a recovery token. Rebinding the same install to a new key requires the recovery token enrolled earlier.', + }), + z + .object({ + protocolVersion: z.literal(2), + operation: z.literal('enroll'), + operationId, + uuid: installId, + keyId, + challenge, + assertion: z.string().max(65_536).meta({ + description: + 'Assertion by the active key over SHA-256 of `witnesswork.app-attest|2|enroll|||||`.', + }), + recoveryToken, + }) + .meta({ + title: 'App Attest v2 enroll', + description: + 'Adds a recovery token to an install already bound by a legacy key.', + }), +]) + +const attestResponseSchema = z.union([ + z.object({ ok: z.literal(true) }).meta({ title: 'App Attest v1 (legacy)' }), + z + .object({ + ok: z.literal(true), + protocolVersion: z.literal(2), + operationId, + status: z + .enum(['bound', 'already_bound', 'rotated', 'recovery_enrolled']) + .meta({ + description: + '`bound`: first key; `already_bound`: this key was already active; `rotated`: a new key replaced the old one with the recovery token; `recovery_enrolled`: enroll succeeded.', + }), + recoveryEnrolled: z.boolean(), + }) + .meta({ title: 'App Attest v2' }), +]) + +export const attestDocs = describeRoute({ + tags: [TAG], + summary: 'Register an App Attest key', + description: `iOS only. Binds a Secure Enclave key to the install id (v1 or v2 \`bind\`), or enrolls a v2 recovery token with the active key (\`enroll\`), using a challenge from \`POST /notes-import/challenge\`. + +The attested App ID must be \`.\` for an accepted bundle id (the App Store app, plus the TestFlight Beta app on production); the key stays bound to the one it matched. Play Integrity has no registration step: a body with \`attestationProvider: "play-integrity"\` is a 400 with \`reason: unsupported_protocol\`.`, + requestBody: jsonBody(attestRequestSchema), + responses: { + 200: jsonResponse('Registered.', attestResponseSchema), + 400: niError( + 'Invalid JSON, missing fields, a Play Integrity body, or (v2) an invalid field or recovery token. Route-level checks answer `bad_request`; v2 checks in the lifecycle answer `attestation_failed` with `reason: invalid_request` or `unsupported_protocol`.', + ['bad_request', 'attestation_failed'] + ), + 401: niError( + `The challenge, attestation, or recovery token didn’t verify: \`challenge_not_found\`, \`challenge_expired\`, \`attestation_invalid\`, \`recovery_not_enrolled\` (\`action: enroll_recovery\`), \`recovery_token_mismatch\`, \`identity_not_bound\`. ${ATTESTATION_NOTE}`, + ['attestation_failed', ...V1_CODES] + ), + 409: niError( + '`operationId` was already used with different data (`operation_conflict`).', + ['attestation_failed'] + ), + ...rateLimitedResponse, + 500: niError('Unexpected failure.', ['server_error']), + 503: niError('App Attest storage is unavailable. `Retry-After: 30`.', [ + 'server_error', + ]), + }, +}) + +// --- POST /notes-import/verify --------------------------------------------- + +const verifyRequestSchema = z.union([ + z + .object({ + protocolVersion: z.literal(1).optional(), + uuid: z.string(), + accountId, + keyId: z.string(), + challenge: z.string(), + assertion: z.string().meta({ + description: + 'Assertion over SHA-256 of `||`, or `|||` when `accountId` is sent.', + }), + contentHash: sha256Hex('Any value the client picks'), + }) + .meta({ title: 'App Attest v1 (legacy)' }), + z + .object({ + uuid: installId, + accountId, + ...appAttestV2AssertionFields('notes-import-verify'), + contentHash: sha256Hex('Any value the client picks'), + requestHash: sha256Hex('Must equal `contentHash`'), + }) + .meta({ title: 'App Attest v2' }), + z + .object({ + uuid: installId, + accountId, + ...playIntegrityFields('notes-import-verify'), + contentHash: sha256Hex('Any value the client picks'), + requestHash: sha256Hex('Must equal `contentHash`'), + }) + .meta({ title: 'Play Integrity' }), +]) + +const verifyResponseSchema = z.union([ + z + .object({ ok: z.literal(true) }) + .meta({ title: 'App Attest v1 (or dev bypass)' }), + z + .object({ + ok: z.literal(true), + protocolVersion: z.literal(2), + operationId, + }) + .meta({ title: 'App Attest v2' }), + z + .object({ + ok: z.literal(true), + attestationProvider: z.literal('play-integrity'), + protocolVersion: z.literal(1), + operationId, + }) + .meta({ title: 'Play Integrity' }), +]) + +export const verifyDocs = describeRoute({ + tags: [TAG], + summary: 'Verify attestation (diagnostic)', + description: `An attested no-op for the app’s developer tools. Runs the same verification as the metered routes, consuming the challenge and advancing the sign count, but touches no allowance and runs no model. + +There is no payload, so \`requestHash\` must equal \`contentHash\` (v2 and Play Integrity), with purpose \`notes-import-verify\`. Not gated by the kill switch.`, + parameters: [devBypassHeader], + requestBody: jsonBody(verifyRequestSchema), + responses: { + 200: jsonResponse('The attestation verified.', verifyResponseSchema), + 400: niError( + 'Invalid JSON, missing or malformed fields, `requestHash` ≠ `contentHash`, or an invalid v2 / Play Integrity request (`reason: invalid_request`).', + ['bad_request', 'attestation_failed'] + ), + ...attestationResponses, + ...rateLimitedResponse, + 500: niError('Unexpected failure.', ['server_error']), + 503: niError(storageUnavailable, ['server_error']), + }, +}) + +// --- Import request (kickoff and legacy) ----------------------------------- + +const contextSchema = z + .object({ + now: z.string().meta({ + description: + 'The user’s current time, ISO 8601 with offset (e.g. `2026-06-17T09:30:00-05:00`). Relative dates resolve against it.', + }), + timeZone: z + .string() + .meta({ description: 'IANA time zone, e.g. `America/Chicago`.' }), + currentRole: z + .enum([ + 'publisher', + 'regularAuxiliary', + 'regularPioneer', + 'circuitOverseer', + 'specialPioneer', + 'custom', + ]) + .optional() + .meta({ description: 'The user’s current role, as context only.' }), + existingContacts: z + .array( + z.object({ + id: z.string(), + name: z.string(), + address: z.string().optional(), + phone: z.string().optional(), + }) + ) + .meta({ + description: + 'Contacts the user already has, so visits attach to real ids instead of duplicates.', + }), + existingCategories: z + .array( + z.object({ id: z.string(), name: z.string(), isCredit: z.boolean() }) + ) + .meta({ + description: + 'Time categories the user already has, so entries reuse their ids.', + }), + }) + .meta({ description: 'What the model needs beyond the notes themselves.' }) + +const refinementSchema = z + .object({ + previousResultJSON: z + .string() + .meta({ description: 'The earlier `result`, as a JSON string.' }), + instruction: z + .string() + .meta({ description: 'The user’s correction, in their words.' }), + }) + .optional() + .meta({ + description: + 'Present to refine an earlier result of the same notes. Uses the refinement allowance instead of an import, and is its own run (the instruction is part of the import id).', + }) + +const importBaseFields = { + uuid: installId, + accountId, + notesText: z.string().meta({ + description: 'The notes to import, at most 100,000 characters by default.', + }), + context: contextSchema, + refinement: refinementSchema, +} + +const notesContentHash = sha256Hex( + 'SHA-256 of `notesText`. Recomputed by the worker; a mismatch is a 400' +) + +const notesRequestHash = sha256Hex( + 'SHA-256 of the sorted-key canonical JSON `{"context","notesText","refinement"}` (`refinement: null` when absent). Recomputed by the worker; a mismatch is a 400' +) + +const v1ImportFields = { + protocolVersion: z.literal(1).optional(), + keyId: z.string(), + challenge: z.string(), + assertion: z.string().meta({ + description: + 'Assertion over SHA-256 of `||`, or `|||` when `accountId` is sent.', + }), + contentHash: notesContentHash.optional(), +} + +const kickoffRequestSchema = z.union([ + z + .object({ ...importBaseFields, ...v1ImportFields }) + .meta({ title: 'App Attest v1 (legacy)' }), + z + .object({ + ...importBaseFields, + ...appAttestV2AssertionFields('notes-import-kickoff'), + contentHash: notesContentHash, + requestHash: notesRequestHash, + }) + .meta({ title: 'App Attest v2' }), + z + .object({ + ...importBaseFields, + ...playIntegrityFields('notes-import-kickoff'), + contentHash: notesContentHash, + requestHash: notesRequestHash, + }) + .meta({ title: 'Play Integrity' }), +]) + +const successSchema = z + .object({ + result: notesImportResultValidator.meta({ + id: 'NotesImportResult', + description: + 'The structured import, validated against the model’s output schema (`NOTES_IMPORT_SCHEMA` in `src/notesImport/schema.ts`). Field descriptions are the instructions the model receives.', + }), + contentHash: sha256Hex('SHA-256 of the imported `notesText`'), + refinement: z + .boolean() + .meta({ description: 'Whether this was a refinement.' }), + emptyCharged: z.boolean().meta({ + description: + 'The run produced no records and still used an import, because the free Empty Import allowance (5 per 7 days by default) was spent. Treat absent as `false`.', + }), + credits: creditsSchema, + }) + .meta({ + id: 'NotesImportSuccess', + description: + 'A finished import. The same shape on the legacy response, the `done` event, and `/result`.', + }) + +const importGateErrors = (legacy: boolean) => ({ + 400: niError( + legacy + ? 'Invalid JSON; missing `uuid`, `notesText`, or `context`; an invalid `accountId`; `contentHash` doesn’t match `notesText`; or an App Attest v2 / Play Integrity body (`reason: unsupported_protocol`).' + : 'Invalid JSON; missing `uuid`, `notesText`, or `context`; an invalid `accountId`; `contentHash` or `requestHash` doesn’t match the payload; or an invalid v2 / Play Integrity request (`reason: invalid_request`).', + ['bad_request', 'attestation_failed'] + ), + 401: niError( + `No App Attest credentials (\`attestation_required\`, \`action: bind\`), or attestation failed. ${ATTESTATION_NOTE}`, + ['attestation_required', 'attestation_failed', ...V1_CODES] + ), + 402: niError( + 'The import allowance for this window is used up. `credits` says when it resets; no `Retry-After`. Replays of already-imported content are free and never get this.', + ['limit_reached'] + ), + ...(legacy + ? {} + : { + 403: attestationResponses[403], + 409: attestationResponses[409], + }), + 413: niError('`notesText` is over the character limit.', ['too_large']), + 429: niError( + 'Too many requests from this IP (`rate_limited`, `Retry-After: 60`); this content’s refinement allowance is used up (`refinement_limit`, with `credits`, no `Retry-After`; it never resets); or this user already has the maximum imports running (`active_cap`, 2 free / 5 Supporter by default, `Retry-After: 15`).', + ['rate_limited', 'refinement_limit', 'active_cap'] + ), +}) + +const GATE_DESCRIPTION = `**Gate**, in order: the kill switch and provider health (503 \`unavailable\`), body checks, the content and request hashes, attestation (or the dev bypass), Supporter status from RevenueCat on \`accountId\` or \`uuid\` (an outage counts as free), then the allowance. Content already imported by this user replays without using an import; a refinement uses the per-content refinement allowance. An import is charged only when it succeeds.` + +export const kickoffDocs = describeRoute({ + tags: [TAG], + summary: 'Start a streaming import', + description: `The attested, metered entry point. Starts the model run in a per-import Durable Object and returns at once with an \`importId\` and a \`subscribeToken\` for \`GET /notes-import/{importId}/events\`, \`/result\`, \`/cancel\`, and \`/destroy\`. + +${GATE_DESCRIPTION} + +The \`importId\` is derived from the user and the content (plus the refinement instruction), so kicking off the same notes again reconnects to the same run instead of starting another; a run that failed restarts. The subscribe token lasts 1 hour, and a finished result is kept for 1 hour. + +\`credits\` is a point-in-time preview and may differ from the terminal snapshot on \`done\`.`, + parameters: [devBypassHeader], + requestBody: jsonBody(kickoffRequestSchema), + responses: { + 200: jsonResponse( + 'The run is started (or already running, or finished).', + z.object({ + importId: z + .string() + .regex(/^imp_[a-f0-9]{40}$/) + .meta({ + description: 'The run’s id, stable per user and content.', + }), + subscribeToken: z.string().meta({ + description: + 'Capability for this import’s stream, result, cancel, and destroy routes (`?token=`). Expires after 1 hour.', + }), + refinement: z.boolean(), + credits: creditsSchema, + }) + ), + ...importGateErrors(false), + 500: niError('The run could not be started.', ['server_error']), + 503: niError(`${killSwitchUnavailable}${storageUnavailable}`, [ + 'unavailable', + 'server_error', + ]), + }, +}) + +export const legacyImportDocs = describeRoute({ + tags: [TAG], + summary: 'Import notes synchronously (legacy)', + description: `The original path, kept as a fallback: holds the request open for the whole model run (often over a minute) and returns the result in one response. App Attest v1 only; v2 and Play Integrity bodies are refused (use \`POST /notes-import/kickoff\`). + +${GATE_DESCRIPTION} The same concurrency cap as kickoff applies.`, + deprecated: true, + parameters: [devBypassHeader], + requestBody: jsonBody( + z + .object({ ...importBaseFields, ...v1ImportFields }) + .meta({ title: 'App Attest v1' }) + ), + responses: { + 200: jsonResponse('The finished import.', successSchema), + ...importGateErrors(true), + 500: niError('Unexpected failure.', ['server_error']), + 502: niError('The model failed or returned an invalid result.', [ + 'model_error', + ]), + 503: niError(`${killSwitchUnavailable}${storageUnavailable}`, [ + 'unavailable', + 'server_error', + ]), + }, +}) + +// --- POST /notes-import/transcribe ----------------------------------------- + +const transcribeBaseFields = { + uuid: installId, + accountId, + image: z.string().meta({ + description: + 'The page photo, standard base64 with padding. Its first bytes must match `mimeType`. At most 4 MiB decoded by default (`capabilities.transcribe.maxBytes`).', + }), + mimeType: z.enum(['image/jpeg', 'image/png']), + contentHash: sha256Hex('SHA-256 of the `image` string as sent'), + requestHash: sha256Hex( + 'SHA-256 of the sorted-key canonical JSON `{"imageHash":,"mimeType":}`' + ), +} + +export const transcribeDocs = describeRoute({ + tags: [TAG], + summary: 'Read a photographed page', + description: `Import from Paper: reads one page image into plain text with a vision model on the zero-data-retention allowlist. Nothing is stored. The app shows the text for editing, then imports it through \`POST /notes-import/kickoff\`, which is where an import is used. + +App Attest v2 (purpose \`notes-import-transcribe\`) or Play Integrity only; v1 is a 400 with \`reason: unsupported_protocol\`. Gated by the kill switch. Pages are free but limited per user per rolling 24 hours (40 free, 200 Supporter by default), and a free user with no imports left is refused before any page is read.`, + parameters: [devBypassHeader], + requestBody: jsonBody( + z.union([ + z + .object({ + ...transcribeBaseFields, + ...appAttestV2AssertionFields('notes-import-transcribe'), + }) + .meta({ title: 'App Attest v2' }), + z + .object({ + ...transcribeBaseFields, + ...playIntegrityFields('notes-import-transcribe'), + }) + .meta({ title: 'Play Integrity' }), + ]) + ), + responses: { + 200: jsonResponse( + 'The page’s text.', + z.object({ + ok: z.literal(true), + text: z.string().meta({ description: 'The transcribed text.' }), + truncated: z.boolean().meta({ + description: 'The model hit its output limit before finishing.', + }), + pagesRemaining: z.number().int().meta({ + description: 'Pages left in the rolling 24 hours.', + }), + }) + ), + 400: niError( + 'Invalid JSON; missing `uuid` or `image`; an unsupported `mimeType`; an invalid `accountId`; an image that isn’t base64 of the declared type; a hash mismatch; App Attest v1 (`reason: unsupported_protocol`); or an invalid v2 / Play Integrity request.', + ['bad_request', 'attestation_failed'] + ), + 401: niError(`Attestation failed. ${ATTESTATION_NOTE}`, [ + 'attestation_failed', + ]), + 402: niError( + 'A free user with no imports left in this window (with `credits`).', + ['limit_reached'] + ), + 403: attestationResponses[403], + 409: attestationResponses[409], + 413: niError('The decoded image is over the byte limit.', ['too_large']), + 429: niError( + 'Too many requests from this IP (`rate_limited`, `Retry-After: 60`), or the daily page limit is reached (`transcribe_limit`, `Retry-After` until the oldest page ages out).', + ['rate_limited', 'transcribe_limit'] + ), + 500: niError('Unexpected failure.', ['server_error']), + 502: niError('The vision model couldn’t read the page.', ['model_error']), + 503: niError(`${killSwitchUnavailable}${storageUnavailable}`, [ + 'unavailable', + 'server_error', + ]), + }, +}) + +// --- Run routes (subscribe-token capability) --------------------------------- + +const importIdParameter = { + name: 'importId', + in: 'path' as const, + required: true, + schema: { type: 'string' as const }, + description: 'The `importId` from kickoff.', +} + +const tokenParameter = { + name: 'token', + in: 'query' as const, + required: true, + schema: { type: 'string' as const }, + description: + 'The `subscribeToken` from kickoff. It must have been issued for this `importId`; it expires after 1 hour.', +} + +const runAuthErrors = { + 400: niError('Missing `token`.', ['bad_request']), + 401: niError( + 'The token is unknown, expired, or for another import. Kick off again for a fresh one.', + ['attestation_required'] + ), + ...rateLimitedResponse, +} + +const RUN_AUTH = + 'Authorized by the kickoff’s `subscribeToken` instead of attestation: this route can’t spend inference.' + +const importStatus = z + .enum([ + 'queued', + 'starting', + 'thinking', + 'structuring', + 'done', + 'error', + 'cancelled', + ]) + .nullable() + .meta({ + description: + 'The run’s state. `null` means no run exists: it was destroyed, or its 1-hour retention passed.', + }) + +const failureSchema = z + .object({ + code: z.string().meta({ + description: + '`model_error` (the model failed or returned an invalid result), `interrupted` (the run stopped midway; kick off again), `server_error`, or `unknown`.', + }), + message: z.string(), + }) + .meta({ description: 'Why the run failed.' }) + +const snapshotSchema = z.object({ + status: importStatus, + payload: successSchema + .optional() + .meta({ description: 'Only when `status` is `done`.' }), + error: failureSchema + .optional() + .meta({ description: 'Only when `status` is `error`.' }), +}) + +export const eventsDocs = describeRoute({ + tags: [TAG], + summary: 'Stream import progress (SSE)', + description: `A Server-Sent Events stream of one run. ${RUN_AUTH} + +The stream replays the run’s persisted events after the resume cursor, then tails live until a terminal event, and closes. A run that has already finished (or doesn’t exist) replays and closes at once. Resume with the \`Last-Event-ID\` header (or \`lastEventId\` query parameter) set to the last \`id:\` seen; omit it to replay from the start. While live, a comment frame (\`:\`) every 15 seconds lets a client detect a dead connection. + +Each frame has an \`event:\` line naming the type and a \`data:\` line with the event as JSON (including \`type\`). Persisted events also carry \`id: \`; \`reasoning\` and \`progress\` frames are cosmetic, have no \`id:\`, and are not replayed. + +| \`event\` | \`data\` | Persisted | +| --- | --- | --- | +| \`status\` | \`{ "type": "status", "status": "starting" \\| "thinking" \\| "structuring" }\` | yes | +| \`reasoning\` | \`{ "type": "reasoning", "text": string }\`: a chunk of the model’s reasoning, when it emits any | no | +| \`progress\` | \`{ "type": "progress", "chars": number }\`: output length so far | no | +| \`done\` | \`{ "type": "done", "payload": }\` | yes, terminal | +| \`error\` | \`{ "type": "error", "code": "model_error" \\| "interrupted" \\| "server_error", "message": string }\` | yes, terminal | +| \`cancelled\` | \`{ "type": "cancelled" }\` (never charged) | yes, terminal | + +The stream is for showing progress; the result is read only from \`done\` or \`GET /notes-import/{importId}/result\`.`, + parameters: [ + importIdParameter, + tokenParameter, + { + name: 'Last-Event-ID', + in: 'header' as const, + required: false, + schema: { type: 'string' as const }, + description: 'Resume after this event `seq`.', + }, + { + name: 'lastEventId', + in: 'query' as const, + required: false, + schema: { type: 'string' as const }, + description: + 'The same cursor, for clients that can’t set headers. The header wins.', + }, + ], + responses: { + 200: { + description: + 'The event stream (`Content-Type: text/event-stream`, `Cache-Control: no-cache, no-transform`).', + content: { + 'text/event-stream': { + schema: { type: 'string' as const }, + example: + 'id: 1\nevent: status\ndata: {"type":"status","status":"starting"}\n\nevent: progress\ndata: {"type":"progress","chars":512}\n\nid: 3\nevent: done\ndata: {"type":"done","payload":{...}}\n\n', + }, + }, + }, + ...runAuthErrors, + }, +}) + +export const resultDocs = describeRoute({ + tags: [TAG], + summary: 'Get an import’s result', + description: `The run’s current state and, once finished, its result, for a client that reconnects after the stream closed or missed the terminal event. ${RUN_AUTH} Results are kept for 1 hour after the run ends.`, + parameters: [importIdParameter, tokenParameter], + responses: { + 200: jsonResponse('The run’s snapshot.', snapshotSchema), + ...runAuthErrors, + }, +}) + +export const cancelDocs = describeRoute({ + tags: [TAG], + summary: 'Cancel a running import', + description: `Stops a queued or running import: aborts the model call, frees the concurrency slot, and ends the stream with \`cancelled\`. A cancelled run is never charged. ${RUN_AUTH} + +No-op once the run has ended. If the run is already settling its result, cancel waits and returns \`done\` instead. Used by the app’s “edit & resend” flow.`, + parameters: [importIdParameter, tokenParameter], + responses: { + 200: jsonResponse('The run’s snapshot after the cancel.', snapshotSchema), + ...runAuthErrors, + }, +}) + +export const destroyDocs = describeRoute({ + tags: [TAG], + summary: 'Delete an import', + description: `Forgets a run now instead of after its retention: aborts any model call (uncharged), frees the concurrency slot, closes the stream, and deletes all stored state, the result included. Idempotent. ${RUN_AUTH} Backs the history view’s delete.`, + parameters: [importIdParameter, tokenParameter], + responses: { + 200: jsonResponse('Deleted.', z.object({ ok: z.literal(true) })), + ...runAuthErrors, + }, +}) diff --git a/src/openapi/coreDocs.ts b/src/openapi/coreDocs.ts new file mode 100644 index 0000000..72779d3 --- /dev/null +++ b/src/openapi/coreDocs.ts @@ -0,0 +1,224 @@ +import { z } from 'zod' +import { describeRoute } from 'hono-openapi' +import { + errorResponse, + jsonBody, + jsonResponse, + rateLimitedResponse, + serverErrorResponse, +} from './schemas' + +/** + * OpenAPI descriptions for the routes registered directly in `src/index.ts`. + * Feature route groups keep theirs in their own `openapi.ts`. + */ + +const html = (description: string) => ({ + description, + content: { 'text/html': { schema: { type: 'string' as const } } }, +}) + +export const healthDocs = describeRoute({ + tags: ['Service'], + summary: 'Health and server clock', + description: + 'Liveness plus the server clock. Clients calibrate against `Date` (and `serverTime`) before signing time-bound requests such as Buddies `ts`, so the response is never cached. No rate limit.', + responses: { + 200: jsonResponse( + 'The worker is up.', + z.object({ + status: z.literal('ok'), + timestamp: z.string().meta({ + description: 'Server time (ISO 8601).', + format: 'date-time', + }), + serverTime: z + .number() + .int() + .meta({ description: 'The same instant in epoch milliseconds.' }), + versionId: z + .string() + .meta({ description: 'Deployed Worker version id.' }), + versionTag: z + .string() + .optional() + .meta({ description: 'Version tag, when one was set at upload.' }), + deployedAt: z.string().meta({ + description: 'When this Worker version was uploaded (ISO 8601).', + format: 'date-time', + }), + }) + ), + }, +}) + +const hereQuery = (extra: string) => [ + { + name: 'q', + in: 'query' as const, + required: true, + schema: { type: 'string' as const }, + description: extra, + }, +] + +const hereResponses = { + 200: { + description: + 'HERE’s response body, passed through unchanged with HERE’s status.', + content: { 'application/json': { schema: { type: 'object' as const } } }, + }, + 400: errorResponse( + 'HERE rejected the query. HERE’s own error fields are kept beside the envelope.', + ['bad_request'] + ), + ...rateLimitedResponse, + 502: errorResponse('HERE was unreachable or answered with a non-JSON body.', [ + 'upstream_error', + ]), +} + +export const geocodeDocs = describeRoute({ + tags: ['Places'], + summary: 'Geocode an address', + description: + 'Proxies [HERE Geocode](https://www.here.com/docs/bundle/geocoding-and-search-api-developer-guide/page/topics-api/code-geocode-address.html). Every HERE query parameter is accepted; any `apiKey` the client sends is removed and replaced with the worker’s.', + parameters: hereQuery('The address to geocode.'), + responses: hereResponses, +}) + +export const autocompleteDocs = describeRoute({ + tags: ['Places'], + summary: 'Autocomplete an address', + description: + 'Proxies [HERE Autocomplete](https://www.here.com/docs/bundle/geocoding-and-search-api-developer-guide/page/topics/endpoint-autocomplete-brief.html). Every HERE query parameter (`limit`, `in`, `lang`, …) is accepted; any `apiKey` the client sends is removed and replaced with the worker’s.', + parameters: hereQuery('The partial address typed so far.'), + responses: hereResponses, +}) + +export const appStoreRatingsDocs = describeRoute({ + tags: ['App Store'], + summary: 'App Store rating totals', + description: + 'Paywall social proof: ratings summed across all 175 App Store storefronts. An hourly cron sweeps 40 storefronts a run and publishes a new summary once a day. Served from the edge cache (`s-maxage` 6 h).', + responses: { + 200: jsonResponse( + 'The latest completed sweep.', + z.object({ + averageRating: z.number().meta({ + description: + 'Rating-count-weighted mean across storefronts, rounded to 2 decimals.', + }), + ratingCount: z.number().int(), + countryCount: z + .number() + .int() + .meta({ description: 'Storefronts with at least one rating.' }), + updatedAt: z.string().meta({ format: 'date-time' }), + }) + ), + 503: errorResponse( + 'No sweep has completed yet (a fresh deploy). `Retry-After: 3600`.', + ['unavailable'] + ), + }, +}) + +export const aasaDocs = describeRoute({ + tags: ['Links'], + summary: 'Apple App Site Association', + description: + 'Tells iOS which paths open the app: `/c#…` (shared contacts), `/c/…` (contacts from older builds), and `/b#1…` (buddy invites), for the App Store, Beta, and development bundle ids.', + responses: { + 200: { + description: 'The AASA document.', + content: { + 'application/json': { + schema: { type: 'object' as const }, + example: { + applinks: { + details: [ + { + appIDs: ['TEAMID.com.leviwilkerson.jwtime'], + components: [ + { '/': '/c', '#': '?*' }, + { '/': '/c/*' }, + { '/': '/b', '#': '1?*' }, + ], + }, + ], + }, + }, + }, + }, + }, + ...serverErrorResponse, + }, +}) + +const langParameter = { + name: 'lang', + in: 'query' as const, + required: false, + schema: { type: 'string' as const }, + description: 'Page language. Falls back to `Accept-Language`, then English.', +} + +export const contactLinkDocs = describeRoute({ + tags: ['Links'], + summary: 'Shared contact fallback page', + description: + 'Shown when someone opens a shared-contact link without the app. The contact rides in the URL fragment (`/c#`), which never reaches the worker; the page’s script turns it into an “Open app” link.', + parameters: [langParameter], + responses: { 200: html('A localized landing page.') }, +}) + +export const legacyContactLinkDocs = describeRoute({ + tags: ['Links'], + summary: 'Shared contact fallback page (legacy)', + description: + 'The same page for links shared by older app versions, which put the payload in the path. The payload only ever goes into the “Open app” link, never into meta tags.', + parameters: [ + { + name: 'payload', + in: 'path' as const, + required: true, + schema: { type: 'string' as const }, + description: 'The base64url contact payload.', + }, + langParameter, + ], + responses: { 200: html('A localized landing page.') }, +}) + +export const buddyInvitePageDocs = describeRoute({ + tags: ['Links'], + summary: 'Buddy invite fallback page', + description: + 'Shown when someone opens a buddy invite (`/b#1`) without the app, with App Store and Google Play buttons. The invite secret stays in the fragment and never reaches the worker.', + responses: { 200: html('The invite landing page.') }, +}) + +export const notesImportAdminResetDocs = describeRoute({ + tags: ['Admin'], + summary: 'Reset a Notes Import meter', + description: + 'Resets one meter’s rolling allowance and its Empty Import rows, keeping permanent replay and refinement records. Used by `pnpm run admin:reset-usage`.', + security: [{ adminToken: [] }], + requestBody: jsonBody( + z.object({ + meterId: z + .string() + .regex(/^[A-Za-z0-9_-]{8,64}$/) + .meta({ + description: 'The account id, or the install id without an account.', + }), + }) + ), + responses: { + 200: jsonResponse('Reset.', z.object({ ok: z.literal(true) })), + 400: errorResponse('Invalid JSON or `meterId`.', ['bad_request']), + 404: errorResponse('Missing or wrong admin token.', ['not_found']), + ...rateLimitedResponse, + }, +}) diff --git a/src/openapi/openapi.test.ts b/src/openapi/openapi.test.ts new file mode 100644 index 0000000..c759be5 --- /dev/null +++ b/src/openapi/openapi.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('cloudflare:workers', () => ({ DurableObject: class {} })) + +import { app } from '../app' +import { OPENAPI_TAGS } from './route' + +const env = { + RATE_LIMITER: { limit: async () => ({ success: true }) }, +} as never + +const get = (path: string) => + app.fetch(new Request(`https://ww-proxy.leviwilkerson.com${path}`), env) + +type Operation = { + summary?: string + tags?: string[] + responses?: Record +} +type Spec = { + openapi: string + paths: Record> +} + +const loadSpec = async (): Promise => { + const response = await get('/openapi.json') + expect(response.status).toBe(200) + return response.json() +} + +/** Every method and path the app serves, in OpenAPI's `{param}` form. */ +const servedRoutes = () => + new Set( + app.routes + .filter((route) => route.method !== 'ALL') + .map( + (route) => `${route.method} ${route.path.replace(/:(\w+)/g, '{$1}')}` + ) + ) + +describe('OpenAPI document', () => { + it('documents every route the app serves', async () => { + const spec = await loadSpec() + const documented = new Set( + Object.entries(spec.paths).flatMap(([path, item]) => + Object.keys(item).map((method) => `${method.toUpperCase()} ${path}`) + ) + ) + expect( + [...servedRoutes()].filter((route) => !documented.has(route)) + ).toEqual([]) + }) + + it('gives every operation a known tag, a summary, and responses', async () => { + const spec = await loadSpec() + expect(spec.openapi).toBe('3.1.0') + const tags = new Set(OPENAPI_TAGS.map((tag) => tag.name)) + for (const [path, item] of Object.entries(spec.paths)) { + for (const [method, operation] of Object.entries(item)) { + const where = `${method.toUpperCase()} ${path}` + expect(operation.summary, where).toBeTruthy() + expect(operation.tags, where).toHaveLength(1) + expect(tags.has(operation.tags![0]), where).toBe(true) + expect(Object.keys(operation.responses ?? {}), where).not.toHaveLength( + 0 + ) + } + } + }) + + it('drops zod’s safe-integer bounds', async () => { + const spec = JSON.stringify(await loadSpec()) + expect(spec).not.toContain(String(Number.MAX_SAFE_INTEGER)) + }) + + it('serves the Scalar reference at /docs', async () => { + const response = await get('/docs') + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toContain('text/html') + expect(await response.text()).toContain('/openapi.json') + }) +}) diff --git a/src/openapi/route.ts b/src/openapi/route.ts new file mode 100644 index 0000000..1647515 --- /dev/null +++ b/src/openapi/route.ts @@ -0,0 +1,174 @@ +import type { Hono } from 'hono' +import { Scalar } from '@scalar/hono-api-reference' +import { describeRoute, generateSpecs } from 'hono-openapi' +import type { Environment } from '../types' +import { componentSchemaObjects } from './schemas' + +export const OPENAPI_PATH = '/openapi.json' +export const DOCS_PATH = '/docs' + +const REPO_URL = 'https://github.com/leviFrosty/ww-api' + +/** Tag order and blurbs; every documented route uses one of these. */ +export const OPENAPI_TAGS = [ + { + name: 'Service', + description: 'Liveness, the server clock, and these docs.', + }, + { + name: 'Places', + description: + 'HERE Geocoding & Search proxy. The worker injects its own `apiKey` and forwards every other query parameter unchanged.', + }, + { + name: 'Notes Import', + description: + 'Turns pasted or photographed notes into contacts, visits, and time entries with a model. Every metered call is authenticated with App Attest (iOS) or Play Integrity (Android). Design: [docs/notes-import-streaming-durable-objects.md](' + + REPO_URL + + '/blob/main/docs/notes-import-streaming-durable-objects.md).', + }, + { + name: 'Buddies', + description: + 'End-to-end encrypted relay between buddies. The relay only sees ciphertext and random ids. Every op is `POST /buddies/v1/{op}` with a signed envelope; ids travel in bodies, never URLs. The wire contract is [docs/buddies-protocol.md](' + + REPO_URL + + '/blob/main/docs/buddies-protocol.md).', + }, + { + name: 'Accounts', + description: + 'Anonymous accounts: a random id and a public key, nothing else. Signed like Buddies ops under the `ww-account/v1` context.', + }, + { + name: 'Route planning', + description: + 'Supporter-only stop ordering through HERE Waypoints Sequence. Coordinates only.', + }, + { + name: 'App Store', + description: 'Public App Store data for the paywall.', + }, + { + name: 'Links', + description: + 'Universal-link support: the AASA file and the no-app fallback pages for shared contacts and buddy invites.', + }, + { + name: 'Admin', + description: + 'Maintainer tools. Each needs `x-ww-admin-token`; without a valid token every admin route answers 404.', + }, +] + +const DOCUMENTATION = { + info: { + title: 'WitnessWork API', + version: '1', + description: `The backend for the [WitnessWork](https://github.com/leviFrosty/witness-work) app: a Cloudflare Worker built with Hono. Source: [ww-api](${REPO_URL}). + +**Errors.** Every JSON error uses one envelope: \`{ "ok": false, "error", "code", "retryAfter"? }\`. Read \`code\`; \`error\` repeats it, except on Notes Import and route planning, where it is a human-readable message. Every 429 and 503 sends \`Retry-After\`. + +**Rate limits.** Places, Notes Import, route planning, and admin routes each count in their own per-IP bucket of 60 requests a minute. Buddies has its own per-caller tiers, documented on each op.`, + license: { name: 'See repository', url: REPO_URL }, + }, + servers: [ + { url: 'https://ww-proxy.leviwilkerson.com', description: 'Production' }, + { + url: 'https://ww-proxy-dev.levi-wilkerson.workers.dev', + description: 'Development', + }, + ], + tags: OPENAPI_TAGS, + components: { + securitySchemes: { + adminToken: { + type: 'apiKey' as const, + in: 'header', + name: 'x-ww-admin-token', + description: 'The environment’s `ADMIN_API_TOKEN` secret.', + }, + }, + }, +} + +type App = Hono<{ Bindings: Environment }> + +/** The OpenAPI document for `app`. */ +/** + * Zod's `.int()` emits JavaScript's safe-integer range as `minimum`/`maximum`, + * which Scalar then shows as the example value. Those bounds say nothing a + * reader needs, so they're dropped. + */ +const dropSafeIntegerBounds = (value: T): T => + JSON.parse(JSON.stringify(value), (key, field) => + (key === 'minimum' && field === Number.MIN_SAFE_INTEGER) || + (key === 'maximum' && field === Number.MAX_SAFE_INTEGER) + ? undefined + : field + ) + +export const buildOpenApiSpec = async (app: App) => + dropSafeIntegerBounds( + await generateSpecs(app, { + documentation: { + ...DOCUMENTATION, + components: { + ...DOCUMENTATION.components, + schemas: componentSchemaObjects(), + }, + }, + // `/openapi.json` itself ends in `.json`; nothing here is a static file. + excludeStaticFile: false, + }) + ) + +/** `buildOpenApiSpec`, run once per isolate (a failure retries next time). */ +const specFor = (app: App) => { + let spec: ReturnType | undefined + return () => { + spec ??= buildOpenApiSpec(app).catch((error) => { + spec = undefined + throw error + }) + return spec + } +} + +/** + * Mounts `/openapi.json` (the document) and `/docs` (Scalar's reference UI) + * on `app`. Call after every other route, so all of them are in the document. + */ +export const mountApiDocs = (app: App) => { + const spec = specFor(app) + app.get( + OPENAPI_PATH, + describeRoute({ + tags: ['Service'], + summary: 'OpenAPI document', + description: + 'This API’s OpenAPI 3.1 document, generated from the routes the worker serves.', + responses: { 200: { description: 'The OpenAPI document.' } }, + }), + async (c) => + c.json(await spec(), 200, { 'Cache-Control': 'public, max-age=300' }) + ) + app.get( + DOCS_PATH, + describeRoute({ + tags: ['Service'], + summary: 'API reference', + description: 'This reference, rendered by Scalar from `/openapi.json`.', + responses: { 200: { description: 'An HTML page.' } }, + }), + Scalar({ + url: OPENAPI_PATH, + pageTitle: 'WitnessWork API', + layout: 'modern', + // No hosted AI chat, telemetry, or SDK upsell toolbar on our page. + agent: { disabled: true }, + telemetry: false, + showDeveloperTools: 'never', + }) + ) + return app +} diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts new file mode 100644 index 0000000..5713ece --- /dev/null +++ b/src/openapi/schemas.ts @@ -0,0 +1,220 @@ +import { z } from 'zod' +import { resolver, type DescribeRouteOptions } from 'hono-openapi' + +/** + * Building blocks for the OpenAPI document served at `/openapi.json` and + * rendered by Scalar at `/docs`. These schemas describe the wire format for + * readers; they don't validate anything. Each handler keeps its own parsing, + * so when a contract changes, update its `openapi.ts` beside it. + */ + +type Responses = NonNullable +type Schema = z.ZodType + +/** A JSON body or response. */ +export const json = (schema: Schema) => ({ + 'application/json': { schema: resolver(schema) }, +}) + +/** A response with a JSON body. */ +export const jsonResponse = (description: string, schema: Schema) => ({ + description, + content: json(schema), +}) + +/** A required JSON request body. */ +export const jsonBody = (schema: Schema, description?: string) => ({ + required: true, + description, + content: json(schema), +}) + +/** The shared error envelope (`src/errors.ts`). */ +export const apiErrorSchema = z + .object({ + ok: z.literal(false), + error: z.string().meta({ + description: + 'The same as `code`, except on Notes Import and route planning, where it is a human-readable message.', + }), + code: z + .string() + .meta({ description: 'Stable, machine-readable code. Read this first.' }), + retryAfter: z.number().int().positive().optional().meta({ + description: + 'Seconds; repeats the `Retry-After` header. Sent on every 429 and 503 except quotas a wait cannot free.', + }), + }) + .loose() + .meta({ + id: 'ApiError', + description: + 'Every JSON error. Routes may add their own fields (`reason`, `action`, …).', + }) + +/** Schemas with a `.meta({ id })` that responses refer to by `$ref`. */ +const componentSchemas = new Map() + +/** + * `#/components/schemas/` for a schema with a `.meta({ id })`, which + * `componentSchemaObjects` then publishes once. Error bodies repeat on nearly + * every response, and hono-openapi inlines a schema used as a whole body. + */ +const schemaRef = (schema: Schema) => { + const id = z.globalRegistry.get(schema)?.id + if (!id) return resolver(schema) + componentSchemas.set(id, schema) + return { $ref: `#/components/schemas/${id}` } +} + +/** The JSON Schemas behind every `schemaRef`, for `components.schemas`. */ +export const componentSchemaObjects = () => { + const objects: Record = {} + for (const [id, schema] of componentSchemas) { + const { + $schema: _, + $defs, + ...object + } = z.toJSONSchema(schema, { + unrepresentable: 'any', + }) as Record + Object.assign(objects, $defs, { [id]: object }) + } + // Nested ids land in `$defs`; they're published beside their parents. + return JSON.parse( + JSON.stringify(objects).replaceAll('"#/$defs/', '"#/components/schemas/') + ) as Record +} + +/** + * An error response in the shared envelope. `codes` lists the `code` values + * this status can carry on this route; `schema` overrides the body when the + * route adds fields (give it a `.meta({ id })` to publish it once). + */ +export const errorResponse = ( + description: string, + codes?: readonly string[], + schema: Schema = apiErrorSchema +) => ({ + description: codes?.length + ? `${description}\n\n\`code\`: ${codes.map((code) => `\`${code}\``).join(', ')}` + : description, + content: { 'application/json': { schema: schemaRef(schema) } }, +}) + +/** The error envelope plus `serverTime`, for ops that can answer `stale`. */ +export const staleErrorSchema = apiErrorSchema + .extend({ + serverTime: z.number().int().optional().meta({ + description: + 'On `stale` only: the server clock in epoch ms, to correct the offset and re-sign.', + }), + }) + .meta({ id: 'StaleError' }) + +/** The per-IP limit every non-Buddies family shares (`src/rateLimit.ts`). */ +export const rateLimitedResponse: Responses = { + 429: errorResponse( + 'Too many requests from this IP in the last minute. `Retry-After: 60`.', + ['rate_limited'] + ), +} + +/** The catch-all for an unexpected failure. */ +export const serverErrorResponse: Responses = { + 500: errorResponse('Unexpected failure.', ['server_error']), +} + +/** A base64url string without padding. */ +export const b64u = (description: string) => + z + .string() + .regex(/^[A-Za-z0-9_-]*$/) + .meta({ description: `${description} (base64url, no padding)` }) + +/** The Buddies relay's 22-character random ids (16 bytes, base64url). */ +export const buddiesId = (description: string) => + z + .string() + .regex(/^[A-Za-z0-9_-]{22}$/) + .meta({ description: `${description} (22-character base64url id)` }) + +/** + * The signed envelope Buddies and accounts ops send: `p` is the payload JSON, + * `s` an Ed25519 signature over the op's signing context, the op, and `p`'s + * bytes. + */ +export const signedEnvelopeSchema = z + .object({ + p: b64u('The payload JSON, UTF-8 encoded'), + s: b64u( + 'Ed25519 signature over `\\n\\n` followed by the decoded `p` bytes' + ), + }) + .strict() + +type JsonSchema = { + type?: string | string[] + const?: unknown + enum?: unknown[] + description?: string + properties?: Record + required?: string[] + items?: JsonSchema + anyOf?: JsonSchema[] + oneOf?: JsonSchema[] +} + +const typeLabel = (schema: JsonSchema): string => { + if (schema.const !== undefined) return `\`${JSON.stringify(schema.const)}\`` + if (schema.enum) + return schema.enum.map((v) => `\`${JSON.stringify(v)}\``).join(' \\| ') + const variants = schema.anyOf ?? schema.oneOf + if (variants) return variants.map(typeLabel).join(' \\| ') + if (schema.type === 'array' && schema.items) + return `${typeLabel(schema.items)}[]` + return Array.isArray(schema.type) + ? schema.type.join(' \\| ') + : (schema.type ?? 'any') +} + +const fieldRows = (schema: JsonSchema, prefix = ''): string[] => + Object.entries(schema.properties ?? {}).flatMap(([name, field]) => { + const path = `${prefix}${name}` + const required = schema.required?.includes(name) ? 'yes' : '' + const description = (field.description ?? '') + .replace(/\n+/g, ' ') + .replace(/\|/g, '\\|') + const row = `| \`${path}\` | ${typeLabel(field)} | ${required} | ${description} |` + const nested = field.type === 'array' ? field.items : field + return nested?.properties + ? [ + row, + ...fieldRows(nested, `${path}${field.type === 'array' ? '[]' : ''}.`), + ] + : [row] + }) + +/** A markdown table of an object schema's fields, nested ones by dotted path. */ +export const fieldTable = (schema: Schema): string => + [ + '| Field | Type | Required | Description |', + '| --- | --- | --- | --- |', + ...fieldRows( + z.toJSONSchema(schema, { + unrepresentable: 'any', + io: 'input', + }) as JsonSchema + ), + ].join('\n') + +/** + * A signed envelope whose decoded `p` must match `payload`. The payload's + * fields are listed as a table in the body's description, since the + * envelope hides them inside base64. + */ +export const signedBody = (payload: Schema, description: string) => + jsonBody( + signedEnvelopeSchema, + `${description}\n\n**Decoded \`p\`:**\n\n${fieldTable(payload)}` + ) diff --git a/src/routePlanning/openapi.ts b/src/routePlanning/openapi.ts new file mode 100644 index 0000000..e4ccf59 --- /dev/null +++ b/src/routePlanning/openapi.ts @@ -0,0 +1,77 @@ +import { z } from 'zod' +import { describeRoute } from 'hono-openapi' +import { + apiErrorSchema, + errorResponse, + jsonBody, + jsonResponse, +} from '../openapi/schemas' +import { ROUTE_PLANNING_LIMITS as LIMITS } from './config' +import { routeOptimizeRequestSchema } from './contracts' + +const errorSchema = apiErrorSchema + .extend({ + retryAfterSeconds: z.number().int().positive().optional().meta({ + description: 'The same as `retryAfter`, kept for shipped builds.', + }), + }) + .meta({ id: 'RoutePlanningError' }) + +const error = (description: string, codes: string[]) => + errorResponse(description, codes, errorSchema) + +export const routeOptimizeDocs = describeRoute({ + tags: ['Route planning'], + summary: 'Order today’s stops', + description: `The shortest driving order from \`start\` through every stop, with traffic at request time, from HERE Waypoints Sequence. The route ends at the last stop. + +Supporter only: \`accountId\` (the RevenueCat app user id) must hold the Supporter entitlement, and a failed check never reaches HERE. Limits: ${LIMITS.maxStops} stops per request; ${LIMITS.dailyLimit} optimizations per account in any rolling 24 hours and ${LIMITS.minuteLimit} per minute (every call that reaches HERE counts, \`no_route\` included); plus the per-IP limit. The kill switch (KV \`route-planning:enabled\` = \`"false"\`) answers 503 \`unavailable\`. + +On the dev worker only, \`x-ww-dev-bypass\` skips the Supporter check.`, + parameters: [ + { + name: 'x-ww-dev-bypass', + in: 'header', + required: false, + schema: { type: 'string' }, + description: 'Development worker only: skips the Supporter check.', + }, + ], + requestBody: jsonBody( + routeOptimizeRequestSchema, + `Coordinates only; any other field is rejected. At most ${LIMITS.maxBodyBytes} bytes.` + ), + responses: { + 200: jsonResponse( + 'The visiting order.', + z.object({ + order: z.array(z.number().int().min(0)).meta({ + description: 'Indices into the request’s `stops`, in visiting order.', + }), + distanceMeters: z.number().int().meta({ + description: 'Driving distance from the start through the last stop.', + }), + durationSeconds: z + .number() + .int() + .meta({ description: 'Driving time for the same, with traffic.' }), + remainingToday: z.number().int().meta({ + description: 'Optimizations left in the rolling 24 hours.', + }), + }) + ), + 400: error('Invalid JSON or request.', ['bad_request']), + 403: error('The account isn’t a Supporter.', ['supporter_required']), + 413: error('Body too large.', ['payload_too_large']), + 422: error('No driving route connects these stops.', ['no_route']), + 429: error( + 'Per-account daily or per-minute limit, or the per-IP limit. `daily_limit` waits until the oldest optimization ages out.', + ['daily_limit', 'rate_limited'] + ), + 502: error('HERE was unavailable.', ['upstream_error']), + 503: error( + 'Turned off, or the Supporter check failed (RevenueCat trouble; fails closed).', + ['unavailable', 'supporter_check_failed'] + ), + }, +}) diff --git a/src/routePlanning/route.ts b/src/routePlanning/route.ts index 9dd78bc..96a9bf5 100644 --- a/src/routePlanning/route.ts +++ b/src/routePlanning/route.ts @@ -17,6 +17,7 @@ import { type RouteOptimizeResponse, } from './contracts' import { fetchWaypointsSequence } from './here' +import { routeOptimizeDocs } from './openapi' /** * `POST /route-planning/optimize` — Supporter-only shortest driving order for @@ -75,7 +76,7 @@ export const createRoutePlanningRoutes = ( const deps = { ...defaultDependencies, ...overrides } const routes = new Hono<{ Bindings: Environment }>() - routes.post('/optimize', async (ctx) => { + routes.post('/optimize', routeOptimizeDocs, async (ctx) => { if (!(await isRoutePlanningEnabled(ctx.env))) { return fail( ctx,