From 1c4ab6d524129a07c90c2bf7fe436a4d264215cc Mon Sep 17 00:00:00 2001 From: KopitarFan <85317276+KopitarFan@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:14:15 -0700 Subject: [PATCH] Added OpenAPI docs and more comments --- README.md | 1 + docs/server-production-runbook.md | 4 + docs/server-rest-api.md | 422 ++++++++++++++++++++++ server/README.md | 22 +- server/package.json | 2 + server/pnpm-lock.yaml | 186 +++++++++- server/src/app.ts | 6 + server/src/cloud-save-package.ts | 6 + server/src/cloud-save-routes.ts | 4 + server/src/local-cloud-save-store.ts | 5 + server/src/openapi.ts | 518 +++++++++++++++++++++++++++ server/test/docs.test.ts | 58 +++ 12 files changed, 1222 insertions(+), 12 deletions(-) create mode 100644 docs/server-rest-api.md create mode 100644 server/src/openapi.ts create mode 100644 server/test/docs.test.ts diff --git a/README.md b/README.md index c5db7a6..5155c2b 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ The core paradigm is inverted from client-server: - Tests: `test/` - Product milestones: `docs/milestones.md` - Architecture notes: `docs/local-first-architecture.md` +- Server REST API contract: `docs/server-rest-api.md` - Server runbook: `docs/server-production-runbook.md` - Observability options: `docs/observability-options.md` - iOS release packaging: `docs/release-packaging.md` diff --git a/docs/server-production-runbook.md b/docs/server-production-runbook.md index 37c02e9..e7cedd1 100644 --- a/docs/server-production-runbook.md +++ b/docs/server-production-runbook.md @@ -4,10 +4,14 @@ Last updated: 2026-06-26 This runbook covers the All Of Me cloud-save API running on Vultr. +For request and response details, see the REST API contract in +`docs/server-rest-api.md`. + ## Production Topology - Public API: `https://api.allofmeapp.com` - Health check: `https://api.allofmeapp.com/healthz` +- Swagger UI: `https://api.allofmeapp.com/docs` - Container image: `ghcr.io/kopitarfan/all-of-me-server:latest` - Host: Vultr Ubuntu instance - Reverse proxy: Caddy diff --git a/docs/server-rest-api.md b/docs/server-rest-api.md new file mode 100644 index 0000000..8d20ea6 --- /dev/null +++ b/docs/server-rest-api.md @@ -0,0 +1,422 @@ +# Server REST API + +Last updated: 2026-06-29 + +This document is the contract for the All Of Me Cloud Save REST API. The API +exists to hold encrypted restore points for the Flutter app. The current device +remains the source of truth, and the server never decrypts cloud-save payloads +or becomes the canonical owner of app data. + +## Base URLs + +- Production: `https://api.allofmeapp.com/` +- Local development: `http://127.0.0.1:3000/` + +All paths below are relative to the base URL. + +Interactive Swagger docs are served at `/docs`. The generated OpenAPI document +is available as JSON at `/docs/json` and YAML at `/docs/yaml`. + +## Design Boundaries + +- Cloud Save is backup and restore, not live multi-device sync. +- Bearer tokens identify devices. An account is an internal grouping for linked + devices, not an email/password user account. +- The server stores token hashes, save metadata, and encrypted + `CloudSavePackage` JSON files. +- The server validates the package envelope, payload size, base64 encoding, and + payload checksum, but it does not decrypt or inspect member data, notes, + profile images, or recovery keys. +- Save reads are always account-scoped from the authenticated device. +- Save lists are returned newest-first. +- Each account keeps at most `CLOUD_SAVE_MAX_VERSIONS` saved versions. The + default is `5`. + +## Request Conventions + +- Send JSON request bodies with `Content-Type: application/json`. +- Send `Accept: application/json` when the client expects JSON. +- Dates are ISO 8601 strings. +- Authenticated endpoints require: + +```http +Authorization: Bearer +``` + +- Clients may send `X-Request-ID` with `1-128` characters from + `A-Z`, `a-z`, `0-9`, `.`, `_`, `:`, or `-`. The server echoes the accepted + request ID in responses. If the header is missing or invalid, the server + generates a request ID. + +## Error Responses + +Errors use one JSON shape: + +```json +{ + "statusCode": 400, + "error": "Bad Request", + "message": "Cloud save package is invalid.", + "errorId": "err_00000000-0000-0000-0000-000000000000", + "requestId": "req_00000000-0000-0000-0000-000000000000" +} +``` + +The server also sends `X-Request-ID` and `X-Error-ID` headers. Client-facing +support screens should show `errorId` when present, then `requestId` as a +fallback. + +Common statuses: + +| Status | Meaning | +| --- | --- | +| `200` | Request succeeded. | +| `201` | Resource created or save stored. | +| `400` | Request JSON, route parameter, or cloud-save envelope is invalid. | +| `401` | Bearer token is missing or invalid, or a link code is expired/reused. | +| `404` | The requested save does not exist in this account. | +| `413` | The decoded cloud-save payload exceeds `CLOUD_SAVE_MAX_PAYLOAD_BYTES`. | +| `429` | A rate limit was exceeded. Check `Retry-After`. | +| `500` | Unexpected server error. The response message is intentionally generic. | + +## Endpoint Reference + +### `GET /healthz` + +Checks whether the API process is up. + +Auth: none. + +Rate limit: disabled. + +Response: + +```json +{ + "ok": true +} +``` + +### `POST /v1/devices/register` + +Creates a new internal account and registers the first device. The bearer token +is returned once. Store it in the app's secure token store. + +Auth: none. + +Request: + +```json +{ + "deviceLabel": "Miguel iPhone" +} +``` + +`deviceLabel` is optional and must be `1-100` trimmed characters when present. + +Response `201`: + +```json +{ + "accountId": "account-1782264000000-abcd1234", + "deviceId": "device-1782264000000-abcd1234", + "deviceLabel": "Miguel iPhone", + "token": "aom_redacted", + "tokenType": "Bearer" +} +``` + +Notes: + +- The server stores only a hash of `token`. +- Re-registration creates a new account. Use device link codes to attach a new + device to an existing account. + +### `POST /v1/devices/link-codes` + +Creates a short-lived one-time code that another device can redeem into a token +for the same account. + +Auth: bearer token required. + +Request: + +```json +{} +``` + +Response `201`: + +```json +{ + "code": "AOM-12345-ABCDE", + "expiresAt": "2026-06-24T12:10:00.000Z" +} +``` + +Notes: + +- The default code lifetime is `DEVICE_LINK_CODE_TTL_MS`, currently + `600000` ms. +- Codes are stored hashed, are single-use, and cannot be listed back from the + API. + +### `POST /v1/devices/link` + +Redeems a valid link code and returns a bearer token for a new device in the +same account. + +Auth: none. The link code is the short-lived credential. + +Request: + +```json +{ + "code": "AOM-12345-ABCDE", + "deviceLabel": "Miguel iPad" +} +``` + +`code` is required. The server accepts lowercase input and ignores spaces or +hyphens during normalization. `deviceLabel` is optional and must be `1-100` +trimmed characters when present. + +Response `201`: + +```json +{ + "accountId": "account-1782264000000-abcd1234", + "deviceId": "device-1782264600000-efgh5678", + "deviceLabel": "Miguel iPad", + "token": "aom_redacted", + "tokenType": "Bearer" +} +``` + +### `POST /v1/saves` + +Stores one encrypted cloud-save package for the authenticated account. + +Auth: bearer token required. + +Request: a `CloudSavePackage`. + +Response `201`: the package metadata. + +```json +{ + "saveId": "cloud-save-1782264000000000", + "createdAt": "2026-06-23T18:40:00.000Z", + "appName": "All Of Me", + "appVersion": "1.0.0+10", + "snapshotSchemaVersion": 3, + "deviceLabel": "Miguel iPhone", + "payloadByteCount": 123456, + "payloadChecksum": "fnv1a32:1234abcd" +} +``` + +Validation: + +- The JSON object is strict. Unknown fields are rejected. +- `formatVersion` must be `1`. +- `payload.encoding` must be `base64`. +- `payload.compression` must be `none`. +- `payload.encryption.algorithm` must be `xchacha20-poly1305`. +- `payload.encryption.keyDerivationAlgorithm` must be + `pbkdf2-hmac-sha256`. +- `payload.data`, `nonceBase64`, `saltBase64`, and `macBase64` must be + canonical base64 with standard `+` and `/` characters and padding. +- Decoded `payload.data` length must match `metadata.payloadByteCount`. +- Decoded `payload.data` must be at or below + `CLOUD_SAVE_MAX_PAYLOAD_BYTES`. +- `metadata.payloadChecksum` must match the server's FNV-1a checksum over the + decoded encrypted payload bytes. +- `nonceBase64` must decode to `24` bytes. +- `saltBase64` must decode to `16` bytes. +- `macBase64` must decode to `16` bytes. + +Storage behavior: + +- `saveId` is unique per account. Uploading the same `saveId` again replaces + that account's previous copy. +- Retention is enforced after each successful upload. +- The stored package remains encrypted and opaque to the server. + +### `GET /v1/saves` + +Lists saved-version metadata for the authenticated account. + +Auth: bearer token required. + +Response `200`: + +```json +[ + { + "saveId": "cloud-save-1782264000000000", + "createdAt": "2026-06-23T18:40:00.000Z", + "appName": "All Of Me", + "appVersion": "1.0.0+10", + "snapshotSchemaVersion": 3, + "deviceLabel": "Miguel iPhone", + "payloadByteCount": 123456, + "payloadChecksum": "fnv1a32:1234abcd" + } +] +``` + +The array is empty when the account has no saves. Ordering is newest-first by +`createdAt`, then by server storage time, then by `saveId`. + +### `GET /v1/saves/latest` + +Downloads the newest saved package for the authenticated account. + +Auth: bearer token required. + +Response `200`: a `CloudSavePackage`. + +Response `404`: no save exists for this account. + +### `GET /v1/saves/:saveId` + +Downloads one saved package by ID for the authenticated account. + +Auth: bearer token required. + +Response `200`: a `CloudSavePackage`. + +Response `400`: `saveId` has invalid characters or length. + +Response `404`: the save does not exist in this account. + +Valid save IDs are `1-128` characters, start with an alphanumeric character, +and may contain alphanumeric characters plus `.`, `_`, `:`, or `-`. + +## CloudSavePackage Schema + +```json +{ + "formatVersion": 1, + "metadata": { + "saveId": "cloud-save-1782264000000000", + "createdAt": "2026-06-23T18:40:00.000Z", + "appName": "All Of Me", + "appVersion": "1.0.0+10", + "snapshotSchemaVersion": 3, + "deviceLabel": "Miguel iPhone", + "payloadByteCount": 123456, + "payloadChecksum": "fnv1a32:1234abcd" + }, + "payload": { + "encoding": "base64", + "compression": "none", + "encryption": { + "algorithm": "xchacha20-poly1305", + "keyDerivationAlgorithm": "pbkdf2-hmac-sha256", + "keyDerivationIterations": 120000, + "keyLengthBits": 256, + "keyId": "passphrase-recovery-key-v1", + "nonceBase64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "saltBase64": "AAAAAAAAAAAAAAAAAAAAAA==", + "macBase64": "AAAAAAAAAAAAAAAAAAAAAA==" + }, + "data": "BASE64_ENCRYPTED_PAYLOAD" + } +} +``` + +Field constraints: + +| Field | Constraint | +| --- | --- | +| `formatVersion` | Literal `1`. | +| `metadata.saveId` | Valid save ID, `1-128` characters. | +| `metadata.createdAt` | Parseable date string. | +| `metadata.appName` | `1-100` trimmed characters. | +| `metadata.appVersion` | Optional, `1-50` trimmed characters. | +| `metadata.snapshotSchemaVersion` | Positive integer. | +| `metadata.deviceLabel` | Optional, `1-100` trimmed characters. | +| `metadata.payloadByteCount` | Positive integer. | +| `metadata.payloadChecksum` | `fnv1a32:` followed by eight lowercase hex digits. | +| `payload.encoding` | Literal `base64`. | +| `payload.compression` | Literal `none`. | +| `payload.encryption.algorithm` | Literal `xchacha20-poly1305`. | +| `payload.encryption.keyDerivationAlgorithm` | Literal `pbkdf2-hmac-sha256`. | +| `payload.encryption.keyDerivationIterations` | Positive integer. | +| `payload.encryption.keyLengthBits` | Positive integer. | +| `payload.encryption.keyId` | `1-128` trimmed characters. | +| `payload.encryption.nonceBase64` | Canonical base64 string that decodes to `24` bytes. | +| `payload.encryption.saltBase64` | Canonical base64 string that decodes to `16` bytes. | +| `payload.encryption.macBase64` | Canonical base64 string that decodes to `16` bytes. | +| `payload.data` | Canonical base64 string. | + +## Client Flows + +### First Device + +1. `POST /v1/devices/register` +2. Store the returned bearer token securely on device. +3. `POST /v1/saves` whenever the user chooses to make a cloud restore point. + +### Add Another Device + +1. Existing device calls `POST /v1/devices/link-codes`. +2. User enters the code on the new device. +3. New device calls `POST /v1/devices/link`. +4. New device stores the returned bearer token securely. +5. New device can list or download account-scoped saves. + +### Restore + +1. Client calls `GET /v1/saves` or `GET /v1/saves/latest`. +2. Client downloads a package with `GET /v1/saves/latest` or + `GET /v1/saves/:saveId`. +3. Client validates checksum, decrypts locally, and asks the user before + replacing local data. + +The restore flow should preserve the product model: cloud copies are manual +restore points, and the local device remains the source of truth. + +## Rate Limits + +Defaults for the single-container production service: + +| Scope | Default | +| --- | --- | +| Global `/v1` requests per client IP | `300` per `60000` ms | +| Device registration per client IP | `5` per `900000` ms | +| Device link-code redemption per client IP | `5` per `900000` ms | +| Save upload per bearer token | `30` per `60000` ms | + +`GET /healthz` is not rate limited. Production runs behind Caddy with +`TRUST_PROXY=true`, so Docker must keep the API bound to `127.0.0.1:3000` +rather than exposing port `3000` publicly. + +Link-code creation uses the global limit because it already requires a valid +bearer token. + +The rate-limit store is in memory and matches the current one-container +deployment. Use a shared store such as Redis before running multiple API +containers. + +## Persistence + +The default store is `CLOUD_SAVE_STORE=local`. + +- Metadata and auth records live in SQLite at + `CLOUD_SAVE_DATA_DIR/cloud-saves.sqlite`. +- Encrypted package JSON files live under + `CLOUD_SAVE_DATA_DIR/packages//.json`. +- Package files are written to a temporary file and then atomically renamed. +- Admin commands report metadata only and never decrypt package contents. + +Tests can use `CLOUD_SAVE_STORE=memory` or inject explicit stores. + +## Related Docs + +- [Server README](../server/README.md) +- [Production runbook](server-production-runbook.md) +- [Local-first architecture](local-first-architecture.md) +- [Observability options](observability-options.md) diff --git a/server/README.md b/server/README.md index ba7a706..ecfd652 100644 --- a/server/README.md +++ b/server/README.md @@ -2,6 +2,22 @@ Node.js and Fastify API for optional All Of Me cloud saves. +## REST API Contract + +The service exposes the All Of Me Cloud Save REST API. The full request, +response, validation, auth, and error contract lives in +[`docs/server-rest-api.md`](../docs/server-rest-api.md). + +The important product boundary is that the API stores encrypted restore points. +It validates the `CloudSavePackage` envelope and metadata, but it never decrypts +or inspects app data. The current device remains the source of truth. + +Interactive Swagger docs are served by the API: + +- Swagger UI: `http://127.0.0.1:3000/docs` +- OpenAPI JSON: `http://127.0.0.1:3000/docs/json` +- OpenAPI YAML: `http://127.0.0.1:3000/docs/yaml` + ## Local Development This service uses pnpm. @@ -120,7 +136,7 @@ ALLOFME_SERVER_IMAGE=all-of-me-server:local \ docker compose --env-file .env.production -f docker-compose.production.yml up -d ``` -## Current Endpoints +## Endpoint Summary - `GET /healthz` returns `{ "ok": true }`. - `POST /v1/devices/register` creates a new account/device pair and returns a @@ -139,7 +155,3 @@ ALLOFME_SERVER_IMAGE=all-of-me-server:local \ `CloudSavePackage` for the authenticated account. - `GET /v1/saves/:saveId` requires bearer auth and returns one saved `CloudSavePackage` for the authenticated account. - -The cloud-save API will build on the Flutter app's encrypted -`CloudSavePackage` shape. The server should store encrypted restore points and -metadata, not plaintext app data. diff --git a/server/package.json b/server/package.json index adac2d4..c20878c 100644 --- a/server/package.json +++ b/server/package.json @@ -18,6 +18,8 @@ "dependencies": { "@fastify/rate-limit": "^11.0.0", "@fastify/sensible": "^6.0.3", + "@fastify/swagger": "^9.7.0", + "@fastify/swagger-ui": "^6.0.0", "better-sqlite3": "^12.11.1", "dotenv": "^17.2.3", "fastify": "^5.6.2", diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml index 6048467..be8966e 100644 --- a/server/pnpm-lock.yaml +++ b/server/pnpm-lock.yaml @@ -14,6 +14,12 @@ importers: '@fastify/sensible': specifier: ^6.0.3 version: 6.0.4 + '@fastify/swagger': + specifier: ^9.7.0 + version: 9.7.0 + '@fastify/swagger-ui': + specifier: ^6.0.0 + version: 6.0.0 better-sqlite3: specifier: ^12.11.1 version: 12.11.1 @@ -41,7 +47,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.14 - version: 4.1.9(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + version: 4.1.9(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)(yaml@2.9.0)) packages: @@ -210,6 +216,9 @@ packages: cpu: [x64] os: [win32] + '@fastify/accept-negotiator@2.0.1': + resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} + '@fastify/ajv-compiler@4.0.5': resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} @@ -231,9 +240,21 @@ packages: '@fastify/rate-limit@11.0.0': resolution: {integrity: sha512-kCs+G59SitZw9TL/ekFe+MrzXk20dEp6zPAM8WEZjFl5Ubvv5ksTbEXYr4jGlBwWAKn78q+NFsj5CN75zXLjaw==} + '@fastify/send@4.1.0': + resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} + '@fastify/sensible@6.0.4': resolution: {integrity: sha512-1vxcCUlPMew6WroK8fq+LVOwbsLtX+lmuRuqpcp6eYqu6vmkLwbKTdBWAZwbeaSgCfW4tzUpTIHLLvTiQQ1BwQ==} + '@fastify/static@9.1.3': + resolution: {integrity: sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==} + + '@fastify/swagger-ui@6.0.0': + resolution: {integrity: sha512-L9c4CbXj3FnquqpCmn0IfbEeIqDUNi6QwXd23VhQj/bHEjNzDFIAy2W9I3prvSqM+mJWOElIa6uXROmcMDnfUA==} + + '@fastify/swagger@9.7.0': + resolution: {integrity: sha512-Vp1SC1GC2Hrkd3faFILv86BzUNyFz5N4/xdExqtCgkGASOzn/x+eMe4qXIGq7cdT6wif/P/oa6r1Ruqx19paZA==} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} @@ -426,6 +447,10 @@ packages: avvio@9.2.0: resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -439,6 +464,10 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -449,6 +478,10 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + content-type@2.0.0: resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} engines: {node: '>=18'} @@ -460,6 +493,15 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -495,6 +537,9 @@ packages: engines: {node: '>=18'} hasBin: true + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -561,6 +606,10 @@ packages: github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -581,6 +630,10 @@ packages: json-schema-ref-resolver@3.0.0: resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + json-schema-resolver@3.0.0: + resolution: {integrity: sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==} + engines: {node: '>=20'} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -661,6 +714,10 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -676,16 +733,32 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.15: resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -709,6 +782,13 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1004,6 +1084,11 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -1103,6 +1188,8 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@fastify/accept-negotiator@2.0.1': {} + '@fastify/ajv-compiler@4.0.5': dependencies: ajv: 8.20.0 @@ -1132,6 +1219,14 @@ snapshots: fastify-plugin: 5.1.0 toad-cache: 3.7.1 + '@fastify/send@4.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + '@fastify/sensible@6.0.4': dependencies: '@lukeed/ms': 2.0.2 @@ -1142,6 +1237,33 @@ snapshots: type-is: 2.1.0 vary: 1.1.2 + '@fastify/static@9.1.3': + dependencies: + '@fastify/accept-negotiator': 2.0.1 + '@fastify/send': 4.1.0 + content-disposition: 1.1.0 + fastify-plugin: 5.1.0 + fastq: 1.20.1 + glob: 13.0.6 + + '@fastify/swagger-ui@6.0.0': + dependencies: + '@fastify/static': 9.1.3 + fastify-plugin: 5.1.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.9.0 + + '@fastify/swagger@9.7.0': + dependencies: + fastify-plugin: 5.1.0 + json-schema-resolver: 3.0.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.9.0 + transitivePeerDependencies: + - supports-color + '@jridgewell/sourcemap-codec@1.5.5': {} '@lukeed/ms@2.0.2': {} @@ -1241,13 +1363,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))': + '@vitest/mocker@4.1.9(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4) + vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)(yaml@2.9.0) '@vitest/pretty-format@4.1.9': dependencies: @@ -1295,6 +1417,8 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.20.1 + balanced-match@4.0.4: {} + base64-js@1.5.1: {} better-sqlite3@12.11.1: @@ -1312,6 +1436,10 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -1321,12 +1449,18 @@ snapshots: chownr@1.1.4: {} + content-disposition@1.1.0: {} + content-type@2.0.0: {} convert-source-map@2.0.0: {} cookie@1.1.1: {} + debug@4.4.3: + dependencies: + ms: 2.1.3 + decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -1376,6 +1510,8 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + escape-html@1.0.3: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -1448,6 +1584,12 @@ snapshots: github-from-package@0.0.0: {} + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -1468,6 +1610,14 @@ snapshots: dependencies: dequal: 2.0.3 + json-schema-resolver@3.0.0: + dependencies: + debug: 4.4.3 + fast-uri: 3.1.2 + rfdc: 1.4.1 + transitivePeerDependencies: + - supports-color + json-schema-traverse@1.0.0: {} light-my-request@6.6.0: @@ -1525,6 +1675,8 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lru-cache@11.5.1: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1537,12 +1689,22 @@ snapshots: dependencies: mime-db: 1.54.0 + mime@3.0.0: {} + mimic-response@3.1.0: {} + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + minimist@1.2.8: {} + minipass@7.1.3: {} + mkdirp-classic@0.5.3: {} + ms@2.1.3: {} + nanoid@3.3.15: {} napi-build-utils@2.0.0: {} @@ -1559,6 +1721,13 @@ snapshots: dependencies: wrappy: 1.0.2 + openapi-types@12.1.3: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.1 + minipass: 7.1.3 + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -1770,7 +1939,7 @@ snapshots: vary@1.1.2: {} - vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4): + vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -1782,11 +1951,12 @@ snapshots: esbuild: 0.28.1 fsevents: 2.3.3 tsx: 4.22.4 + yaml: 2.9.0 - vitest@4.1.9(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)): + vitest@4.1.9(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)) + '@vitest/mocker': 4.1.9(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -1803,7 +1973,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4) + vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.2 @@ -1817,4 +1987,6 @@ snapshots: wrappy@1.0.2: {} + yaml@2.9.0: {} + zod@4.4.3: {} diff --git a/server/src/app.ts b/server/src/app.ts index 5c13d32..49aa0ca 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -15,6 +15,7 @@ import { registerCloudSaveRoutes } from './cloud-save-routes.js'; import { type CloudSaveStore } from './cloud-save-store.js'; import { createDefaultStores } from './cloud-save-store-factory.js'; import { type AppConfig } from './config.js'; +import { registerOpenApiRoutes } from './openapi.js'; export type AppDependencies = { authStore?: AuthStore; @@ -32,6 +33,8 @@ export async function buildApp( dependencies: AppDependencies = {} ): Promise { const app = Fastify({ + // The REST contract caps decoded encrypted payload bytes. The HTTP body + // limit stays larger so JSON/base64 envelope overhead can reach validation. bodyLimit: config.cloudSaveMaxPayloadBytes * 2 + 16 * 1024, genReqId: (request) => requestIdFromHeader(request.headers['x-request-id']), trustProxy: config.trustProxy, @@ -51,6 +54,8 @@ export async function buildApp( handleError(error, request, reply); }); + await registerOpenApiRoutes(app); + const defaultStores = dependencies.authStore != null && dependencies.cloudSaveStore != null ? null @@ -159,6 +164,7 @@ function statusCodeForError(error: HttpErrorLike): number { function requestIdFromHeader(value: string | string[] | undefined): string { const candidate = Array.isArray(value) ? value[0] : value; + // Keep caller-supplied IDs log-safe and compact before echoing them back. if (candidate != null && /^[A-Za-z0-9._:-]{1,128}$/.test(candidate)) { return candidate; } diff --git a/server/src/cloud-save-package.ts b/server/src/cloud-save-package.ts index 0b3c454..cecae6e 100644 --- a/server/src/cloud-save-package.ts +++ b/server/src/cloud-save-package.ts @@ -135,6 +135,8 @@ function validateCloudSavePackage( const expectedPayloadEncodedLength = base64EncodedLength( metadata.payloadByteCount ); + // Check the base64 text length before decoding so mismatched packages fail + // cheaply and do not allocate large buffers. if (payload.data.length !== expectedPayloadEncodedLength) { throw new CloudSavePackageValidationError( 'Cloud save payload byte count does not match metadata.' @@ -154,6 +156,8 @@ function validateCloudSavePackage( ); } + // The server validates the encryption envelope only. Decryption stays on the + // client so the API never sees plaintext backup contents. const nonce = decodeBase64(payload.encryption.nonceBase64, 'nonceBase64'); if (nonce.byteLength !== 24) { throw new CloudSavePackageValidationError( @@ -177,6 +181,8 @@ function validateCloudSavePackage( } function decodeBase64(value: string, fieldName: string): Buffer { + // Node's decoder accepts some non-canonical inputs. Keep the API contract + // strict so checksum and byte-count validation are deterministic. if (!isCanonicalBase64Text(value)) { throw new CloudSavePackageValidationError( `Cloud save field "${fieldName}" must be base64.` diff --git a/server/src/cloud-save-routes.ts b/server/src/cloud-save-routes.ts index 920bf6b..c6f2ef3 100644 --- a/server/src/cloud-save-routes.ts +++ b/server/src/cloud-save-routes.ts @@ -19,6 +19,8 @@ export async function registerCloudSaveRoutes( app: FastifyInstance, options: CloudSaveRouteOptions ): Promise { + // Register literal read routes before /:saveId so "latest" is not parsed as + // a save ID. app.get('/v1/saves', async (request) => { const device = await requireAuthenticatedDevice( app, @@ -97,6 +99,8 @@ function cloudSaveUploadRateLimitKey(request: FastifyRequest): string { const authorization = request.headers.authorization; const token = parseBearerToken(authorization); + // Valid-looking bearer tokens get their own upload bucket, which avoids one + // active device throttling another behind the same home or mobile network. return token == null ? `ip:${request.ip}` : `device:${hashAuthToken(token)}`; } diff --git a/server/src/local-cloud-save-store.ts b/server/src/local-cloud-save-store.ts index c5fc330..811a490 100644 --- a/server/src/local-cloud-save-store.ts +++ b/server/src/local-cloud-save-store.ts @@ -458,6 +458,7 @@ export class LocalCloudSaveStore implements AuthStore, CloudSaveStore { const finalPath = this.absolutePackagePath(packagePath); const tempPath = `${finalPath}.${process.pid}.${Date.now()}.tmp`; + // Write then rename so readers never observe a partial JSON package file. await mkdir(dirname(finalPath), { recursive: true }); await writeFile(tempPath, JSON.stringify(cloudSavePackage, null, 2)); await rename(tempPath, finalPath); @@ -468,6 +469,8 @@ export class LocalCloudSaveStore implements AuthStore, CloudSaveStore { this.absolutePackagePath(packagePath), 'utf8' ); + // Re-parse stored packages to catch disk corruption or manual edits before + // sending a package back to a client. return parseCloudSavePackage(JSON.parse(contents), { maxPayloadBytes: Number.MAX_SAFE_INTEGER }); @@ -519,6 +522,8 @@ export class LocalCloudSaveStore implements AuthStore, CloudSaveStore { const columns = this.database .prepare('PRAGMA table_info(cloud_saves)') .all() as Array<{ name: string }>; + // Early local builds used a non-account-scoped cloud_saves table. Drop it + // during dev migration rather than exposing global saves through v1 routes. if ( columns.length > 0 && !columns.some((column) => column.name === 'account_id') diff --git a/server/src/openapi.ts b/server/src/openapi.ts new file mode 100644 index 0000000..f6a713e --- /dev/null +++ b/server/src/openapi.ts @@ -0,0 +1,518 @@ +import fastifySwagger from '@fastify/swagger'; +import fastifySwaggerUi from '@fastify/swagger-ui'; +import { type FastifyInstance } from 'fastify'; + +const jsonContent = (schema: unknown) => ({ + 'application/json': { + schema + } +}); + +const errorContent = jsonContent({ $ref: '#/components/schemas/ErrorResponse' }); + +const errorHeaders = { + 'x-request-id': { + description: 'Request correlation ID.', + schema: { type: 'string' } + }, + 'x-error-id': { + description: 'Support reference for this error.', + schema: { type: 'string' } + } +}; + +const errorResponse = (description: string) => ({ + description, + headers: errorHeaders, + content: errorContent +}); + +export const openApiDocument = { + openapi: '3.0.3', + info: { + title: 'All Of Me Cloud Save API', + version: '0.1.0', + description: + 'REST API for optional encrypted All Of Me cloud-save restore points. ' + + 'The current device remains the source of truth; the server stores ' + + 'encrypted packages and never decrypts app data.' + }, + servers: [ + { + url: 'https://api.allofmeapp.com', + description: 'Production' + }, + { + url: 'http://127.0.0.1:3000', + description: 'Local development' + } + ], + tags: [ + { + name: 'System', + description: 'Process health and operational checks.' + }, + { + name: 'Devices', + description: 'Cloud Save device registration and one-time link codes.' + }, + { + name: 'Saves', + description: 'Encrypted Cloud Save upload and restore endpoints.' + } + ], + paths: { + '/healthz': { + get: { + tags: ['System'], + summary: 'Health check', + operationId: 'getHealth', + responses: { + 200: { + description: 'API process is healthy.', + content: jsonContent({ $ref: '#/components/schemas/HealthResponse' }) + } + } + } + }, + '/v1/devices/register': { + post: { + tags: ['Devices'], + summary: 'Register first device', + operationId: 'registerDevice', + requestBody: { + required: false, + content: jsonContent({ + $ref: '#/components/schemas/RegisterDeviceRequest' + }) + }, + responses: { + 201: { + description: 'Device registered. Store the returned bearer token once.', + content: jsonContent({ + $ref: '#/components/schemas/DeviceRegistration' + }) + }, + 400: { $ref: '#/components/responses/BadRequest' }, + 429: { $ref: '#/components/responses/TooManyRequests' }, + 500: { $ref: '#/components/responses/ServerError' } + } + } + }, + '/v1/devices/link-codes': { + post: { + tags: ['Devices'], + summary: 'Create a device link code', + operationId: 'createDeviceLinkCode', + security: [{ bearerAuth: [] }], + requestBody: { + required: false, + content: jsonContent({ $ref: '#/components/schemas/EmptyObject' }) + }, + responses: { + 201: { + description: 'One-time code created for another device.', + content: jsonContent({ $ref: '#/components/schemas/DeviceLinkCode' }) + }, + 401: { $ref: '#/components/responses/Unauthorized' }, + 429: { $ref: '#/components/responses/TooManyRequests' }, + 500: { $ref: '#/components/responses/ServerError' } + } + } + }, + '/v1/devices/link': { + post: { + tags: ['Devices'], + summary: 'Redeem a device link code', + operationId: 'redeemDeviceLinkCode', + requestBody: { + required: true, + content: jsonContent({ + $ref: '#/components/schemas/RedeemDeviceLinkCodeRequest' + }) + }, + responses: { + 201: { + description: 'New device registered in the existing account.', + content: jsonContent({ + $ref: '#/components/schemas/DeviceRegistration' + }) + }, + 400: { $ref: '#/components/responses/BadRequest' }, + 401: { $ref: '#/components/responses/Unauthorized' }, + 429: { $ref: '#/components/responses/TooManyRequests' }, + 500: { $ref: '#/components/responses/ServerError' } + } + } + }, + '/v1/saves': { + get: { + tags: ['Saves'], + summary: 'List cloud-save versions', + operationId: 'listCloudSaveVersions', + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'Newest-first save metadata for this account.', + content: jsonContent({ + type: 'array', + items: { $ref: '#/components/schemas/CloudSaveMetadata' } + }) + }, + 401: { $ref: '#/components/responses/Unauthorized' }, + 429: { $ref: '#/components/responses/TooManyRequests' }, + 500: { $ref: '#/components/responses/ServerError' } + } + }, + post: { + tags: ['Saves'], + summary: 'Upload an encrypted cloud save', + operationId: 'uploadCloudSave', + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: jsonContent({ $ref: '#/components/schemas/CloudSavePackage' }) + }, + responses: { + 201: { + description: 'Cloud save stored. Response is the stored metadata.', + content: jsonContent({ + $ref: '#/components/schemas/CloudSaveMetadata' + }) + }, + 400: { $ref: '#/components/responses/BadRequest' }, + 401: { $ref: '#/components/responses/Unauthorized' }, + 413: { $ref: '#/components/responses/PayloadTooLarge' }, + 429: { $ref: '#/components/responses/TooManyRequests' }, + 500: { $ref: '#/components/responses/ServerError' } + } + } + }, + '/v1/saves/latest': { + get: { + tags: ['Saves'], + summary: 'Download latest cloud save', + operationId: 'downloadLatestCloudSave', + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'Newest encrypted CloudSavePackage for this account.', + content: jsonContent({ $ref: '#/components/schemas/CloudSavePackage' }) + }, + 401: { $ref: '#/components/responses/Unauthorized' }, + 404: { $ref: '#/components/responses/NotFound' }, + 429: { $ref: '#/components/responses/TooManyRequests' }, + 500: { $ref: '#/components/responses/ServerError' } + } + } + }, + '/v1/saves/{saveId}': { + get: { + tags: ['Saves'], + summary: 'Download cloud save by ID', + operationId: 'downloadCloudSaveById', + security: [{ bearerAuth: [] }], + parameters: [ + { + name: 'saveId', + in: 'path', + required: true, + description: 'Account-scoped cloud-save ID.', + schema: { $ref: '#/components/schemas/CloudSaveId' } + } + ], + responses: { + 200: { + description: 'Encrypted CloudSavePackage for this account.', + content: jsonContent({ $ref: '#/components/schemas/CloudSavePackage' }) + }, + 400: { $ref: '#/components/responses/BadRequest' }, + 401: { $ref: '#/components/responses/Unauthorized' }, + 404: { $ref: '#/components/responses/NotFound' }, + 429: { $ref: '#/components/responses/TooManyRequests' }, + 500: { $ref: '#/components/responses/ServerError' } + } + } + } + }, + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'opaque device token' + } + }, + schemas: { + EmptyObject: { + type: 'object', + additionalProperties: false + }, + HealthResponse: { + type: 'object', + additionalProperties: false, + required: ['ok'], + properties: { + ok: { type: 'boolean', example: true } + } + }, + ErrorResponse: { + type: 'object', + additionalProperties: false, + required: ['statusCode', 'error', 'message', 'errorId', 'requestId'], + properties: { + statusCode: { type: 'integer', example: 400 }, + error: { type: 'string', example: 'Bad Request' }, + message: { + type: 'string', + example: 'Cloud save package is invalid.' + }, + errorId: { + type: 'string', + example: 'err_00000000-0000-0000-0000-000000000000' + }, + requestId: { + type: 'string', + example: 'req_00000000-0000-0000-0000-000000000000' + } + } + }, + RegisterDeviceRequest: { + type: 'object', + additionalProperties: false, + properties: { + deviceLabel: { + type: 'string', + minLength: 1, + maxLength: 100, + example: 'Miguel iPhone' + } + } + }, + RedeemDeviceLinkCodeRequest: { + type: 'object', + additionalProperties: false, + required: ['code'], + properties: { + code: { + type: 'string', + minLength: 1, + maxLength: 80, + example: 'AOM-12345-ABCDE' + }, + deviceLabel: { + type: 'string', + minLength: 1, + maxLength: 100, + example: 'Miguel iPad' + } + } + }, + DeviceRegistration: { + type: 'object', + additionalProperties: false, + required: ['accountId', 'deviceId', 'token', 'tokenType'], + properties: { + accountId: { type: 'string', example: 'account-1782264000000-abcd' }, + deviceId: { type: 'string', example: 'device-1782264000000-abcd' }, + deviceLabel: { type: 'string', example: 'Miguel iPhone' }, + token: { type: 'string', example: 'aom_redacted' }, + tokenType: { type: 'string', enum: ['Bearer'] } + } + }, + DeviceLinkCode: { + type: 'object', + additionalProperties: false, + required: ['code', 'expiresAt'], + properties: { + code: { + type: 'string', + pattern: '^AOM-[0-9A-F]{5}-[0-9A-F]{5}$', + example: 'AOM-12345-ABCDE' + }, + expiresAt: { + type: 'string', + format: 'date-time', + example: '2026-06-24T12:10:00.000Z' + } + } + }, + CloudSaveId: { + type: 'string', + minLength: 1, + maxLength: 128, + pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', + example: 'cloud-save-1782264000000000' + }, + CloudSaveMetadata: { + type: 'object', + additionalProperties: false, + required: [ + 'saveId', + 'createdAt', + 'appName', + 'snapshotSchemaVersion', + 'payloadByteCount', + 'payloadChecksum' + ], + properties: { + saveId: { $ref: '#/components/schemas/CloudSaveId' }, + createdAt: { + type: 'string', + format: 'date-time', + example: '2026-06-23T18:40:00.000Z' + }, + appName: { + type: 'string', + minLength: 1, + maxLength: 100, + example: 'All Of Me' + }, + appVersion: { + type: 'string', + minLength: 1, + maxLength: 50, + example: '1.0.0+10' + }, + snapshotSchemaVersion: { type: 'integer', minimum: 1, example: 3 }, + deviceLabel: { + type: 'string', + minLength: 1, + maxLength: 100, + example: 'Miguel iPhone' + }, + payloadByteCount: { + type: 'integer', + minimum: 1, + example: 123456 + }, + payloadChecksum: { + type: 'string', + pattern: '^fnv1a32:[0-9a-f]{8}$', + example: 'fnv1a32:1234abcd' + } + } + }, + CloudSaveEncryption: { + type: 'object', + additionalProperties: false, + required: [ + 'algorithm', + 'keyDerivationAlgorithm', + 'keyDerivationIterations', + 'keyLengthBits', + 'keyId', + 'nonceBase64', + 'saltBase64', + 'macBase64' + ], + properties: { + algorithm: { type: 'string', enum: ['xchacha20-poly1305'] }, + keyDerivationAlgorithm: { + type: 'string', + enum: ['pbkdf2-hmac-sha256'] + }, + keyDerivationIterations: { + type: 'integer', + minimum: 1, + example: 120000 + }, + keyLengthBits: { type: 'integer', minimum: 1, example: 256 }, + keyId: { + type: 'string', + minLength: 1, + maxLength: 128, + example: 'passphrase-recovery-key-v1' + }, + nonceBase64: { + type: 'string', + description: 'Canonical base64 that decodes to 24 bytes.', + pattern: '^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$' + }, + saltBase64: { + type: 'string', + description: 'Canonical base64 that decodes to 16 bytes.', + pattern: '^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$' + }, + macBase64: { + type: 'string', + description: 'Canonical base64 that decodes to 16 bytes.', + pattern: '^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$' + } + } + }, + CloudSavePayload: { + type: 'object', + additionalProperties: false, + required: ['encoding', 'compression', 'encryption', 'data'], + properties: { + encoding: { type: 'string', enum: ['base64'] }, + compression: { type: 'string', enum: ['none'] }, + encryption: { $ref: '#/components/schemas/CloudSaveEncryption' }, + data: { + type: 'string', + minLength: 1, + description: 'Canonical base64 encrypted payload bytes.', + pattern: '^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$' + } + } + }, + CloudSavePackage: { + type: 'object', + additionalProperties: false, + required: ['formatVersion', 'metadata', 'payload'], + properties: { + formatVersion: { type: 'integer', enum: [1] }, + metadata: { $ref: '#/components/schemas/CloudSaveMetadata' }, + payload: { $ref: '#/components/schemas/CloudSavePayload' } + } + } + }, + responses: { + BadRequest: errorResponse('Request JSON, route parameter, or envelope is invalid.'), + Unauthorized: errorResponse('Bearer token or link code is missing, invalid, expired, or reused.'), + NotFound: errorResponse('The requested account-scoped save was not found.'), + PayloadTooLarge: errorResponse('The decoded cloud-save payload exceeds the configured limit.'), + TooManyRequests: { + ...errorResponse('A rate limit was exceeded.'), + headers: { + ...errorHeaders, + 'retry-after': { + description: 'Seconds to wait before retrying.', + schema: { type: 'string' } + } + } + }, + ServerError: errorResponse('Unexpected server error.') + } + }, + externalDocs: { + description: 'Markdown REST contract and operational notes.', + url: 'https://github.com/KopitarFan/AllOfMe/blob/main/docs/server-rest-api.md' + } +}; + +export async function registerOpenApiRoutes( + app: FastifyInstance +): Promise { + await app.register(fastifySwagger, { + mode: 'static', + specification: { + document: openApiDocument as never + } + }); + + await app.register(fastifySwaggerUi, { + routePrefix: '/docs', + staticCSP: true, + uiConfig: { + deepLinking: true, + docExpansion: 'list', + persistAuthorization: true, + displayRequestDuration: true + }, + theme: { + title: 'All Of Me API Docs' + } + }); +} diff --git a/server/test/docs.test.ts b/server/test/docs.test.ts new file mode 100644 index 0000000..0dec246 --- /dev/null +++ b/server/test/docs.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from 'vitest'; + +import { buildApp } from '../src/app.js'; +import { loadConfig } from '../src/config.js'; + +describe('Swagger docs', () => { + test('serves the OpenAPI document', async () => { + const app = await buildApp(loadConfig({ NODE_ENV: 'test' })); + + try { + const response = await app.inject({ + method: 'GET', + url: '/docs/json' + }); + const document = response.json<{ + openapi: string; + paths: Record>; + components: { + schemas: Record; + securitySchemes: Record; + }; + }>(); + + expect(response.statusCode).toBe(200); + expect(document.openapi).toBe('3.0.3'); + expect(document.paths['/healthz']).toHaveProperty('get'); + expect(document.paths['/v1/devices/register']).toHaveProperty('post'); + expect(document.paths['/v1/devices/link-codes']).toHaveProperty('post'); + expect(document.paths['/v1/devices/link']).toHaveProperty('post'); + expect(document.paths['/v1/saves']).toHaveProperty('get'); + expect(document.paths['/v1/saves']).toHaveProperty('post'); + expect(document.paths['/v1/saves/latest']).toHaveProperty('get'); + expect(document.paths['/v1/saves/{saveId}']).toHaveProperty('get'); + expect(document.components.schemas).toHaveProperty('CloudSavePackage'); + expect(document.components.securitySchemes).toHaveProperty('bearerAuth'); + } finally { + await app.close(); + } + }); + + test('serves Swagger UI', async () => { + const app = await buildApp(loadConfig({ NODE_ENV: 'test' })); + + try { + const response = await app.inject({ + method: 'GET', + url: '/docs/' + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toContain('text/html'); + expect(response.body).toContain('All Of Me API Docs'); + expect(response.body).toContain('swagger-ui-bundle.js'); + } finally { + await app.close(); + } + }); +});