diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bede2d7f..dea7931c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,7 @@ api/ │ │ ├── me.ts # GET /me; POST /me/name; POST /me/forum-laws-dismissed; POST /me/rules-agreement; link/unlink + address verification │ │ ├── view.ts # GET /view/:viewKey (public profile card) │ │ ├── lightning-address.ts # GET /lightning-address (public LUD-16 resolve) -│ │ ├── debug.ts # GET/POST /debug/accounts; PATCH /debug/accounts/:id (DEBUG_TOKEN) +│ │ ├── debug.ts # GET/POST /debug/accounts; PATCH /debug/accounts/:id role or unlink (DEBUG_TOKEN) │ │ ├── debug-contacts.ts # GET /debug/contacts (operator DEBUG_TOKEN) │ │ ├── debug-payments.ts # GET /debug/invoices; GET /debug/zap-ingests (DEBUG_TOKEN) │ │ ├── debug-push.ts # POST /debug/push-ping (operator DEBUG_TOKEN) @@ -46,13 +46,16 @@ api/ │ │ ├── stats.ts # GET /gifts/stats (public gift totals) │ │ ├── gifts.ts # GET /gifts?day= (public per-day gift list) │ │ ├── invoices.ts # POST /invoices, POST /invoices/proof (spend worker) -│ │ ├── messages.ts # GET/POST /messages, GET /messages/:id/photo, POST /messages/:id/invoice +│ │ ├── messages.ts # GET/POST /messages, GET /messages/:id/photo, GET /messages/:id/video.*, POST /messages/:id/invoice +│ │ ├── well-known.ts # GET /.well-known/nostr.json (NIP-05) │ │ └── contact.ts # POST /contact (private in-app mailbox) │ ├── lib/ │ │ ├── meta.ts # Service constants (name, version, repo URL) │ │ ├── config.ts # Auth, verification, and gift-invoice TTLs/amounts (no required env for verify) │ │ ├── name.ts # Display-name trim/validate (C0/DEL) -│ │ ├── message.ts # Forum text/photo validate + public JSON (hasPhoto; no bytes) +│ │ ├── message.ts # Forum text/photo/video validate + public JSON (hasPhoto/hasVideo; no bytes) +│ │ ├── video.ts # Forum video magic-bytes, MEDIA_DIR, Range parse, disk stream +│ │ ├── nip05.ts # NIP-05 slugs, nostr.json names, kind:0 identifier │ │ ├── message-store.ts # MessageStore port, InMemoryMessageStore, PostgresMessageStore │ │ ├── contact.ts # Contact public/debug JSON projection (reuses forum text rules) │ │ ├── contact-store.ts # ContactStore port, InMemoryContactStore, PostgresContactStore @@ -85,6 +88,7 @@ api/ │ │ ├── nostr/ # Custodial nsec, kind:0 profile + kind:1 note worker, NIP-57 zap, write-set relays │ │ └── auth/ │ │ ├── account-json.ts # Public account JSON (no nsec) +│ │ ├── account-setup.ts # Next owner setup step (name, Lightning Address, rules) │ │ ├── hex.ts # CSPRNG hex tokens │ │ ├── passkey.ts # WebAuthn register/authenticate domain logic │ │ ├── service.ts # Session issuance and bearer resolution @@ -126,6 +130,8 @@ api/ │ │ ├── gift.test.ts │ │ ├── gift-store.test.ts │ │ ├── message.test.ts +│ │ ├── video.test.ts +│ │ ├── nip05.test.ts │ │ ├── message-store.test.ts │ │ ├── nostr/ # kek, keys, publish, worker, relays, zap, event, sign, rate-limit │ │ ├── contact.test.ts @@ -137,6 +143,7 @@ api/ │ │ ├── push-worker.test.ts │ │ └── auth/ │ │ ├── account-json.test.ts +│ │ ├── account-setup.test.ts │ │ ├── hex.test.ts │ │ ├── passkey.test.ts │ │ ├── service.test.ts @@ -158,6 +165,7 @@ api/ │ ├── gifts.test.ts │ ├── invoices.test.ts │ ├── messages.test.ts +│ ├── well-known.test.ts │ ├── contact.test.ts │ ├── debug-contacts.test.ts │ ├── debug-payments.test.ts @@ -178,7 +186,7 @@ api/ ├── scripts/ │ ├── check-handbook.mjs # CI gate: missing heading → exit 1 │ ├── check-e2e.mjs # CI gate: missing endpoint request or Function: title → exit 1 -│ └── gifts-debug.sh # Operator CLI for GET /debug/accounts and PATCH /debug/accounts/:id +│ └── gifts-debug.sh # Operator CLI: list, set role, unlink Lightning Address (DEBUG_TOKEN) ├── e2e/ │ ├── http.spec.ts # Playwright endpoint smokes against bun src/index.ts │ └── functions.spec.ts # Playwright Function: tests against the booted process @@ -367,27 +375,28 @@ docker run -p 3000:3000 -e BIND_ADDR=0.0.0.0:3000 21gifts/api:dev Configuration is read from environment variables only — no config files. Currently: -| Variable | Default | Purpose | -| ---------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `BIND_ADDR` | `0.0.0.0:3000` | Listen address | -| `SERVICE_VERSION` | `0.1.0` | Surfaced via `/info` | -| `DATABASE_URL` | _(unset → in-memory)_ | Postgres connection string. When set, auth, `btc_usd_daily`, `message` (plus `message_invoice` and `nostr_zap_ingest`), `contact`, `push_subscription`, `push_outbox`, and `db_change` are migrated, `GET /gifts` and `GET /gifts/stats` read `gift` plus persisted BTC-USD daily closes (best-effort boot fill; failures log and do not kill the process), `GET/POST /messages` and `GET /messages/:id/photo` use `PostgresMessageStore`, `POST /contact` / `GET /debug/contacts` use `PostgresContactStore`, `GET /debug/invoices` and `GET /debug/zap-ingests` list invoice attempts and zap ingest rows, and a matching `POST /invoices/proof` inserts into `gift`. Unset keeps `InMemoryAuthStore`, in-memory forum, contact, and push stores, empty gift stats, empty day lists, and a no-op gift recorder. | -| `DEBUG_TOKEN` | _(unset → debug off)_ | Operator bearer for `GET /debug/accounts`, `POST /debug/accounts`, `PATCH /debug/accounts/:id`, `GET /debug/contacts`, `GET /debug/invoices`, `GET /debug/zap-ingests`, and `POST /debug/push-ping`. Unset or blank → `503`; the process still boots. | -| `WEBAUTHN_RP_ID` | _(none — required for passkey)_ | WebAuthn RP ID (`21.gifts` / `dev.21.gifts` / `localhost`). Passkey routes return `500` until it is set; the process still boots. Not a secret. | -| `WEBAUTHN_RP_NAME` | `21.gifts` | Human-readable RP name. | -| `CORS_ALLOWED_ORIGINS` | built-in apex / app aliases / localhost | Comma-separated browser origins. Passkey finish keeps those whose hostname is the RP ID or `app.`. | -| `SPEND_API_TOKEN` | _(none — optional)_ | Bearer for spend-worker `POST /invoices` / `POST /invoices/proof`. Unset/blank → **503**; the process still boots. | -| `BTC_USD_CANDLES_URL` | Coinbase Exchange BTC-USD candles URL | Optional override for daily close fetch used by `GET /gifts` and `GET /gifts/stats`. Blank/unset → default Coinbase URL; the process still boots. | -| `NOSTR_NSEC_KEK` | _(required with `DATABASE_URL`)_ | 32-byte hex AES-GCM KEK for custodial nsec. With `DATABASE_URL`, missing or malformed KEK **throws at boot**. Memory boots omit it. | -| `NOSTR_PUBLISH` | _(unset → sign only)_ | Set to `1` to fan out signed kind:1 notes, replaceable kind:0 profiles, and NIP-65 kind:10002 relay lists over WebSockets. Unchanged kind:0 / kind:10002 content is skipped for the life of the AuthStore instance. Other values do not publish. | -| `NOSTR_PUBLISH_PUBLIC` | _(unset → space-only published)_ | Set to `1` (with `NOSTR_PUBLISH=1`) to also write kind:1 notes, kind:0 profiles, and kind:10002 relay lists to Damus / Primal / nos.lol. Unset: space ACK is terminal `published`. Does not gate zap ingest or invoice `relays`. | -| `NOSTR_RELAY_URL` | `wss://relay.nostr.space` | Compose durability relay (nostr.space). Used when `NOSTR_RELAY_SPACE` is unset. | -| `NOSTR_RELAY_SPACE` | _(falls back to `NOSTR_RELAY_URL`)_ | Optional override of the durability relay WebSocket URL. | -| `NOSTR_RELAY_PUBLIC` | Damus, Primal, nos.lol | Optional comma-separated public relays. Used for kind:1, kind:0, and kind:10002 write when `NOSTR_PUBLISH_PUBLIC=1`, and always for zap ingest plus invoice `relays` tags (even when that flag is off). | -| `PUBLIC_BASE_URL` | _(unset → no photo URL in kind:1)_ | Site origin for public photo URLs in kind:1 (`https://21.gifts` → `https://api.21.gifts`, `https://dev.21.gifts` → `https://dev-api.21.gifts`; otherwise the trimmed origin). Unset or blank → photo notes are signed without a URL and are not reset/re-signed. Not required at boot. Playwright pins it to `http://127.0.0.1:3000`. | -| `VAPID_PUBLIC_KEY` | _(unset → push HTTP 503)_ | URL-safe base64 uncompressed P-256 public key (65 decoded bytes). Not a secret. Missing, blank, malformed, or unpaired with a valid private key → push HTTP **503**; the process still boots. | -| `VAPID_PRIVATE_KEY` | _(unset → push HTTP 503)_ | URL-safe base64 P-256 private key. Secret. Never log. Pair with `VAPID_PUBLIC_KEY`. | -| `VAPID_SUBJECT` | `https://21.gifts` | VAPID `sub` URI. Optional. | +| Variable | Default | Purpose | +| ---------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `BIND_ADDR` | `0.0.0.0:3000` | Listen address | +| `SERVICE_VERSION` | `0.1.0` | Surfaced via `/info` | +| `DATABASE_URL` | _(unset → in-memory)_ | Postgres connection string. When set, auth, `btc_usd_daily`, `message` (plus `message_invoice` and `nostr_zap_ingest`), `contact`, `push_subscription`, `push_outbox`, and `db_change` are migrated, `GET /gifts` and `GET /gifts/stats` read `gift` plus persisted BTC-USD daily closes (best-effort boot fill; failures log and do not kill the process), `GET/POST /messages`, `GET /messages/:id/photo`, and `GET /messages/:id/video.*` (MIME in Postgres, bytes under `MEDIA_DIR`) use `PostgresMessageStore`, `POST /contact` / `GET /debug/contacts` use `PostgresContactStore`, `GET /debug/invoices` and `GET /debug/zap-ingests` list invoice attempts and zap ingest rows, and a matching `POST /invoices/proof` inserts into `gift`. Unset keeps `InMemoryAuthStore`, in-memory forum, contact, and push stores, empty gift stats, empty day lists, and a no-op gift recorder. | +| `DEBUG_TOKEN` | _(unset → debug off)_ | Operator bearer for `GET /debug/accounts`, `POST /debug/accounts`, `PATCH /debug/accounts/:id`, `GET /debug/contacts`, `GET /debug/invoices`, `GET /debug/zap-ingests`, and `POST /debug/push-ping`. Unset or blank → `503`; the process still boots. | +| `WEBAUTHN_RP_ID` | _(none — required for passkey)_ | WebAuthn RP ID (`21.gifts` / `dev.21.gifts` / `localhost`). Passkey routes return `500` until it is set; the process still boots. Not a secret. | +| `WEBAUTHN_RP_NAME` | `21.gifts` | Human-readable RP name. | +| `CORS_ALLOWED_ORIGINS` | built-in apex / app aliases / localhost | Comma-separated browser origins. Passkey finish keeps those whose hostname is the RP ID or `app.`. | +| `SPEND_API_TOKEN` | _(none — optional)_ | Bearer for spend-worker `POST /invoices` / `POST /invoices/proof`. Unset/blank → **503**; the process still boots. | +| `BTC_USD_CANDLES_URL` | Coinbase Exchange BTC-USD candles URL | Optional override for daily close fetch used by `GET /gifts` and `GET /gifts/stats`. Blank/unset → default Coinbase URL; the process still boots. | +| `NOSTR_NSEC_KEK` | _(required with `DATABASE_URL`)_ | 32-byte hex AES-GCM KEK for custodial nsec. With `DATABASE_URL`, missing or malformed KEK **throws at boot**. Memory boots omit it. | +| `NOSTR_PUBLISH` | _(unset → sign only)_ | Set to `1` to fan out signed kind:1 notes, replaceable kind:0 profiles, and NIP-65 kind:10002 relay lists over WebSockets. Unchanged kind:0 / kind:10002 content is skipped for the life of the AuthStore instance. Other values do not publish. | +| `NOSTR_PUBLISH_PUBLIC` | _(unset → space-only published)_ | Set to `1` (with `NOSTR_PUBLISH=1`) to also write kind:1 notes, kind:0 profiles, and kind:10002 relay lists to Damus / Primal / nos.lol. Unset: space ACK is terminal `published`. Does not gate zap ingest or invoice `relays`. | +| `NOSTR_RELAY_URL` | `wss://relay.nostr.space` | Compose durability relay (nostr.space). Used when `NOSTR_RELAY_SPACE` is unset. | +| `NOSTR_RELAY_SPACE` | _(falls back to `NOSTR_RELAY_URL`)_ | Optional override of the durability relay WebSocket URL. | +| `NOSTR_RELAY_PUBLIC` | Damus, Primal, nos.lol | Optional comma-separated public relays. Used for kind:1, kind:0, and kind:10002 write when `NOSTR_PUBLISH_PUBLIC=1`, and always for zap ingest plus invoice `relays` tags (even when that flag is off). | +| `PUBLIC_BASE_URL` | _(unset → no media URL / no NIP-05)_ | Site origin for public photo/video URLs in kind:1 and the NIP-05 domain (`https://21.gifts` → `https://api.21.gifts` for media; nip05 uses hostname `21.gifts`). Unset or blank → media notes are signed without a URL and NIP-05 is omitted. Not required at boot. Playwright pins it to `http://127.0.0.1:3000`. | +| `MEDIA_DIR` | _(temp dir)_ | Directory for forum video files. Unset → process-local temp (tests). Compose pins `/data/media`. Not a secret. Not required at boot. | +| `VAPID_PUBLIC_KEY` | _(unset → push HTTP 503)_ | URL-safe base64 uncompressed P-256 public key (65 decoded bytes). Not a secret. Missing, blank, malformed, or unpaired with a valid private key → push HTTP **503**; the process still boots. | +| `VAPID_PRIVATE_KEY` | _(unset → push HTTP 503)_ | URL-safe base64 P-256 private key. Secret. Never log. Pair with `VAPID_PUBLIC_KEY`. | +| `VAPID_SUBJECT` | `https://21.gifts` | VAPID `sub` URI. Optional. | More will be added as concrete subsystems that need runtime configuration (relay client, …) land. The LUD-16 metadata cache TTL is a code constant diff --git a/Dockerfile b/Dockerfile index f21357bf..18f6c603 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,7 +24,8 @@ RUN bun build src/index.ts --target=bun --outdir=dist FROM oven/bun:1.3-alpine WORKDIR /app -RUN addgroup -S app && adduser -S app -G app +RUN addgroup -S app && adduser -S app -G app \ + && mkdir -p /data/media && chown app:app /data/media COPY --from=builder /app/dist ./dist COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/package.json ./package.json diff --git a/SPEC.md b/SPEC.md index 98ce2982..f86e578d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -66,7 +66,7 @@ Public base URLs used in examples: | POST | `/auth/passkey/register/finish` | none | Verify attestation, issue session | | POST | `/auth/passkey/authenticate/begin` | none | Issue WebAuthn request options | | POST | `/auth/passkey/authenticate/finish` | none | Verify assertion, issue session | -| GET | `/me` | `Authorization: Bearer` | Account | +| GET | `/me` | `Authorization: Bearer` | Account (`setup` next onboarding step) | | GET | `/view/:viewKey` | none | Public profile card by view key | | POST | `/me/name` | Bearer | Set/replace display name | | POST | `/me/forum-laws-dismissed` | Bearer | Dismiss welcome-forum living-room laws | @@ -83,7 +83,7 @@ Public base URLs used in examples: | GET | `/lightning-address` | none | Resolve LUD-16 metadata (cached) | | GET | `/debug/accounts` | `Authorization: Bearer` | Operator account listing (`DEBUG_TOKEN`) | | POST | `/debug/accounts` | `Authorization: Bearer` | Operator provision name + Lightning Address (`DEBUG_TOKEN`) | -| PATCH | `/debug/accounts/:id` | `Authorization: Bearer` | Operator set `account.role` (`DEBUG_TOKEN`) | +| PATCH | `/debug/accounts/:id` | `Authorization: Bearer` | Operator set `role` and/or unlink Lightning Address | | GET | `/debug/contacts` | `Authorization: Bearer` | Operator contact listing (`DEBUG_TOKEN`) | | GET | `/debug/invoices` | `Authorization: Bearer` | Operator forum invoice attempts (`DEBUG_TOKEN`) | | GET | `/debug/zap-ingests` | `Authorization: Bearer` | Operator kind:9735 ingest log (`DEBUG_TOKEN`) | @@ -229,12 +229,13 @@ ID). "forumLawsDismissed": false, "viewKey": "<64-hex>", "createdAt": 0, - "rulesAgreedAt": null + "rulesAgreedAt": null, + "setup": "name" } } ``` -The `account` object is the same owner JSON as `GET /me` (includes `viewKey`). +The `account` object is the same owner JSON as `GET /me` (includes `viewKey` and `setup`). ### `POST /auth/passkey/authenticate/begin` @@ -277,22 +278,24 @@ Missing or invalid bearer → **Response** `401`: "forumLawsDismissed": false, "viewKey": "<64-hex>", "createdAt": 0, - "rulesAgreedAt": null + "rulesAgreedAt": null, + "setup": "name" } ``` -| Field | Type | Meaning | -| -------------------------- | -------------- | --------------------------------------------------------------------------------------------- | -| `id` | string | Opaque account id | -| `linkingKey` | string \| null | Historical LNURL-auth linking key (hex), or `null` for passkey accounts | -| `role` | string | `basis`, `verified`, `moderator`, or `founder` | -| `name` | string \| null | Display name, or `null` until set | -| `lightningAddress` | string \| null | Linked LUD-16 address, or `null` | -| `lightningAddressVerified` | boolean | Proof-of-control flag (`true` only after confirm) | -| `forumLawsDismissed` | boolean | `true` after the welcome-forum living-room laws hint was dismissed | -| `viewKey` | string | Durable 64 lowercase hex capability secret for GET /view/:viewKey. Owner-only. Not a session. | -| `createdAt` | number | Creation time (epoch ms) | -| `rulesAgreedAt` | number \| null | Epoch ms of first living-room rules agreement, or `null` | +| Field | Type | Meaning | +| -------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | string | Opaque account id | +| `linkingKey` | string \| null | Historical LNURL-auth linking key (hex), or `null` for passkey accounts | +| `role` | string | `basis`, `verified`, `moderator`, or `founder` | +| `name` | string \| null | Display name, or `null` until set | +| `lightningAddress` | string \| null | Linked LUD-16 address, or `null` | +| `lightningAddressVerified` | boolean | Proof-of-control flag (`true` only after confirm) | +| `forumLawsDismissed` | boolean | `true` after the welcome-forum living-room laws hint was dismissed | +| `viewKey` | string | Durable 64 lowercase hex capability secret for GET /view/:viewKey. Owner-only. Not a session. | +| `createdAt` | number | Creation time (epoch ms) | +| `rulesAgreedAt` | number \| null | Epoch ms of first living-room rules agreement, or `null` | +| `setup` | string \| null | Next owner step: `name`, `lightning-address`, `rules`, or `null` when complete. Computed here; clients must not invent a parallel sequence. | ### `GET /view/:viewKey` @@ -665,19 +668,24 @@ still omits `viewKey`. ### `PATCH /debug/accounts/:id` -Operator assignment of the account's forum display role. Authenticated with -`Authorization: Bearer` matching `DEBUG_TOKEN` (same gate as -`GET /debug/accounts`). Body: +Operator assignment of the account's forum display role and/or unlinking the +Lightning Address. Authenticated with `Authorization: Bearer` matching +`DEBUG_TOKEN` (same gate as `GET /debug/accounts`). Body is one or both of: ```json -{ "role": "basis" } +{ "role": "basis", "lightningAddress": null } ``` -`role` must be one of `basis`, `verified`, `moderator`, or `founder`. This -path does not patch name or Lightning Address. `verified` is a human-identity -badge (a moderator physically met the person); it is not -`lightningAddressVerified`. New passkey accounts stay `basis` until an -operator changes them here. +`role` must be one of `basis`, `verified`, `moderator`, or `founder`. +`lightningAddress` may only be JSON `null` (unlink). Setting a new address +is not supported here (`POST /me/lightning-address` remains the live +resolve path). Unlink resets `lightningAddressVerified` to `false` and +drops any in-flight verification. `GET /me` then returns +`setup: "lightning-address"` when a name is already stored, so any client +that follows `setup` (or a missing `lightningAddress`) shows the address +form. `verified` as a **role** is a human-identity badge (a moderator +physically met the person); it is not `lightningAddressVerified`. New +passkey accounts stay `basis` until an operator changes them here. `DEBUG_TOKEN` unset or blank → **Response** `503`: @@ -691,10 +699,11 @@ Missing or non-matching bearer → **Response** `401`: { "error": "Unauthorized" } ``` -Body is not JSON with a known `role` string → **Response** `400`: +Body is not JSON with a known `role` and/or `lightningAddress: null` → +**Response** `400`: ```json -{ "error": "Expected a JSON body with a \"role\" string" } +{ "error": "Expected a JSON body with a \"role\" string and/or lightningAddress null" } ``` Unknown account id → **Response** `404`: @@ -703,9 +712,11 @@ Unknown account id → **Response** `404`: { "error": "Not found" } ``` -Success → **Response** `200` with the updated account JSON (same eight-field dump as -`GET /debug/accounts`; no `viewKey`). The process logs -`debug.accounts.role_set` with the account id and new role (never the token). +Success → **Response** `200` with the updated account JSON (same nine-field dump as +`GET /debug/accounts`; no `viewKey`). Role changes log +`debug.accounts.role_set` with the account id and new role. Unlink logs +`debug.accounts.lightning_address.cleared` with the account id (never the +token or the previous address). ### `GET /debug/contacts` diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 3b2ffad3..a05386dd 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -7,6 +7,34 @@ - **Used by:** `unlinkLightningAddress` in the app. - **Auth:** See Purpose — Bearer where stated, else public. +## Endpoint: GET /messages/:id/video.mp4 + +- **Purpose:** Public MP4 bytes streamed from disk with `Accept-Ranges` / HTTP 206 so Damus can seek. `Access-Control-Allow-Origin: *`. +- **Errors:** 404 `{ error: 'Video not found' }`; 416 unsatisfiable `Range` (`Content-Range: bytes */SIZE`); 503 `{ error: 'Messages are unavailable' }`. +- **Used by:** Damus/Primal kind:1 video URLs. +- **Auth:** none. + +## Endpoint: GET /messages/:id/video.webm + +- **Purpose:** Same as `video.mp4` for WebM posts. +- **Errors:** Same 404 / 416 / 503. +- **Used by:** Damus/Primal. +- **Auth:** none. + +## Endpoint: GET /messages/:id/video.mov + +- **Purpose:** Same as `video.mp4` for QuickTime posts. +- **Errors:** Same 404 / 416 / 503. +- **Used by:** Damus/Primal. +- **Auth:** none. + +## Endpoint: GET /.well-known/nostr.json + +- **Purpose:** NIP-05 directory `{ names, relays }`. CORS `*`. Optional `?name=`. +- **Errors:** 503 `{ error: 'Directory is unavailable' }`. +- **Used by:** Damus verification; app proxies this from the site apex. +- **Auth:** none. + ## Endpoint: GET /apple-touch-icon.png - **Purpose:** PNG brand mark (apple-touch). `Cache-Control: public, max-age=86400`. @@ -30,9 +58,9 @@ ## Endpoint: PATCH /debug/accounts/:id -- **Purpose:** Operator assignment of `account.role` (`basis` \| `verified` \| `moderator` \| `founder`). Body `{ "role": "" }`. Returns the updated account JSON (same shape as `GET /debug/accounts`: eight fields via `serializeAccount`; no `viewKey`). Does not patch name or Lightning Address. -- **Errors:** 503 `{ error: 'Debug is not configured' }` when `DEBUG_TOKEN` is unset or blank; 401 `{ error: 'Unauthorized' }` when the Bearer token does not match; 400 `{ error: 'Expected a JSON body with a "role" string' }` for unknown/missing/non-JSON body; 404 `{ error: 'Not found' }` when the account id is unknown. -- **Used by:** Operator `gifts-debug role` CLI. +- **Purpose:** Operator assignment of `account.role` (`basis` \| `verified` \| `moderator` \| `founder`) and/or hard-unlink of the Lightning Address. Body is one or both of `{ "role": "" }` and `{ "lightningAddress": null }`. Unlink sets `lightningAddress` to null, `lightningAddressVerified` to false, and drops in-flight address verification. Returns the updated account JSON (same nine-field dump as `GET /debug/accounts` via `serializeAccount`; no `viewKey`). Does not set a new address here (`POST /me/lightning-address` remains the live resolve path). +- **Errors:** 503 `{ error: 'Debug is not configured' }` when `DEBUG_TOKEN` is unset or blank; 401 `{ error: 'Unauthorized' }` when the Bearer token does not match; 400 `{ error: 'Expected a JSON body with a "role" string and/or lightningAddress null' }` for unknown/missing/non-JSON body or a non-null `lightningAddress`; 404 `{ error: 'Not found' }` when the account id is unknown. +- **Used by:** Operator `gifts-debug role` and `gifts-debug unlink` CLI. - **Auth:** `Authorization: Bearer` with `DEBUG_TOKEN`. Not an end-user session. ## Endpoint: GET /debug/contacts @@ -177,7 +205,7 @@ ## Endpoint: GET /me -- **Purpose:** Bearer session. Current account JSON (id, linkingKey, role, name, lightning address, verified flag, forumLawsDismissed, `createdAt`, `rulesAgreedAt`, owner `viewKey`). +- **Purpose:** Bearer session. Current account JSON (id, linkingKey, role, name, lightning address, verified flag, forumLawsDismissed, `createdAt`, `rulesAgreedAt`, owner `viewKey`, `setup`). `setup` is the next owner step (`name` \| `lightning-address` \| `rules`) or `null` when complete; computed here so clients do not invent a parallel sequence. - **Errors:** 401 if missing/expired. - **Used by:** App `fetchMe`. - **Auth:** See Purpose — Bearer where stated, else public. @@ -191,7 +219,7 @@ ## Endpoint: GET /messages -- **Purpose:** Bearer required. Lists the public member forum newest-first (author name snapshotted at post, `text`, ISO `createdAt`, `sats`, `payable`, `hasPhoto`, and live author `role`), capped at 200 (latest-200 window). Clients render chronological messenger-group order (oldest top, newest bottom above the composer). Empty list is 200 `{ messages: [] }`. No `accountId` and no photo bytes in JSON; `payable` is true when the note has an `eventId` and the author has a Lightning Address; missing author → `role` `"basis"` and `payable` false. +- **Purpose:** Bearer required. Lists the public member forum newest-first (author name snapshotted at post, `text`, ISO `createdAt`, `sats`, `payable`, `hasPhoto`, `hasVideo`, `videoContentType`, and live author `role`), capped at 200 (latest-200 window). Clients render chronological messenger-group order (oldest top, newest bottom above the composer). Empty list is 200 `{ messages: [] }`. No `accountId` and no photo/video bytes in JSON; `payable` is true when the note has an `eventId` and the author has a Lightning Address; missing author → `role` `"basis"` and `payable` false. `videoContentType` is `null` when `hasVideo` is false. - **Errors:** 401 `{ error: 'Unauthorized' }` missing/invalid/expired bearer; 503 `{ error: 'Messages are unavailable' }` if the store throws (`messages.list.failed`). - **Used by:** App public comment thread. - **Auth:** `Authorization: Bearer` session. @@ -233,8 +261,8 @@ ## Endpoint: POST /messages -- **Purpose:** Bearer required. JSON body `{ text?, photo?: { contentType, data } }` (base64; not multipart). Text-only `{ text }` stays valid; photo-only allowed; at least one of non-empty trimmed text or photo required. Name snapshot + optional JPEG/PNG/WebP ≤ 1 MiB. 200 is the public message including `sats`, `payable`, `hasPhoto`, and the session account's live `role` (not wrapped). New notes have `sats` 0 and `payable` false until signed. -- **Errors:** 401 Unauthorized; 400 Expected a JSON body with text and/or photo; 400 Set a name before posting; 400 Text must be 1–500 characters; 400 Text must be 1–500 characters or include a photo; 400 Photo must be a JPEG, PNG, or WebP under 1 MiB; 429 Too many messages (`Retry-After: 10`); 503 Messages are unavailable (`messages.create.failed`). +- **Purpose:** Bearer required. JSON `{ text?, photo?: { contentType, data } }` (base64 JPEG/PNG/WebP ≤ 1 MiB) or `multipart/form-data` with `text`, `video` (MP4/WebM/MOV ≤ 32 MiB), and optional JPEG/PNG/WebP `poster`. Text-only stays valid; photo-only or video-only allowed; at least one of non-empty trimmed text, photo, or video required. Name snapshot. 200 is the public message including `sats`, `payable`, `hasPhoto`, `hasVideo`, `videoContentType`, and the session account's live `role` (not wrapped). New notes have `sats` 0 and `payable` false until signed. +- **Errors:** 401 Unauthorized; 400 Expected a JSON body with text and/or photo; 400 Set a name before posting; 400 Text must be 1–500 characters; 400 Text must be 1–500 characters or include a photo or video; 400 Photo must be a JPEG, PNG, or WebP under 1 MiB; 400 Poster must be a JPEG, PNG, or WebP under 1 MiB; 400 Video must be an MP4, WebM, or MOV under 32 MiB; 429 Too many messages (`Retry-After: 10`); 503 Messages are unavailable (`messages.create.failed`). - **Used by:** App forum composer. - **Auth:** `Authorization: Bearer` session. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 7f068ec3..912df084 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -107,7 +107,7 @@ ## Function: migrateMessageSchema -- **Purpose:** Applies `MESSAGE_SCHEMA_SQL` in order (`CREATE TABLE IF NOT EXISTS message` with nullable `photo`/`photo_content_type`, newest-first index, additive `ALTER … ADD COLUMN IF NOT EXISTS` for existing databases, then `message_invoice` and `nostr_zap_ingest` without FKs plus their `created_at`/`message_id` and `receipt_id` indexes). +- **Purpose:** Applies `MESSAGE_SCHEMA_SQL` in order (`CREATE TABLE IF NOT EXISTS message` with nullable `photo`/`photo_content_type`, newest-first index, additive `ALTER … ADD COLUMN IF NOT EXISTS` for existing databases including `video_content_type` (MIME in Postgres; video bytes on disk under `MEDIA_DIR`, not bytea), then `message_invoice` and `nostr_zap_ingest` without FKs plus their `created_at`/`message_id` and `receipt_id` indexes). - **Inputs:** `SqlClient`. - **Returns / side effects:** Void; idempotent DDL execute matching `docs/schema/message.sql`. - **Used by:** `openBootStores` when SQL opens. @@ -156,7 +156,7 @@ ## Function: PostgresMessageStore -- **Purpose:** Durable `MessageStore` over Postgres (`message` table plus `message_invoice` and `nostr_zap_ingest`). `listLatest` selects Nostr columns plus `(photo IS NOT NULL) AS has_photo` and never the `photo` bytea column (HTTP window newest-first; product UX is a messenger group — clients reverse); `create` inserts optional photo bytes; `getPhoto` loads bytes by id; `getById`; `getByEventId` (`WHERE event_id`); `claimUnsigned`/`claimUnpublished` lease rows (`claimed_until <= now` is expired; unsigned requires `pending` + null `event_id`); `listPendingSigned` returns pending rows whose kind:1 lacks `t=bitcoin` (`created_at ASC, id ASC`); `clearSignedEvent` nulls `event_id` / `nostr_event` / `claimed_until` only while `pending` and `event_id` still matches the listed id; `listSignedMissingPhoto` returns published rows with a photo whose kind:1 content lacks `/messages/:id/photo.` plus an image extension (`sats = 0`, pending excluded so fan-out is not starved, `created_at ASC, id ASC`); `listSignedMissingHashtags` returns published unpaid rows whose kind:1 content lacks a `#bitcoin` or `#21gifts` token (next character must not be `[A-Za-z0-9_]`; `sats = 0`, pending excluded so fan-out is not starved, includes null / non-string content, `created_at ASC, id ASC`); `resetSignedEvent` nulls `event_id` / `nostr_event` / `claimed_until`, parks `pending`, and clears the epoch only when `event_id` still matches and `sats` is 0; `updateSignedEvent` (false on `event_id` collision); `updatePublishState`; `addSats`; `recordZapReceipt` (one statement: `INSERT nostr_zap_receipt ON CONFLICT DO NOTHING` plus `UPDATE message.sats`); `recordInvoiceAttempt` / `listInvoiceAttempts`; `recordZapIngest` / `listZapIngests`. +- **Purpose:** Durable `MessageStore` over Postgres (`message` table plus `message_invoice` and `nostr_zap_ingest`). `listLatest` selects Nostr columns plus `(photo IS NOT NULL) AS has_photo` and never the `photo` bytea column (HTTP window newest-first; product UX is a messenger group — clients reverse); `create` inserts optional photo bytes and optional `video_content_type` (disk write via `writeForumVideo`; `removeForumVideo` unlink on INSERT failure); `getPhoto` loads bytes by id; `getById`; `getByEventId` (`WHERE event_id`); `claimUnsigned`/`claimUnpublished` lease rows (`claimed_until <= now` is expired; unsigned requires `pending` + null `event_id`); `listPendingSigned` returns pending rows whose kind:1 lacks `t=bitcoin` (`created_at ASC, id ASC`); `clearSignedEvent` nulls `event_id` / `nostr_event` / `claimed_until` only while `pending` and `event_id` still matches the listed id; `listSignedMissingPhoto` returns published rows with a photo whose kind:1 content lacks `/messages/:id/photo.` plus an image extension (`sats = 0`, pending excluded so fan-out is not starved, video rows / `video_content_type` excluded so posters are not treated as missing photos, `created_at ASC, id ASC`); `listSignedMissingVideo` returns published rows with `video_content_type` set whose kind:1 content lacks `/messages/:id/video.` (`sats = 0`, pending excluded, `created_at ASC, id ASC`); `listSignedMissingHashtags` returns published unpaid rows whose kind:1 content lacks a `#bitcoin` or `#21gifts` token (next character must not be `[A-Za-z0-9_]`; `sats = 0`, pending excluded so fan-out is not starved, includes null / non-string content, `created_at ASC, id ASC`); `resetSignedEvent` nulls `event_id` / `nostr_event` / `claimed_until`, parks `pending`, and clears the epoch only when `event_id` still matches and `sats` is 0; `updateSignedEvent` (false on `event_id` collision); `updatePublishState`; `addSats`; `recordZapReceipt` (one statement: `INSERT nostr_zap_receipt ON CONFLICT DO NOTHING` plus `UPDATE message.sats`); `recordInvoiceAttempt` / `listInvoiceAttempts`; `recordZapIngest` / `listZapIngests`. - **Inputs:** Constructor takes a shared boot `SqlClient` (already migrated). - **Returns / side effects:** Parameter-bound SQL; maps snake_case rows to `MessageRow` / `ForumPhoto` / invoice and ingest rows. Claim uses `FOR UPDATE SKIP LOCKED`. Errors propagate to the route (503) except invoice/ingest persist failures which are caught by callers. - **Used by:** `openBootStores` when `DATABASE_URL` is set. @@ -226,9 +226,9 @@ ## Function: debugRoutes -- **Purpose:** Operator listing, provisioning, and role assignment for registered accounts. +- **Purpose:** Operator listing, provisioning, role assignment, and Lightning Address unlink for registered accounts. - **Inputs:** `DebugRouteDeps`: store, optional debugToken. -- **Returns / side effects:** Hono app (`GET /`, `POST /`, `PATCH /:id`). Shared 503 if token unset; 401 if bearer mismatches. GET 200 `{ accounts }` (no `viewKey`) logs `debug.accounts.listed` with count. POST body `{ accounts: [{ name, lightningAddress }] }` → 400 invalid body (including C0/DEL names or non-LUD-16 addresses after the shape check; no row is written); 500 `{ error: 'Could not save the account' }` when create does not persist the address, the name-only update matches no row, or the name-only update returns a row whose `name` is not the requested name; creates by Lightning Address, or for an existing address updates **only** `name` via `updateAccountNameByLightningAddress` (keeps `viewKey` / `role` / other columns); returns `{ accounts: [{ name, lightningAddress, viewKey, created }] }`; logs `debug.accounts.provisioned` with created/updated counts (never viewKeys or the token). PATCH body `{ role }` → 400 unknown/missing; 404 missing account; 200 `serializeAccount` of the updated row; logs `debug.accounts.role_set` with account id and role. Never logs the token. +- **Returns / side effects:** Hono app (`GET /`, `POST /`, `PATCH /:id`). Shared 503 if token unset; 401 if bearer mismatches. GET 200 `{ accounts }` (no `viewKey`) logs `debug.accounts.listed` with count. POST body `{ accounts: [{ name, lightningAddress }] }` → 400 invalid body (including C0/DEL names or non-LUD-16 addresses after the shape check; no row is written); 500 `{ error: 'Could not save the account' }` when create does not persist the address, the name-only update matches no row, or the name-only update returns a row whose `name` is not the requested name; creates by Lightning Address, or for an existing address updates **only** `name` via `updateAccountNameByLightningAddress` (keeps `viewKey` / `role` / other columns); returns `{ accounts: [{ name, lightningAddress, viewKey, created }] }`; logs `debug.accounts.provisioned` with created/updated counts (never viewKeys or the token). PATCH body `{ role }` and/or `{ lightningAddress: null }` → 400 unknown/missing; 404 missing account; 200 `serializeAccount` of the updated row; unlink also `deleteVerification` and logs `debug.accounts.lightning_address.cleared`; role changes log `debug.accounts.role_set` with account id and role. Never logs the token or the previous address. - **Used by:** `createApp` at `/debug/accounts`. ## Function: debugContactsRoutes @@ -366,9 +366,9 @@ ## Function: InMemoryMessageStore -- **Purpose:** Process-local `MessageStore` for the public member forum. Default empty so the process boots without a database. Photos live in a private map, not on listed rows. Same port as Postgres: `getById`, `getByEventId`, claim/sign/publish (`claimUnsigned` is pending + null `eventId`; lease expires at `claimedUntil`), `listPendingSigned` (pending, no `t=bitcoin`, oldest-first), `clearSignedEvent` (pending and `eventId` still matches `expectedEventId`, then nulls `eventId` / `nostrEvent` / `claimedUntil`), `listSignedMissingPhoto` (published + photo, kind:1 content lacks `/messages/:id/photo.` plus extension, oldest-first, `sats === 0`, pending excluded), `listSignedMissingHashtags` (published unpaid, kind:1 content lacks a `#bitcoin` or `#21gifts` token, oldest-first, `sats === 0`, pending excluded so fan-out is not starved), `resetSignedEvent` (nulls `eventId` / `nostrEvent` / `claimedUntil`, parks `pending`, no-op unless `eventId` still matches and `sats` is 0), `addSats`, `recordZapReceipt` (duplicate receipt id does not add sats), `recordInvoiceAttempt` / `listInvoiceAttempts`, `recordZapIngest` / `listZapIngests`; `updateSignedEvent` returns false on duplicate `eventId`. Store/HTTP order is newest-first; product UX is a messenger group (clients reverse). -- **Inputs:** Optional seed `MessageRow[]` (copied; `hasPhoto` defaults false). `listLatest(limit)` sorts newest `createdAt` then `id` DESC and caps at `limit`. `create(row, photo?)` appends a copy; `getPhoto(id)` returns a photo copy or null. -- **Returns / side effects:** Promise of row/photo copies; mutating results does not change the store. Listed objects never expose bytes. No I/O. +- **Purpose:** Process-local `MessageStore` for the public member forum. Default empty so the process boots without a database. Photos live in a private map, not on listed rows. Same port as Postgres: `getById`, `getByEventId`, claim/sign/publish (`claimUnsigned` is pending + null `eventId`; lease expires at `claimedUntil`), `listPendingSigned` (pending, no `t=bitcoin`, oldest-first), `clearSignedEvent` (pending and `eventId` still matches `expectedEventId`, then nulls `eventId` / `nostrEvent` / `claimedUntil`), `listSignedMissingPhoto` (published + photo, kind:1 content lacks `/messages/:id/photo.` plus extension, oldest-first, `sats === 0`, pending excluded, video rows excluded so posters are not treated as missing photos), `listSignedMissingVideo` (published + video MIME, kind:1 content lacks `/messages/:id/video.`, oldest-first, `sats === 0`, pending excluded), `listSignedMissingHashtags` (published unpaid, kind:1 content lacks a `#bitcoin` or `#21gifts` token, oldest-first, `sats === 0`, pending excluded so fan-out is not starved), `resetSignedEvent` (nulls `eventId` / `nostrEvent` / `claimedUntil`, parks `pending`, no-op unless `eventId` still matches and `sats` is 0), `addSats`, `recordZapReceipt` (duplicate receipt id does not add sats), `recordInvoiceAttempt` / `listInvoiceAttempts`, `recordZapIngest` / `listZapIngests`; `updateSignedEvent` returns false on duplicate `eventId`. Store/HTTP order is newest-first; product UX is a messenger group (clients reverse). +- **Inputs:** Optional seed `MessageRow[]` (copied; `hasPhoto` defaults false). `listLatest(limit)` sorts newest `createdAt` then `id` DESC and caps at `limit`. `create(row, photo?, video?)` appends a copy; `getPhoto(id)` returns a photo copy or null. +- **Returns / side effects:** Promise of row/photo copies; mutating results does not change the store. Listed objects never expose bytes. When `video` is set, `create` awaits `writeForumVideo` (disk under `MEDIA_DIR`); if that write throws, the row is never pushed (no unlink). - **Used by:** `createApp` default `messageStore`. ## Function: InMemoryContactStore @@ -520,7 +520,7 @@ ## Function: createApp -- **Purpose:** Wires CORS, requestLog, brand, health, info, auth, me, `/view`, lightning-address, `/debug/accounts`, `/debug/contacts`, `/debug/invoices`, `/debug/zap-ingests`, `/debug/push-ping`, Web Push subscription routes, `/gifts`, `/gifts/stats`, `/messages` (incl. invoice), `/contact`, and invoices. +- **Purpose:** Wires CORS, requestLog, brand, health, info, auth, me, `/view`, lightning-address, `/debug/accounts`, `/debug/contacts`, `/debug/invoices`, `/debug/zap-ingests`, `/debug/push-ping`, Web Push subscription routes, `/gifts`, `/gifts/stats`, `/messages` (incl. invoice), `/.well-known` NIP-05 `nostr.json` (CORS `*`), `/contact`, and invoices. - **Inputs:** Optional `AppDeps` (store, clock, payer, fetch, cache, readBrand, origins, `debugToken`, giftStore, `giftRecorder`, `btcUsdRates`, `messageStore`, `contactStore`, `pushStore`, `vapidPublicKey`, `nostrKek`, spendApiToken, invoiceStore, `webAuthnRpId`, `webAuthnRpName`, `passkeyCeremony`). Omitted `giftRecorder` → `invoiceRoutes` uses `NoopGiftRecorder`; omitted `messageStore` → `InMemoryMessageStore`; omitted `contactStore` → `InMemoryContactStore`; omitted `pushStore` → `InMemoryPushStore`; omitted/blank `vapidPublicKey` → push HTTP 503 after session; omitted `nostrKek` → unsigned forum + invoice 503; SQL boot injects `SqlGiftRecorder`, `PostgresMessageStore`, `PostgresContactStore`, `PostgresPushStore`, and parsed KEK. Does not take a push sender (worker owns delivery). - **Returns / side effects:** Hono app. Default `btcUsdRates` is an empty `InMemoryBtcUsdStore`. Used by Bun.serve in `index.ts` and by tests via `app.request()`. - **Used by:** Boot path and every HTTP test. @@ -569,9 +569,9 @@ ## Function: messagesRoutes -- **Purpose:** Hono sub-app for the public member forum: `GET /` lists newest-first (cap 200, `hasPhoto`, `sats`, `payable`, live `role`); `POST /` creates text and/or one photo when the account has a non-blank display name; `GET /:id/photo` serves raw bytes without auth (Nostr `imeta`); `POST /:id/invoice` returns `{ pr, amountSats }` only for a NIP-57 `description_hash` invoice (otherwise 400 author's-wallet copy + persist `not_zap` / `noZap`; invoice limiter after payable/KEK checks; post limiter on create). After a successful create, optional `pushStore` enqueues forum pushes for other subscribed accounts (`push.enqueue.failed` is swallowed; POST still 200). Product UX is a messenger group — clients reverse the newest-first list for display (oldest top, newest bottom). +- **Purpose:** Hono sub-app for the public member forum: `GET /` lists newest-first (cap 200, `hasPhoto`, `hasVideo`, `videoContentType`, `sats`, `payable`, live `role`); `POST /` creates text and/or one photo (JSON) or one video (multipart `video` + optional JPEG/PNG/WebP `poster`) when the account has a non-blank display name; `GET /:id/photo` serves raw bytes without auth (Nostr `imeta`); `GET /:id/video.mp4|.webm|.mov` streams stored files with `Accept-Ranges` / 206 / 416; `POST /:id/invoice` returns `{ pr, amountSats }` only for a NIP-57 `description_hash` invoice (otherwise 400 author's-wallet copy + persist `not_zap` / `noZap`; invoice limiter after payable/KEK checks; post limiter on create). After a successful create, optional `pushStore` enqueues forum pushes for other subscribed accounts (`push.enqueue.failed` is swallowed; POST still 200). Product UX is a messenger group — clients reverse the newest-first list for display (oldest top, newest bottom). - **Inputs:** `MessagesRouteDeps`: message `store`, shared `authStore`, `now`, optional `nostrKek`, `fetchImpl`, `postLimiter`, `invoiceLimiter`, optional `pushStore`. -- **Returns / side effects:** Hono app mounted at `/messages`. 401 without session on list/create/invoice; 400 on bad body / missing name / invalid text / bad photo / unpaid note ("This message cannot be paid yet") / author's wallet cannot receive this Bitcoin payment (`noZap`, `not_zap`) / Could not start the Bitcoin payment (`unreachable` and other LNURL transport failures); 404 photo missing; 429 on post or invoice rate limits (invoice only after payable checks; NIP-57 reject still counts like other LNURL failures); 503 on store/KEK/sign failure (`messages.list.failed` / `messages.create.failed` / `messages.photo.failed`). Public JSON includes `sats`/`payable`/`hasPhoto`/live `role` and omits `accountId` and photo bytes (missing author → `role` `"basis"` on list). +- **Returns / side effects:** Hono app mounted at `/messages`. 401 without session on list/create/invoice; 400 on bad body / missing name / invalid text / bad photo / bad poster / bad video / unpaid note ("This message cannot be paid yet") / author's wallet cannot receive this Bitcoin payment (`noZap`, `not_zap`) / Could not start the Bitcoin payment (`unreachable` and other LNURL transport failures); 404 photo/video missing; 416 unsatisfiable video Range; 429 on post or invoice rate limits (invoice only after payable checks; NIP-57 reject still counts like other LNURL failures); 503 on store/KEK/sign failure (`messages.list.failed` / `messages.create.failed` / `messages.photo.failed` / `messages.video.failed`). Public JSON includes `sats`/`payable`/`hasPhoto`/`hasVideo`/`videoContentType`/live `role` and omits `accountId` and media bytes (missing author → `role` `"basis"` on list). - **Used by:** `createApp`. ## Function: contactRoutes @@ -590,7 +590,7 @@ ## Function: normalizeForumText -- **Purpose:** Trim and validate forum message text. Empty/whitespace becomes `''` (valid for photo-only posts). Over-long (>500) or disallowed C0/DEL still reject; newlines `\n`/`\r` allowed. +- **Purpose:** Trim and validate forum message text. Empty/whitespace becomes `''` (valid for photo-only or video-only posts). Over-long (>500) or disallowed C0/DEL still reject; newlines `\n`/`\r` allowed. - **Inputs:** `raw` string. - **Returns / side effects:** Trimmed text (possibly empty) or `null`. No I/O. - **Used by:** `POST /messages`, `POST /contact`. @@ -611,9 +611,9 @@ ## Function: serializeMessage -- **Purpose:** Project a stored forum row to its public JSON shape including zap totals, payability, `hasPhoto`, and live author role. -- **Inputs:** `MessageRow` (includes `accountId`; never photo bytes), `payable` boolean, and `role` (`AccountRole`). -- **Returns / side effects:** `{ id, name, text, createdAt, sats, payable, hasPhoto, role }` with ISO-8601 `createdAt`; `accountId` omitted; never photo bytes. No I/O. +- **Purpose:** Project a stored forum row to its public JSON shape including zap totals, payability, `hasPhoto`, `hasVideo`, `videoContentType`, and live author role. +- **Inputs:** `MessageRow` (includes `accountId`; never photo/video bytes), `payable` boolean, and `role` (`AccountRole`). +- **Returns / side effects:** `{ id, name, text, createdAt, sats, payable, hasPhoto, hasVideo, videoContentType, role }` with ISO-8601 `createdAt`; `videoContentType` is null when `hasVideo` is false; `accountId` omitted; never photo/video bytes. No I/O. - **Used by:** `messagesRoutes`. ## Function: serializeContact @@ -791,6 +791,13 @@ - **Returns / side effects:** `{ ok: true, value: { challengeId, options } }` or `{ ok: false, error }` (`This profile could not be found.` / `This profile already has a passkey`). Persists a register challenge bound to the existing account id. - **Used by:** `POST /auth/passkey/register/begin` when the body includes a string `viewKey`. +## Function: accountSetup + +- **Purpose:** Next owner setup step from stored account fields. The api is the source of truth; clients only route. +- **Inputs:** `Account`. +- **Returns / side effects:** `'name'` when name is null/blank, else `'lightning-address'` when Lightning Address is null/blank, else `'rules'` when `rulesAgreedAt` is null, else `null`. No I/O. +- **Used by:** `serializeOwnerAccount`. + ## Function: serializeAccount - **Purpose:** Project an account to the nine-field dump without `viewKey` (no Nostr fields). @@ -800,9 +807,9 @@ ## Function: serializeOwnerAccount -- **Purpose:** Owner JSON for authenticated account responses: the nine public fields plus `viewKey`, so the owner can copy the capability URL. Used by `GET /me`, `/me` writes including `POST /me/rules-agreement`, and passkey finish — never by the debug listing. +- **Purpose:** Owner JSON for authenticated account responses: the nine public fields plus `viewKey` and `setup`, so the owner can copy the capability URL and the client can route onboarding. Used by `GET /me`, `/me` writes including `POST /me/rules-agreement`, and passkey finish — never by the debug listing. - **Inputs:** `Account`. -- **Returns / side effects:** `OwnerAccountResponse`. No I/O. +- **Returns / side effects:** `OwnerAccountResponse` (eleven fields including `setup`). No I/O. - **Used by:** `meRoutes`, `authRoutes`. ## Function: serializeViewProfile @@ -905,22 +912,22 @@ ## Function: buildKind1Event -- **Purpose:** Unsigned top-level kind:1 for a forum line. Optional photo appends the public image URL to content and a NIP-92 `imeta` tag. Always ensures Damus-visible `#bitcoin` / `#21gifts` via `kind1ContentWithHashtags`, appending only missing tokens (forum row `text` is not modified). -- **Inputs:** content, unix created_at, optional `{ url, mime }`. +- **Purpose:** Unsigned top-level kind:1 for a forum line. Optional media (`Kind1Photo`: image or video MIME) appends the public URL to content and a NIP-92 `imeta` tag; video may add `imeta` `image` from `posterUrl`. Always ensures Damus-visible `#bitcoin` / `#21gifts` via `kind1ContentWithHashtags`, appending only missing tokens (forum row `text` is not modified). +- **Inputs:** content, unix created_at, optional `{ url, mime, posterUrl? }`. - **Returns / side effects:** Unsigned fields. - **Used by:** Worker sign path. ## Function: buildKind0Content -- **Purpose:** Kind:0 JSON without extra whitespace (`name`, `display_name`, `website`, `picture`, optional `lud16`). -- **Inputs:** name, lightningAddress or null. -- **Returns / side effects:** JSON string; `picture` is always the 21.gifts icon; `lud16` only when address set. +- **Purpose:** Kind:0 JSON without extra whitespace (`name`, `display_name`, `website`, `picture`, `about: '21.gifts'`, optional `lud16`, optional `nip05`). +- **Inputs:** name, lightningAddress or null, optional nip05 or null. +- **Returns / side effects:** JSON string; `picture` is always the 21.gifts icon; `about` is always `21.gifts`; `lud16` only when address set; `nip05` only when a public identifier is passed. - **Used by:** `buildKind0Event`, worker `publishProfiles`. ## Function: buildKind0Event -- **Purpose:** Unsigned replaceable kind:0. -- **Inputs:** name, lightningAddress, unix created_at. +- **Purpose:** Unsigned replaceable kind:0, including optional `nip05`. +- **Inputs:** name, lightningAddress, unix created_at, optional nip05. - **Returns / side effects:** Unsigned fields. - **Used by:** Worker `publishProfiles`. @@ -1066,7 +1073,7 @@ ## Function: runNostrWorkerTick -- **Purpose:** Sign unsigned rows; fan out when `NOSTR_PUBLISH=1`. Space-only ACK is terminal `published`/`space`. With `NOSTR_PUBLISH_PUBLIC=1`, space-only parks `pending` until a public ACK. Pending kind:1 JSON without `t=bitcoin` is dropped and re-signed, then unsigned rows are signed. After that, published unpaid notes missing a photo URL (`PUBLIC_BASE_URL` set) or Damus `#bitcoin`/`#21gifts` in content are reset for the next tick. Pending rows EVENT as-is so a reset cannot renew the 60s sign lease. Zapped rows keep `eventId`. An empty API base skips photo-URL resign. Sign looks up photo bytes even when `hasPhoto` is stale. When publishing, also fans out kind:0 profiles (`name` / `display_name` / `picture`) and NIP-65 kind:10002 relay lists. Kind:1 photo posts include the public image URL and `imeta`. Each tick queries zap relays (space plus the public list, even when `NOSTR_PUBLISH_PUBLIC` is off) for kind:9735 and indexes validated receipts onto `sats`, even when `NOSTR_PUBLISH` is off. +- **Purpose:** Sign unsigned rows; fan out when `NOSTR_PUBLISH=1`. Space-only ACK is terminal `published`/`space`. With `NOSTR_PUBLISH_PUBLIC=1`, space-only parks `pending` until a public ACK. Pending kind:1 JSON without `t=bitcoin` is dropped and re-signed, then unsigned rows are signed. After that, published unpaid notes missing a photo URL, a video URL, or Damus `#bitcoin`/`#21gifts` in content are reset for the next tick (`PUBLIC_BASE_URL` set for media URLs; video posters are not treated as missing photos). Pending rows EVENT as-is so a reset cannot renew the 60s sign lease. Zapped rows keep `eventId`. An empty API base skips photo/video-URL resign. Sign looks up photo bytes even when `hasPhoto` is stale. When publishing, also fans out kind:0 profiles (`name` / `display_name` / `picture` / optional `nip05`) and NIP-65 kind:10002 relay lists. Kind:1 photo/video posts include the public media URL and `imeta`. Each tick queries zap relays (space plus the public list, even when `NOSTR_PUBLISH_PUBLIC` is off) for kind:9735 and indexes validated receipts onto `sats`, even when `NOSTR_PUBLISH` is off. - **Kind:0 cache:** Unchanged content is not resent for the life of the AuthStore instance. After the live account row is read, the worker stores a reservation object and treats only that object as owner after each await. A nack or throw deletes the reservation only when it is still that object; the last issued `created_at` watermark is kept so a retry in the same second still increments. Kind:0 `created_at` is `max(wall clock, last issued + 1)` so an in-flight older profile cannot win a same-second replaceable-event tie. - **Kind:0 batch:** At most `WORKER_BATCH` keyed attempts run per tick, including nacks. With public fan-out on, a space-only ACK is a nack and the profile is retried. - **Inputs:** worker deps. @@ -1114,3 +1121,122 @@ - **Inputs:** none. - **Returns / side effects:** Column defaults including `sats: 0`. - **Used by:** `POST /messages`, stores. + +## Function: allocateNip05Local + +- **Purpose:** Unique NIP-05 local-part; first slug wins, collisions append account-id hex. +- **Inputs:** name, account id, taken set. +- **Returns / side effects:** local-part string. +- **Used by:** `nip05Identifier`, `listNip05Entries`. + +## Function: buildNostrJson + +- **Purpose:** NIP-05 `names` + `relays` map for `GET /.well-known/nostr.json`. +- **Inputs:** auth store, env, optional name filter. +- **Returns / side effects:** JSON body. +- **Used by:** `wellKnownRoutes`. + +## Function: decodeForumVideo + +- **Purpose:** Size + magic-byte check for MP4/WebM/MOV (32 MiB cap). +- **Inputs:** raw bytes. +- **Returns / side effects:** `{ contentType, bytes }` or null. +- **Used by:** `POST /messages` multipart. + +## Function: detectVideoContentType + +- **Purpose:** `ftyp` / WebM magic → MIME. +- **Inputs:** bytes. +- **Returns / side effects:** MIME or null. +- **Used by:** `decodeForumVideo`. + +## Function: forumVideoExt + +- **Purpose:** Damus path extension for a video MIME. +- **Inputs:** MIME. +- **Returns / side effects:** `mp4` / `webm` / `mov`. +- **Used by:** public video URLs. + +## Function: forumVideoUrl + +- **Purpose:** Absolute `GET /messages/:id/video.mp4` (or `.webm` / `.mov`) URL. +- **Inputs:** API origin, message id, MIME. +- **Returns / side effects:** URL string. +- **Used by:** Worker sign path. + +## Function: listNip05Entries + +- **Purpose:** Named accounts with pubkeys, oldest first, unique locals. +- **Inputs:** auth store. +- **Returns / side effects:** `Nip05Entry[]`. +- **Used by:** `buildNostrJson`. + +## Function: nip05Domain + +- **Purpose:** Hostname from `PUBLIC_BASE_URL`; null for loopback/IP. +- **Inputs:** env. +- **Returns / side effects:** hostname or null. +- **Used by:** kind:0 `nip05`. + +## Function: nip05Identifier + +- **Purpose:** `local@domain` for one account matching `nostr.json`. +- **Inputs:** account, named accounts oldest-first, domain. +- **Returns / side effects:** identifier string. +- **Used by:** Worker kind:0. + +## Function: nip05Slug + +- **Purpose:** Display name → `a-z0-9-` local-part (`user` if empty). +- **Inputs:** name. +- **Returns / side effects:** slug. +- **Used by:** `allocateNip05Local`. + +## Function: parseBytesRange + +- **Purpose:** Parse `bytes=start-end` for 200 / 206 / 416 responses (RFC 7233). +- **Inputs:** header, file size. +- **Returns / side effects:** `{ type: 'full' }` | `{ type: 'partial'; start; end }` | `{ type: 'unsatisfiable' }`. +- **Used by:** `GET /messages/:id/video.*`. + +## Function: removeForumVideo + +- **Purpose:** Best-effort unlink of a stored video file. +- **Inputs:** message id, MIME, env. +- **Returns / side effects:** void. +- **Used by:** tests; create rollback. + +## Function: resolveMediaDir + +- **Purpose:** `MEDIA_DIR` or process temp `21gifts-media`. +- **Inputs:** env. +- **Returns / side effects:** directory path. +- **Used by:** video read/write. + +## Function: streamForumVideo + +- **Purpose:** Inclusive byte-range file stream for `GET /messages/:id/video.*` without loading the file into RAM. +- **Inputs:** absolute path, inclusive start, inclusive end. +- **Returns / side effects:** `ReadableStream` as the HTTP body. +- **Used by:** `serveForumVideo`. + +## Function: videoFilePath + +- **Purpose:** `{dir}/{id}.{ext}` on disk. +- **Inputs:** dir, id, MIME. +- **Returns / side effects:** path. +- **Used by:** write/read/serve. + +## Function: wellKnownRoutes + +- **Purpose:** Hono `GET /nostr.json` (CORS `*`). +- **Inputs:** auth store, env. +- **Returns / side effects:** Hono app mounted at `/.well-known`. +- **Used by:** `createApp`. + +## Function: writeForumVideo + +- **Purpose:** Persist video bytes under `MEDIA_DIR`. +- **Inputs:** message id, video, env. +- **Returns / side effects:** mkdir + writeFile. +- **Used by:** `MessageStore.create`. diff --git a/docs/schema/message.sql b/docs/schema/message.sql index f092c7f2..01b837d9 100644 --- a/docs/schema/message.sql +++ b/docs/schema/message.sql @@ -1,10 +1,11 @@ -- Public forum messages (GET/POST /messages, GET /messages/:id/photo, --- POST /messages/:id/invoice). Author display name is snapshotted at post --- time. Indexed newest-first for listLatest. Nostr columns are filled by the --- worker (event_id, signed JSON, publish state, sats). Optional photo (bytea) --- + photo_content_type; list queries must not SELECT the photo column — use --- (photo IS NOT NULL) AS has_photo only. ALTER ADD COLUMN IF NOT EXISTS keeps --- existing databases additive. +-- GET /messages/:id/video.*, POST /messages/:id/invoice). Author display name +-- is snapshotted at post time. Indexed newest-first for listLatest. Nostr +-- columns are filled by the worker (event_id, signed JSON, publish state, +-- sats). Optional photo (bytea) + photo_content_type; list queries must not +-- SELECT the photo column — use (photo IS NOT NULL) AS has_photo only. +-- Optional video_content_type; bytes on disk under MEDIA_DIR (not bytea). +-- ALTER ADD COLUMN IF NOT EXISTS keeps existing databases additive. CREATE TABLE IF NOT EXISTS message ( id uuid PRIMARY KEY, @@ -26,6 +27,8 @@ ALTER TABLE message ADD COLUMN IF NOT EXISTS nostr_publish_epoch text; ALTER TABLE message ADD COLUMN IF NOT EXISTS nostr_attempts integer NOT NULL DEFAULT 0; ALTER TABLE message ADD COLUMN IF NOT EXISTS photo bytea; ALTER TABLE message ADD COLUMN IF NOT EXISTS photo_content_type text; +-- Video MIME only; bytes live on disk under MEDIA_DIR (not bytea). +ALTER TABLE message ADD COLUMN IF NOT EXISTS video_content_type text; CREATE UNIQUE INDEX IF NOT EXISTS message_event_id_uidx ON message (event_id) WHERE event_id IS NOT NULL; CREATE TABLE IF NOT EXISTS nostr_zap_receipt ( event_id text PRIMARY KEY, diff --git a/e2e/functions.spec.ts b/e2e/functions.spec.ts index 1e577098..91337c21 100644 --- a/e2e/functions.spec.ts +++ b/e2e/functions.spec.ts @@ -811,6 +811,11 @@ test('Function: serializeAccount — GET /debug/accounts listing omits viewKey', } }); +test('Function: accountSetup — GET /me without bearer is 401', async ({ request }) => { + const res = await request.get('/me'); + expect(res.status()).toBe(401); +}); + test('Function: serializeOwnerAccount — GET /me without bearer is 401', async ({ request }) => { const res = await request.get('/me'); expect(res.status()).toBe(401); @@ -960,3 +965,86 @@ test('Function: unsignedNostrDefaults — GET /messages without bearer is 401', }) => { expect((await request.get('/messages')).status()).toBe(401); }); +test('Function: allocateNip05Local — GET /.well-known/nostr.json is 200', async ({ request }) => { + expect((await request.get('/.well-known/nostr.json')).status()).toBe(200); +}); +test('Function: buildNostrJson — GET /.well-known/nostr.json is 200', async ({ request }) => { + expect((await request.get('/.well-known/nostr.json')).status()).toBe(200); +}); +test('Function: decodeForumVideo — POST /messages without bearer is 401', async ({ request }) => { + const res = await request.post('/messages', { + data: { text: 'hi' }, + }); + expect(res.status()).toBe(401); +}); +test('Function: detectVideoContentType — POST /messages without bearer is 401', async ({ + request, +}) => { + const res = await request.post('/messages', { + data: { text: 'hi' }, + }); + expect(res.status()).toBe(401); +}); +test('Function: forumVideoExt — POST /messages without bearer is 401', async ({ request }) => { + const res = await request.post('/messages', { + data: { text: 'hi' }, + }); + expect(res.status()).toBe(401); +}); +test('Function: forumVideoUrl — POST /messages without bearer is 401', async ({ request }) => { + const res = await request.post('/messages', { + data: { text: 'hi' }, + }); + expect(res.status()).toBe(401); +}); +test('Function: listNip05Entries — GET /.well-known/nostr.json is 200', async ({ request }) => { + expect((await request.get('/.well-known/nostr.json')).status()).toBe(200); +}); +test('Function: nip05Domain — GET /.well-known/nostr.json is 200', async ({ request }) => { + expect((await request.get('/.well-known/nostr.json')).status()).toBe(200); +}); +test('Function: nip05Identifier — GET /.well-known/nostr.json is 200', async ({ request }) => { + expect((await request.get('/.well-known/nostr.json')).status()).toBe(200); +}); +test('Function: nip05Slug — GET /.well-known/nostr.json is 200', async ({ request }) => { + expect((await request.get('/.well-known/nostr.json')).status()).toBe(200); +}); +test('Function: parseBytesRange — POST /messages without bearer is 401', async ({ request }) => { + const res = await request.post('/messages', { + data: { text: 'hi' }, + }); + expect(res.status()).toBe(401); +}); +test('Function: removeForumVideo — POST /messages without bearer is 401', async ({ request }) => { + const res = await request.post('/messages', { + data: { text: 'hi' }, + }); + expect(res.status()).toBe(401); +}); +test('Function: streamForumVideo — POST /messages without bearer is 401', async ({ request }) => { + const res = await request.post('/messages', { + data: { text: 'hi' }, + }); + expect(res.status()).toBe(401); +}); +test('Function: resolveMediaDir — POST /messages without bearer is 401', async ({ request }) => { + const res = await request.post('/messages', { + data: { text: 'hi' }, + }); + expect(res.status()).toBe(401); +}); +test('Function: videoFilePath — POST /messages without bearer is 401', async ({ request }) => { + const res = await request.post('/messages', { + data: { text: 'hi' }, + }); + expect(res.status()).toBe(401); +}); +test('Function: wellKnownRoutes — GET /.well-known/nostr.json is 200', async ({ request }) => { + expect((await request.get('/.well-known/nostr.json')).status()).toBe(200); +}); +test('Function: writeForumVideo — POST /messages without bearer is 401', async ({ request }) => { + const res = await request.post('/messages', { + data: { text: 'hi' }, + }); + expect(res.status()).toBe(401); +}); diff --git a/e2e/http.spec.ts b/e2e/http.spec.ts index 2c141836..2eac86fd 100644 --- a/e2e/http.spec.ts +++ b/e2e/http.spec.ts @@ -141,6 +141,22 @@ test('GET /messages/:id/photo.webp without bearer is 404', async ({ request }) = const res = await request.get('/messages/:id/photo.webp'); expect(res.status()).toBe(404); }); +test('GET /messages/:id/video.mp4 without bearer is 404', async ({ request }) => { + const res = await request.get('/messages/:id/video.mp4'); + expect(res.status()).toBe(404); +}); +test('GET /messages/:id/video.webm without bearer is 404', async ({ request }) => { + const res = await request.get('/messages/:id/video.webm'); + expect(res.status()).toBe(404); +}); +test('GET /messages/:id/video.mov without bearer is 404', async ({ request }) => { + const res = await request.get('/messages/:id/video.mov'); + expect(res.status()).toBe(404); +}); +test('GET /.well-known/nostr.json is 200', async ({ request }) => { + const res = await request.get('/.well-known/nostr.json'); + expect(res.status()).toBe(200); +}); test('POST /me/name without bearer is 401', async ({ request }) => { const res = await request.post('/me/name', { diff --git a/scripts/check-handbook.mjs b/scripts/check-handbook.mjs index ae53d953..bf507681 100644 --- a/scripts/check-handbook.mjs +++ b/scripts/check-handbook.mjs @@ -138,6 +138,7 @@ function extractEndpoints() { 'debug-contacts.ts': '/debug/contacts', 'debug-payments.ts': '/debug', 'debug-push.ts': '/debug/push-ping', + 'well-known.ts': '/.well-known', }; const methodRe = /\.(get|post|delete|put|patch)\((['"])(\/[-A-Za-z0-9_./:]*)\2/g; for (const file of fs.readdirSync(routeDir).filter((n) => n.endsWith('.ts'))) { diff --git a/scripts/gifts-debug.sh b/scripts/gifts-debug.sh index 519cf4d3..25cd7b50 100755 --- a/scripts/gifts-debug.sh +++ b/scripts/gifts-debug.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # -# gifts-debug — operator listing and role assignment for 21.gifts accounts -# (GET /debug/accounts, PATCH /debug/accounts/:id). No raw SQL. +# gifts-debug — operator listing, role assignment, and Lightning Address unlink +# for 21.gifts accounts (GET /debug/accounts, PATCH /debug/accounts/:id). +# No raw SQL. # # Credentials (never in this script, never printed): # ~/.config/21gifts/debug.env -> DEBUG_TOKEN, DEBUG_API_URL @@ -11,11 +12,13 @@ # gifts-debug auth # check token; print account count on stderr # gifts-debug accounts [--raw] # table (default) or JSON # gifts-debug role # set account.role; print updated account JSON +# gifts-debug unlink # hard-delete Lightning Address; print updated account JSON # # Example: # gifts-debug accounts # gifts-debug accounts --raw # gifts-debug role moderator +# gifts-debug unlink # set -euo pipefail @@ -110,6 +113,27 @@ cmd_role() { printf '%s\n' "$body" } +cmd_unlink() { + local id="${1:-}" tmp status body + [ -n "$id" ] || die "usage: gifts-debug unlink " + tmp=$(mktemp) + status=$(curl -sS -o "$tmp" -w '%{http_code}' \ + -X PATCH \ + -H "Authorization: Bearer ${DEBUG_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"lightningAddress":null}' \ + "${DEBUG_API_URL}/debug/accounts/${id}") || { + rm -f "$tmp" + die "request failed" + } + body=$(cat "$tmp") + rm -f "$tmp" + if [ "$status" != "200" ]; then + die "HTTP ${status}: ${body}" + fi + printf '%s\n' "$body" +} + ARGS=() while [ $# -gt 0 ]; do case "$1" in @@ -126,6 +150,7 @@ case "${1:-}" in auth) cmd_auth ;; accounts) cmd_accounts ;; role) shift; cmd_role "$@" ;; + unlink) shift; cmd_unlink "$@" ;; ""|-h|--help) usage 0 ;; *) die "unknown command: $1" ;; esac diff --git a/src/__tests__/lib/auth/account-json.test.ts b/src/__tests__/lib/auth/account-json.test.ts index cd65f45b..23d93084 100644 --- a/src/__tests__/lib/auth/account-json.test.ts +++ b/src/__tests__/lib/auth/account-json.test.ts @@ -53,8 +53,10 @@ describe('serializeOwnerAccount', () => { createdAt: 1, rulesAgreedAt: null, viewKey: 'a'.repeat(64), + setup: 'rules', }); expect(json.viewKey).toBe(account.viewKey); + expect(json.setup).toBe('rules'); }); }); diff --git a/src/__tests__/lib/auth/account-setup.test.ts b/src/__tests__/lib/auth/account-setup.test.ts new file mode 100644 index 00000000..0085e9d4 --- /dev/null +++ b/src/__tests__/lib/auth/account-setup.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { accountSetup } from '@/lib/auth/account-setup'; +import type { Account } from '@/lib/auth/store'; + +const base: Account = { + id: 'acc', + linkingKey: null, + role: 'basis', + name: null, + lightningAddress: null, + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: 'a'.repeat(64), + createdAt: 1, + rulesAgreedAt: null, +}; + +describe('accountSetup', () => { + it('asks for a name first', () => { + expect(accountSetup(base)).toBe('name'); + }); + + it('treats a blank name as missing', () => { + expect(accountSetup({ ...base, name: ' ' })).toBe('name'); + }); + + it('asks for a Lightning Address after a name', () => { + expect(accountSetup({ ...base, name: 'Ada' })).toBe('lightning-address'); + }); + + it('treats a blank Lightning Address as missing', () => { + expect(accountSetup({ ...base, name: 'Ada', lightningAddress: ' ' })).toBe( + 'lightning-address', + ); + }); + + it('asks for rules after name and Lightning Address', () => { + expect( + accountSetup({ + ...base, + name: 'Ada', + lightningAddress: 'ada@walletofsatoshi.com', + }), + ).toBe('rules'); + }); + + it('is complete when name, Lightning Address, and rules are set', () => { + expect( + accountSetup({ + ...base, + name: 'Ada', + lightningAddress: 'ada@walletofsatoshi.com', + rulesAgreedAt: 2, + }), + ).toBeNull(); + }); +}); diff --git a/src/__tests__/lib/message-store.test.ts b/src/__tests__/lib/message-store.test.ts index 3bb3aae0..b4a0ca4c 100644 --- a/src/__tests__/lib/message-store.test.ts +++ b/src/__tests__/lib/message-store.test.ts @@ -349,6 +349,139 @@ describe('InMemoryMessageStore', () => { expect((await store.getById('a'))?.eventId).toBe('cd'.repeat(32)); }); + it('listSignedMissingVideo and resetSignedEvent re-queue video posts', async () => { + const store = new InMemoryMessageStore(); + const mp4 = new Uint8Array(32); + mp4.set([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d]); + const video = { contentType: 'video/mp4' as const, bytes: mp4 }; + await store.create( + { ...EARLY, text: 'clip', hasVideo: true, videoContentType: 'video/mp4' }, + undefined, + video, + ); + await store.updateSignedEvent('a', 'ab'.repeat(32), { content: '' }); + await store.updatePublishState('a', 'published', 'space'); + await store.create( + { + ...EARLY, + id: 'n', + createdAt: new Date('2026-07-01T00:00:00.000Z'), + hasVideo: true, + videoContentType: 'video/mp4', + eventId: '11'.repeat(32), + nostrEvent: null, + }, + undefined, + video, + ); + await store.create( + { + ...EARLY, + id: 'z', + createdAt: new Date('2026-09-01T00:00:00.000Z'), + hasVideo: true, + videoContentType: 'video/mp4', + eventId: '22'.repeat(32), + nostrEvent: { content: 1 }, + }, + undefined, + video, + ); + const tiedAt = new Date('2026-08-15T00:00:00.000Z'); + await store.create( + { + ...EARLY, + id: 'q', + createdAt: tiedAt, + hasVideo: true, + videoContentType: 'video/mp4', + eventId: '33'.repeat(32), + nostrEvent: { content: '' }, + }, + undefined, + video, + ); + await store.create( + { + ...EARLY, + id: 'p', + createdAt: tiedAt, + hasVideo: true, + videoContentType: 'video/mp4', + eventId: '44'.repeat(32), + nostrEvent: { content: '' }, + }, + undefined, + video, + ); + await store.updatePublishState('n', 'published', 'space'); + await store.updatePublishState('p', 'published', 'space'); + await store.updatePublishState('q', 'published', 'space'); + await store.updatePublishState('z', 'published', 'space'); + expect((await store.listSignedMissingVideo(10)).map((row) => row.id)).toEqual([ + 'n', + 'a', + 'p', + 'q', + 'z', + ]); + await store.create( + { + ...EARLY, + id: 'pending-video', + createdAt: new Date('2026-06-01T00:00:00.000Z'), + hasVideo: true, + videoContentType: 'video/mp4', + eventId: '55'.repeat(32), + nostrEvent: { content: '' }, + }, + undefined, + video, + ); + expect((await store.listSignedMissingVideo(10)).map((row) => row.id)).not.toContain( + 'pending-video', + ); + await store.addSats('z', 21); + expect((await store.listSignedMissingVideo(10)).map((row) => row.id)).toEqual([ + 'n', + 'a', + 'p', + 'q', + ]); + await store.resetSignedEvent('z', '22'.repeat(32)); + expect((await store.getById('z'))?.eventId).toBe('22'.repeat(32)); + await store.resetSignedEvent('a', 'ab'.repeat(32)); + expect((await store.getById('a'))?.eventId).toBeNull(); + expect((await store.getById('a'))?.nostrPublishState).toBe('pending'); + await store.updateSignedEvent('a', 'cd'.repeat(32), { + content: 'http://127.0.0.1:3000/messages/a/video.mp4', + }); + expect((await store.listSignedMissingVideo(10)).map((row) => row.id)).toEqual(['n', 'p', 'q']); + await store.resetSignedEvent('a', 'ff'.repeat(32)); + expect((await store.getById('a'))?.eventId).toBe('cd'.repeat(32)); + }); + + it('does not re-queue a published video poster as a missing photo', async () => { + const store = new InMemoryMessageStore(); + const jpeg: ForumPhoto = { + contentType: 'image/jpeg', + bytes: new Uint8Array([0xff, 0xd8, 0xff, 0xd9]), + }; + const mp4 = new Uint8Array(32); + mp4.set([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d]); + await store.create( + { ...EARLY, id: 'clip', text: 'clip', hasPhoto: true, hasVideo: true }, + jpeg, + { contentType: 'video/mp4', bytes: mp4 }, + ); + await store.updateSignedEvent('clip', 'ab'.repeat(32), { + content: 'clip\nhttp://127.0.0.1:3000/messages/clip/video.mp4', + }); + await store.updatePublishState('clip', 'published', 'space'); + expect((await store.listSignedMissingPhoto(10)).map((row) => row.id)).not.toContain('clip'); + expect((await store.listSignedMissingVideo(10)).map((row) => row.id)).not.toContain('clip'); + }); + it('listSignedMissingHashtags finds unpaid notes missing Damus hashtags', async () => { const store = new InMemoryMessageStore(); const jpeg: ForumPhoto = { @@ -612,6 +745,7 @@ describe('PostgresMessageStore', () => { text: 'hi', created_at: new Date('2026-08-28T12:00:00.000Z'), has_photo: true, + video_content_type: 'video/mp4', }, { id: 'm2', @@ -620,6 +754,7 @@ describe('PostgresMessageStore', () => { text: 'yo', created_at: '2026-08-27T12:00:00.000Z', has_photo: false, + video_content_type: '', }, ]; const store = new PostgresMessageStore(sql); @@ -633,9 +768,11 @@ describe('PostgresMessageStore', () => { expect(sql.queries[0]?.params).toEqual([50]); expect(listed[0]?.id).toBe('m1'); expect(listed[0]?.hasPhoto).toBe(true); + expect(listed[0]?.hasVideo).toBe(true); expect(listed[0]?.sats).toBe(0); expect(listed[1]?.id).toBe('m2'); expect(listed[1]?.hasPhoto).toBe(false); + expect(listed[1]?.hasVideo).toBe(false); }); it('create binds seven params with null photo', async () => { @@ -652,7 +789,7 @@ describe('PostgresMessageStore', () => { }; const created = await store.create(row); expect(sql.executes[0]?.text).toMatch( - /INSERT INTO message \(id, account_id, name, text, photo, photo_content_type, created_at, nostr_publish_state, sats\)/, + /INSERT INTO message \(id, account_id, name, text, photo, photo_content_type, video_content_type, created_at, nostr_publish_state, sats\)/, ); expect(sql.executes[0]?.text).not.toMatch(/ON CONFLICT/i); expect(sql.executes[0]?.params).toEqual([ @@ -662,9 +799,11 @@ describe('PostgresMessageStore', () => { 'hello', null, null, + null, row.createdAt, ]); - expect(created).toEqual(row); + expect(created.id).toBe(row.id); + expect(created.hasVideo).toBe(false); expect(created).not.toBe(row); }); @@ -685,6 +824,25 @@ describe('PostgresMessageStore', () => { expect(sql.executes[0]?.params[5]).toBe('image/jpeg'); }); + it('create writes video bytes then binds video_content_type', async () => { + const sql = new MockSql(); + const store = new PostgresMessageStore(sql); + const mp4 = new Uint8Array(32); + mp4.set([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d]); + const row: MessageRow = { + id: 'm-vid-pg', + accountId: 'acc', + name: 'Ada', + text: 'clip', + createdAt: new Date('2026-08-28T12:00:00.000Z'), + hasPhoto: false, + ...unsignedNostrDefaults(), + }; + const created = await store.create(row, undefined, { contentType: 'video/mp4', bytes: mp4 }); + expect(created.hasVideo).toBe(true); + expect(sql.executes[0]?.params[6]).toBe('video/mp4'); + }); + it('create binds text together with photo bytes', async () => { const sql = new MockSql(); const row: MessageRow = { @@ -812,6 +970,28 @@ describe('PostgresMessageStore', () => { ).rejects.toThrow('create boom'); }); + it('unlinks a written video when the insert fails', async () => { + const sql = new MockSql(); + sql.executeError = new Error('create boom'); + const mp4 = new Uint8Array(32); + mp4.set([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d]); + await expect( + new PostgresMessageStore(sql).create( + { + id: 'm-vid-fail', + accountId: 'acc', + name: 'Ada', + text: 'clip', + createdAt: new Date(0), + hasPhoto: false, + ...unsignedNostrDefaults(), + }, + undefined, + { contentType: 'video/mp4', bytes: mp4 }, + ), + ).rejects.toThrow('create boom'); + }); + it('getByEventId SQL matches event_id and the same SELECT column list as getById', async () => { const sql = new MockSql(); sql.nextRows = [ @@ -940,6 +1120,34 @@ describe('PostgresMessageStore', () => { expect(sql.executes.at(-1)?.text).toMatch(/sats = 0/); }); + it('listSignedMissingVideo hits Postgres', async () => { + const sql = new MockSql(); + sql.nextRows = [ + { + id: 'm1', + account_id: 'acc', + name: 'Ada', + text: 'clip', + created_at: new Date(0), + has_photo: false, + video_content_type: 'video/mp4', + event_id: 'ab'.repeat(32), + nostr_publish_state: 'published', + sats: 0, + }, + ]; + const store = new PostgresMessageStore(sql); + const missing = await store.listSignedMissingVideo(4); + expect(missing[0]?.id).toBe('m1'); + const listSql = sql.queries.at(-1)?.text ?? ''; + expect(listSql).toMatch( + /video_content_type IN \('video\/mp4', 'video\/webm', 'video\/quicktime'\)/, + ); + expect(listSql).toMatch(/sats = 0/); + expect(listSql).toMatch(/nostr_publish_state = 'published'/); + expect(listSql).toMatch(/\/messages\/' \|\| id::text \|\| '\/video\./); + }); + it('listSignedMissingHashtags hits Postgres', async () => { const sql = new MockSql(); sql.nextRows = [ diff --git a/src/__tests__/lib/message.test.ts b/src/__tests__/lib/message.test.ts index 094435fb..2aa447ce 100644 --- a/src/__tests__/lib/message.test.ts +++ b/src/__tests__/lib/message.test.ts @@ -73,6 +73,8 @@ describe('serializeMessage', () => { sats: 0, payable: true, hasPhoto: false, + hasVideo: false, + videoContentType: null, role: 'moderator', }); expect(serializeMessage(row, false, 'basis')).not.toHaveProperty('accountId'); @@ -90,6 +92,23 @@ describe('serializeMessage', () => { }; expect(serializeMessage(row, false, 'basis').hasPhoto).toBe(true); }); + + it('includes hasVideo and videoContentType', () => { + const row: MessageRow = { + id: 'msg-3', + accountId: 'acc-1', + name: 'Ada', + text: 'clip', + createdAt: new Date('2026-08-28T12:00:00.000Z'), + hasPhoto: false, + hasVideo: true, + videoContentType: 'video/mp4', + ...unsignedNostrDefaults(), + }; + const publicMsg = serializeMessage(row, false, 'basis'); + expect(publicMsg.hasVideo).toBe(true); + expect(publicMsg.videoContentType).toBe('video/mp4'); + }); }); describe('detectImageContentType', () => { diff --git a/src/__tests__/lib/nip05.test.ts b/src/__tests__/lib/nip05.test.ts new file mode 100644 index 00000000..2427bc2e --- /dev/null +++ b/src/__tests__/lib/nip05.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest'; +import { InMemoryAuthStore, type Account } from '@/lib/auth/store'; +import { + allocateNip05Local, + buildNostrJson, + nip05Domain, + nip05Identifier, + nip05Slug, +} from '@/lib/nip05'; + +function account(partial: Partial & Pick): Account { + return { + linkingKey: null, + role: 'basis', + lightningAddress: null, + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: 'ab'.repeat(32), + createdAt: 1, + rulesAgreedAt: null, + ...partial, + }; +} + +describe('nip05', () => { + it('slugs names and allocates unique locals', () => { + expect(nip05Slug('Ada Lovelace')).toBe('ada-lovelace'); + expect(nip05Slug('!!!')).toBe('user'); + const taken = new Set(['ada']); + expect(allocateNip05Local('Ada', '11111111-1111-1111-1111-111111111111', taken)).toBe( + 'ada-11111111', + ); + }); + + it('uses the public host and skips loopback', () => { + expect(nip05Domain({ PUBLIC_BASE_URL: 'https://21.gifts' })).toBe('21.gifts'); + expect(nip05Domain({ PUBLIC_BASE_URL: 'https://dev.21.gifts/' })).toBe('dev.21.gifts'); + expect(nip05Domain({ PUBLIC_BASE_URL: 'http://127.0.0.1:3000' })).toBeNull(); + expect(nip05Domain({ PUBLIC_BASE_URL: 'http://[::1]:3000' })).toBeNull(); + expect(nip05Domain({ PUBLIC_BASE_URL: 'not-a-url' })).toBeNull(); + expect(nip05Domain({})).toBeNull(); + const taken = new Set(['ada', 'ada-11111111']); + expect(allocateNip05Local('Ada', '11111111-1111-1111-1111-111111111111', taken)).toContain( + 'ada-', + ); + const hex = '11111111111111111111111111111111'; + const exhausted = new Set(['ada']); + for (let n = 8; n <= hex.length; n += 1) { + exhausted.add(`ada-${hex.slice(0, n)}`.slice(0, 32)); + } + expect(allocateNip05Local('Ada', '11111111-1111-1111-1111-111111111111', exhausted)).toBe( + 'ada-2', + ); + exhausted.add('ada-2'); + expect(allocateNip05Local('Ada', '11111111-1111-1111-1111-111111111111', exhausted)).toBe( + 'ada-3', + ); + }); + + it('builds nostr.json names and relays', async () => { + const auth = new InMemoryAuthStore(); + const ada = account({ + id: '00000000-0000-4000-8000-000000000001', + name: 'Ada', + createdAt: 1, + }); + await auth.createAccount(ada); + await auth.createAccount( + account({ + id: '00000000-0000-4000-8000-000000000003', + name: 'NoKey', + createdAt: 2, + viewKey: 'ee'.repeat(32), + }), + ); + await auth.createAccount( + account({ + id: '00000000-0000-4000-8000-00000000000a', + name: 'Zed', + createdAt: 1, + viewKey: 'ff'.repeat(32), + }), + ); + await auth.setNostrKeyIfAbsent('00000000-0000-4000-8000-00000000000a', { + pubkey: 'bb'.repeat(32), + ciphertext: new Uint8Array(16), + kekId: 1, + custody: 'custodial', + }); + await auth.setNostrKeyIfAbsent(ada.id, { + pubkey: 'aa'.repeat(32), + ciphertext: new Uint8Array(16), + kekId: 1, + custody: 'custodial', + }); + const body = await buildNostrJson(auth, { NOSTR_PUBLISH: '1' }, 'ada'); + expect(body.names['ada']).toBe('aa'.repeat(32)); + expect(body.relays['aa'.repeat(32)]?.length).toBeGreaterThan(0); + const identifier = nip05Identifier(ada, [ada], '21.gifts'); + expect(identifier).toBe('ada@21.gifts'); + const skipped = account({ + id: '00000000-0000-4000-8000-000000000099', + name: ' ', + createdAt: 0, + }); + expect(nip05Identifier(ada, [skipped, ada], '21.gifts')).toBe('ada@21.gifts'); + const unknown = account({ id: '00000000-0000-4000-8000-000000000002', name: 'Bob' }); + expect(nip05Identifier(unknown, [ada], '21.gifts')).toBe('bob@21.gifts'); + const all = await buildNostrJson(auth, {}, ''); + expect(all.names['ada']).toBe('aa'.repeat(32)); + }); + + it('reserves slugs for named accounts without a pubkey so kind:0 matches nostr.json', async () => { + const auth = new InMemoryAuthStore(); + const older = account({ + id: '00000000-0000-4000-8000-000000000001', + name: 'Ada', + createdAt: 1, + }); + const younger = account({ + id: '00000000-0000-4000-8000-00000000000a', + name: 'Ada', + createdAt: 2, + viewKey: 'ff'.repeat(32), + }); + await auth.createAccount(older); + await auth.createAccount(younger); + await auth.setNostrKeyIfAbsent(younger.id, { + pubkey: 'aa'.repeat(32), + ciphertext: new Uint8Array(16), + kekId: 1, + custody: 'custodial', + }); + const named = [older, younger]; + const identifier = nip05Identifier(younger, named, '21.gifts'); + const body = await buildNostrJson(auth, {}, 'ada'); + expect(body.names['ada']).toBeUndefined(); + const local = identifier.slice(0, identifier.indexOf('@')); + expect(local).toBe('ada-00000000'); + const filtered = await buildNostrJson(auth, {}, local); + expect(filtered.names[local]).toBe('aa'.repeat(32)); + }); +}); diff --git a/src/__tests__/lib/nostr/event.test.ts b/src/__tests__/lib/nostr/event.test.ts index fcc386d0..95a13ca1 100644 --- a/src/__tests__/lib/nostr/event.test.ts +++ b/src/__tests__/lib/nostr/event.test.ts @@ -86,6 +86,7 @@ describe('kind0', () => { display_name: 'Ada', website: 'https://21.gifts', picture: KIND0_PICTURE_URL, + about: '21.gifts', }); expect(forumPhotoUrl('https://api.21.gifts/', 'm1')).toBe( 'https://api.21.gifts/messages/m1/photo.jpg', diff --git a/src/__tests__/lib/nostr/worker.test.ts b/src/__tests__/lib/nostr/worker.test.ts index 2101a649..58bd1a33 100644 --- a/src/__tests__/lib/nostr/worker.test.ts +++ b/src/__tests__/lib/nostr/worker.test.ts @@ -453,6 +453,7 @@ describe('runNostrWorkerTick', () => { display_name: 'Ada', website: 'https://21.gifts', picture: 'https://21.gifts/apple-touch-icon.png', + about: '21.gifts', }); expect(kinds).toContain(10002); expect(kinds).toContain(1); @@ -583,6 +584,110 @@ describe('runNostrWorkerTick', () => { ]); }); + it('embeds a public video URL and poster imeta on kind:1', async () => { + const { auth, messages } = await seed(); + const mp4 = new Uint8Array(32); + mp4.set([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d]); + await messages.create( + { + id: 'm-vid', + accountId: 'acc', + name: 'Ada', + text: 'clip', + createdAt: new Date('2026-08-28T00:02:30.000Z'), + hasPhoto: true, + hasVideo: true, + videoContentType: 'video/mp4', + ...unsignedNostrDefaults(), + }, + { contentType: 'image/jpeg', bytes: new Uint8Array([0xff, 0xd8, 0xff, 0xd9]) }, + { contentType: 'video/mp4', bytes: mp4 }, + ); + const publisher = new RecordingPublisher(); + const env = { + NOSTR_PUBLISH: '1', + NOSTR_RELAY_SPACE: 'wss://relay.nostr.space', + PUBLIC_BASE_URL: 'https://dev.21.gifts', + }; + await runNostrWorkerTick( + deps({ + messages, + auth, + kek: KEK, + publisher, + now: () => 1_700_000_000_000, + env, + }), + ); + await runNostrWorkerTick( + deps({ + messages, + auth, + kek: KEK, + publisher, + now: () => 1_700_000_060_000, + env, + }), + ); + const note = publisher.calls.find( + (call) => call.event['kind'] === 1 && String(call.event['content']).includes('m-vid/video'), + ); + expect(String(note?.event['content'])).toContain( + 'https://dev-api.21.gifts/messages/m-vid/video.mp4', + ); + expect(note?.event['tags']).toEqual( + expect.arrayContaining([ + [ + 'imeta', + 'url https://dev-api.21.gifts/messages/m-vid/video.mp4', + 'm video/mp4', + 'image https://dev-api.21.gifts/messages/m-vid/photo.jpg', + ], + ]), + ); + }); + + it('embeds a video URL without a poster when none is stored', async () => { + const { auth, messages } = await seed(); + const mp4 = new Uint8Array(32); + mp4.set([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d]); + await messages.create( + { + id: 'm-vid2', + accountId: 'acc', + name: 'Ada', + text: 'clip2', + createdAt: new Date('2026-08-28T00:02:31.000Z'), + hasPhoto: false, + hasVideo: true, + videoContentType: 'video/mp4', + ...unsignedNostrDefaults(), + }, + undefined, + { contentType: 'video/mp4', bytes: mp4 }, + ); + const publisher = new RecordingPublisher(); + const env = { + NOSTR_PUBLISH: '1', + NOSTR_RELAY_SPACE: 'wss://relay.nostr.space', + PUBLIC_BASE_URL: 'https://dev.21.gifts', + }; + await runNostrWorkerTick( + deps({ messages, auth, kek: KEK, publisher, now: () => 1_700_000_000_000, env }), + ); + await runNostrWorkerTick( + deps({ messages, auth, kek: KEK, publisher, now: () => 1_700_000_060_000, env }), + ); + const note = publisher.calls.find( + (call) => call.event['kind'] === 1 && String(call.event['content']).includes('m-vid2/video'), + ); + expect(note?.event['tags']).toEqual( + expect.arrayContaining([ + ['imeta', 'url https://dev-api.21.gifts/messages/m-vid2/video.mp4', 'm video/mp4'], + ]), + ); + }); + it('re-signs published photo posts that lack the photo URL', async () => { const { auth, messages } = await seed(); await messages.create( @@ -676,6 +781,109 @@ describe('runNostrWorkerTick', () => { expect(row?.nostrPublishState).toBe('published'); }); + it('re-signs published video posts that lack the video URL', async () => { + const { auth, messages } = await seed(); + const mp4 = new Uint8Array(32); + mp4.set([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d]); + await messages.create( + { + id: 'm-video', + accountId: 'acc', + name: 'Ada', + text: '', + createdAt: new Date('2026-08-28T00:04:30.000Z'), + hasPhoto: false, + hasVideo: true, + videoContentType: 'video/mp4', + ...unsignedNostrDefaults(), + }, + undefined, + { contentType: 'video/mp4', bytes: mp4 }, + ); + await messages.updateSignedEvent('m-video', 'ab'.repeat(32), { + kind: 1, + content: '#bitcoin #21gifts', + tags: [ + ['t', 'bitcoin'], + ['t', '21gifts'], + ['r', 'https://21.gifts'], + ], + }); + await messages.updatePublishState('m-video', 'published', 'space'); + const env = { + NOSTR_PUBLISH: '1', + NOSTR_RELAY_SPACE: 'wss://relay.nostr.space', + PUBLIC_BASE_URL: 'http://127.0.0.1:3000', + }; + await runNostrWorkerTick( + deps({ + messages, + auth, + kek: KEK, + publisher: new RecordingPublisher(), + now: () => 1_700_000_000_000, + env, + }), + ); + await runNostrWorkerTick( + deps({ + messages, + auth, + kek: KEK, + publisher: new RecordingPublisher(), + now: () => 1_700_000_060_000, + env, + }), + ); + const row = await messages.getById('m-video'); + expect(row?.eventId).not.toBe('ab'.repeat(32)); + expect(String(row?.nostrEvent?.['content'])).toContain('/messages/m-video/video.mp4'); + }); + + it('does not reset published video posts when PUBLIC_BASE_URL is unset', async () => { + const { auth, messages } = await seed(); + const mp4 = new Uint8Array(32); + mp4.set([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d]); + await messages.create( + { + id: 'm-novideo-url', + accountId: 'acc', + name: 'Ada', + text: '', + createdAt: new Date('2026-08-28T00:04:45.000Z'), + hasPhoto: false, + hasVideo: true, + videoContentType: 'video/mp4', + ...unsignedNostrDefaults(), + }, + undefined, + { contentType: 'video/mp4', bytes: mp4 }, + ); + await messages.updateSignedEvent('m-novideo-url', 'ab'.repeat(32), { + kind: 1, + content: '#bitcoin #21gifts', + tags: [ + ['t', 'bitcoin'], + ['t', '21gifts'], + ['r', 'https://21.gifts'], + ], + }); + await messages.updatePublishState('m-novideo-url', 'published', 'space'); + await runNostrWorkerTick( + deps({ + messages, + auth, + kek: KEK, + publisher: new RecordingPublisher(), + now: () => 1_700_000_000_000, + env: { NOSTR_PUBLISH: '1', NOSTR_RELAY_SPACE: 'wss://relay.nostr.space' }, + }), + ); + const row = await messages.getById('m-novideo-url'); + expect(row?.eventId).toBe('ab'.repeat(32)); + expect(row?.nostrPublishState).toBe('published'); + }); + it('does not reset a zapped photo post that lacks the photo URL', async () => { const { auth, messages } = await seed(); await messages.create( @@ -750,6 +958,7 @@ describe('runNostrWorkerTick', () => { ], }); messages.listSignedMissingPhoto = async () => []; + messages.listSignedMissingVideo = async () => []; messages.listSignedMissingHashtags = async () => []; const publisher = new RecordingPublisher(); await runNostrWorkerTick( @@ -848,6 +1057,7 @@ describe('runNostrWorkerTick', () => { }); await messages.addSats('m-zap-pending', 7); messages.listSignedMissingPhoto = async () => []; + messages.listSignedMissingVideo = async () => []; messages.listSignedMissingHashtags = async () => []; const publisher = new RecordingPublisher(); await runNostrWorkerTick( @@ -976,11 +1186,20 @@ describe('runNostrWorkerTick', () => { kek: KEK, publisher, now: () => 1_700_000_000_000, - env: { NOSTR_PUBLISH: '1', NOSTR_RELAY_SPACE: 'wss://relay.nostr.space' }, + env: { + NOSTR_PUBLISH: '1', + NOSTR_RELAY_SPACE: 'wss://relay.nostr.space', + PUBLIC_BASE_URL: 'https://dev.21.gifts', + }, }), ); const profile = publisher.calls.find((call) => call.event['kind'] === 0); - expect(JSON.parse(String(profile?.event['content'])).name).toBe('Anton'); + const content = JSON.parse(String(profile?.event['content'])) as { + name: string; + nip05: string; + }; + expect(content.name).toBe('Anton'); + expect(content.nip05).toBe('anton@dev.21.gifts'); }); it('publishes kind:0 to public relays when NOSTR_PUBLISH_PUBLIC=1', async () => { diff --git a/src/__tests__/lib/nostr/zap-index.test.ts b/src/__tests__/lib/nostr/zap-index.test.ts index 7884ce52..1e415432 100644 --- a/src/__tests__/lib/nostr/zap-index.test.ts +++ b/src/__tests__/lib/nostr/zap-index.test.ts @@ -1066,6 +1066,7 @@ describe('indexOpenZapReceipts', () => { base.claimUnpublished(...args), listPendingSigned: (limit: number) => base.listPendingSigned(limit), listSignedMissingPhoto: (limit: number) => base.listSignedMissingPhoto(limit), + listSignedMissingVideo: (limit: number) => base.listSignedMissingVideo(limit), listSignedMissingHashtags: (limit: number) => base.listSignedMissingHashtags(limit), clearSignedEvent: (...args: Parameters) => base.clearSignedEvent(...args), diff --git a/src/__tests__/lib/video.test.ts b/src/__tests__/lib/video.test.ts new file mode 100644 index 00000000..12bdac1e --- /dev/null +++ b/src/__tests__/lib/video.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; +import { + decodeForumVideo, + detectVideoContentType, + forumVideoExt, + forumVideoUrl, + parseBytesRange, + removeForumVideo, + resolveMediaDir, + streamForumVideo, + videoFilePath, + writeForumVideo, +} from '@/lib/video'; + +function mp4Bytes(): Uint8Array { + const bytes = new Uint8Array(32); + bytes.set([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d]); + return bytes; +} + +describe('video', () => { + it('detects mp4, mov, and webm', () => { + expect(detectVideoContentType(mp4Bytes())).toBe('video/mp4'); + const mov = mp4Bytes(); + mov.set([0x71, 0x74, 0x20, 0x20], 8); + expect(detectVideoContentType(mov)).toBe('video/quicktime'); + expect( + detectVideoContentType(new Uint8Array([0x1a, 0x45, 0xdf, 0xa3, 0x77, 0x65, 0x62, 0x6d])), + ).toBe('video/webm'); + expect(detectVideoContentType(new Uint8Array([1, 2, 3]))).toBeNull(); + }); + + it('rejects non-video ISO-BMFF brands and bare EBML', () => { + const heic = mp4Bytes(); + heic.set([0x6d, 0x69, 0x66, 0x31], 8); + expect(detectVideoContentType(heic)).toBeNull(); + const avif = mp4Bytes(); + avif.set([0x61, 0x76, 0x69, 0x66], 8); + expect(detectVideoContentType(avif)).toBeNull(); + const m4a = mp4Bytes(); + m4a.set([0x4d, 0x34, 0x41, 0x20], 8); + expect(detectVideoContentType(m4a)).toBeNull(); + expect(detectVideoContentType(new Uint8Array([0x1a, 0x45, 0xdf, 0xa3, 0x00]))).toBeNull(); + }); + + it('rejects empty and oversize video', () => { + expect(decodeForumVideo(new Uint8Array())).toBeNull(); + const huge = new Uint8Array(32 * 1024 * 1024 + 1); + huge.set([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d]); + expect(decodeForumVideo(huge)).toBeNull(); + expect(decodeForumVideo(mp4Bytes())?.contentType).toBe('video/mp4'); + }); + + it('builds Damus-friendly video URLs', () => { + expect(forumVideoExt('video/mp4')).toBe('mp4'); + expect(forumVideoExt('video/webm')).toBe('webm'); + expect(forumVideoExt('video/quicktime')).toBe('mov'); + expect(forumVideoUrl('https://api.21.gifts/', 'm1', 'video/mp4')).toBe( + 'https://api.21.gifts/messages/m1/video.mp4', + ); + }); + + it('parses byte ranges', () => { + expect(parseBytesRange(undefined, 100)).toEqual({ type: 'full' }); + expect(parseBytesRange('bytes=0-9', 100)).toEqual({ type: 'partial', start: 0, end: 9 }); + expect(parseBytesRange('bytes=50-', 100)).toEqual({ type: 'partial', start: 50, end: 99 }); + expect(parseBytesRange('bytes=-10', 100)).toEqual({ type: 'partial', start: 90, end: 99 }); + expect(parseBytesRange('bytes=80-70', 100)).toEqual({ type: 'full' }); + expect(parseBytesRange('bytes=', 100)).toEqual({ type: 'full' }); + expect(parseBytesRange('bytes=-', 100)).toEqual({ type: 'full' }); + expect(parseBytesRange('bytes=-0', 100)).toEqual({ type: 'full' }); + expect(parseBytesRange('bytes=abc-1', 100)).toEqual({ type: 'full' }); + expect(parseBytesRange('bytes=0-9', 0)).toEqual({ type: 'full' }); + expect(parseBytesRange('nope', 100)).toEqual({ type: 'full' }); + expect(parseBytesRange(' ', 100)).toEqual({ type: 'full' }); + expect(parseBytesRange('bytes=100-', 100)).toEqual({ type: 'unsatisfiable' }); + expect(parseBytesRange('bytes=200-300', 100)).toEqual({ type: 'unsatisfiable' }); + }); + + it('falls back to a temp media dir', () => { + expect(resolveMediaDir({ MEDIA_DIR: ' /data/media ' })).toBe('/data/media'); + expect(resolveMediaDir({})).toContain('21gifts-media'); + }); + + it('writes and removes a video file', async () => { + const video = decodeForumVideo(mp4Bytes()); + expect(video).not.toBeNull(); + if (video === null) { + return; + } + await writeForumVideo('vid-1', video); + const stream = streamForumVideo( + videoFilePath(resolveMediaDir(), 'vid-1', 'video/mp4'), + 0, + video.bytes.byteLength - 1, + ); + const reader = stream.getReader(); + const first = await reader.read(); + expect(first.done).toBe(false); + expect(first.value?.byteLength).toBeGreaterThan(0); + await reader.cancel(); + await removeForumVideo('vid-1', 'video/mp4'); + await removeForumVideo('missing', 'video/mp4'); + }); +}); diff --git a/src/__tests__/routes/debug.test.ts b/src/__tests__/routes/debug.test.ts index de412663..b16673b8 100644 --- a/src/__tests__/routes/debug.test.ts +++ b/src/__tests__/routes/debug.test.ts @@ -117,7 +117,7 @@ describe('debugRoutes', () => { }); expect(res.status).toBe(400); expect(await res.json()).toEqual({ - error: 'Expected a JSON body with a "role" string', + error: 'Expected a JSON body with a "role" string and/or lightningAddress null', }); }); @@ -133,7 +133,7 @@ describe('debugRoutes', () => { }); expect(res.status).toBe(400); expect(await res.json()).toEqual({ - error: 'Expected a JSON body with a "role" string', + error: 'Expected a JSON body with a "role" string and/or lightningAddress null', }); }); @@ -196,6 +196,116 @@ describe('debugRoutes', () => { ).toBe(true); }); + it('PATCH clears the Lightning Address and verification flag', async () => { + const store = new InMemoryAuthStore(); + await store.createAccount({ + id: 'acc', + linkingKey: null, + role: 'basis', + name: 'Ada', + lightningAddress: 'ada@walletofsatoshi.com', + lightningAddressVerified: true, + forumLawsDismissed: false, + viewKey: 'b'.repeat(64), + createdAt: 1, + rulesAgreedAt: 2, + }); + const app = new Hono().route('/debug/accounts', debugRoutes({ store, debugToken: 'secret' })); + const res = await app.request('/debug/accounts/acc', { + method: 'PATCH', + headers: { authorization: 'Bearer secret', 'content-type': 'application/json' }, + body: JSON.stringify({ lightningAddress: null }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + lightningAddress: string | null; + lightningAddressVerified: boolean; + }; + expect(body.lightningAddress).toBeNull(); + expect(body.lightningAddressVerified).toBe(false); + const stored = await store.getAccount('acc'); + expect(stored?.lightningAddress).toBeNull(); + expect(stored?.lightningAddressVerified).toBe(false); + expect(stored?.name).toBe('Ada'); + expect(await store.getVerification('acc')).toBeUndefined(); + expect( + parsedEvents(warn).some((e) => e['event'] === 'debug.accounts.lightning_address.cleared'), + ).toBe(true); + }); + + it('PATCH unlink drops in-flight address verification', async () => { + const store = new InMemoryAuthStore(); + await store.createAccount({ + id: 'acc', + linkingKey: null, + role: 'basis', + name: 'Ada', + lightningAddress: 'ada@walletofsatoshi.com', + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: 'b'.repeat(64), + createdAt: 1, + rulesAgreedAt: 2, + }); + await store.putVerification({ + accountId: 'acc', + address: 'ada@walletofsatoshi.com', + nonce: 'a'.repeat(32), + createdAt: 1, + }); + const app = new Hono().route('/debug/accounts', debugRoutes({ store, debugToken: 'secret' })); + const res = await app.request('/debug/accounts/acc', { + method: 'PATCH', + headers: { authorization: 'Bearer secret', 'content-type': 'application/json' }, + body: JSON.stringify({ lightningAddress: null }), + }); + expect(res.status).toBe(200); + expect(await store.getVerification('acc')).toBeUndefined(); + }); + + it('PATCH can set role and unlink in one body', async () => { + const store = new InMemoryAuthStore(); + await store.createAccount({ + id: 'acc', + linkingKey: null, + role: 'basis', + name: 'Ada', + lightningAddress: 'ada@walletofsatoshi.com', + lightningAddressVerified: true, + forumLawsDismissed: false, + viewKey: 'b'.repeat(64), + createdAt: 1, + rulesAgreedAt: 2, + }); + const app = new Hono().route('/debug/accounts', debugRoutes({ store, debugToken: 'secret' })); + const res = await app.request('/debug/accounts/acc', { + method: 'PATCH', + headers: { authorization: 'Bearer secret', 'content-type': 'application/json' }, + body: JSON.stringify({ role: 'moderator', lightningAddress: null }), + }); + expect(res.status).toBe(200); + const stored = await store.getAccount('acc'); + expect(stored?.role).toBe('moderator'); + expect(stored?.lightningAddress).toBeNull(); + expect(stored?.lightningAddressVerified).toBe(false); + }); + + it('PATCH returns 400 when lightningAddress is not null', async () => { + const app = new Hono().route( + '/debug/accounts', + debugRoutes({ store: new InMemoryAuthStore(), debugToken: 'secret' }), + ); + const res = await app.request('/debug/accounts/acc', { + method: 'PATCH', + headers: { authorization: 'Bearer secret', 'content-type': 'application/json' }, + body: JSON.stringify({ lightningAddress: 'ada@walletofsatoshi.com' }), + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: 'Expected a JSON body with a "role" string and/or lightningAddress null', + }); + }); + it('POST returns 503 when debug is not configured', async () => { const app = new Hono().route( '/debug/accounts', diff --git a/src/__tests__/routes/me.test.ts b/src/__tests__/routes/me.test.ts index 6e0614e4..51a1aa94 100644 --- a/src/__tests__/routes/me.test.ts +++ b/src/__tests__/routes/me.test.ts @@ -143,6 +143,7 @@ describe('GET /me', () => { lightningAddressVerified: boolean; viewKey: string; rulesAgreedAt: number | null; + setup: 'name' | 'lightning-address' | 'rules' | null; }; expect(body.id).toBe('acc'); expect(body.role).toBe('basis'); @@ -151,6 +152,7 @@ describe('GET /me', () => { expect(body.lightningAddressVerified).toBe(false); expect(body.viewKey).toBe(VIEW_KEY); expect(body.rulesAgreedAt).toBeNull(); + expect(body.setup).toBe('name'); }); }); @@ -688,6 +690,8 @@ describe('DELETE /me/lightning-address', () => { it('unlinks the address and clears pending verification', async () => { const store = await seededStore({ lightningAddress: ADDRESS }); + const existing = await store.getAccount('acc'); + await store.updateAccount({ ...existing!, name: 'Ada' }); await store.putVerification({ accountId: 'acc', address: ADDRESS, @@ -699,8 +703,12 @@ describe('DELETE /me/lightning-address', () => { headers: AUTH, }); expect(res.status).toBe(200); - const body = (await res.json()) as { lightningAddress: string | null }; + const body = (await res.json()) as { + lightningAddress: string | null; + setup: 'name' | 'lightning-address' | 'rules' | null; + }; expect(body.lightningAddress).toBeNull(); + expect(body.setup).toBe('lightning-address'); expect((await store.getAccount('acc'))?.lightningAddress).toBeNull(); expect(await store.getVerification('acc')).toBeUndefined(); expect( diff --git a/src/__tests__/routes/messages.test.ts b/src/__tests__/routes/messages.test.ts index 056b12a6..1a052313 100644 --- a/src/__tests__/routes/messages.test.ts +++ b/src/__tests__/routes/messages.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { chmod, mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; import { Hono } from 'hono'; import { InMemoryAuthStore } from '@/lib/auth/store'; import { InMemoryMessageStore, type MessageStore } from '@/lib/message-store'; @@ -6,6 +9,7 @@ import { MESSAGE_MAX_LENGTH, unsignedNostrDefaults } from '@/lib/message'; import { InvoiceRateLimiter, PostRateLimiter } from '@/lib/nostr/rate-limit'; import { messagesRoutes } from '@/routes/messages'; import { InMemoryPushStore } from '@/lib/push-store'; +import { removeForumVideo, resolveMediaDir, videoFilePath } from '@/lib/video'; function parsedEvents(warn: ReturnType): Array> { return warn.mock.calls @@ -102,6 +106,7 @@ function throwingStore(overrides: Partial = {}): MessageStore { claimUnpublished: boom, listPendingSigned: boom, listSignedMissingPhoto: boom, + listSignedMissingVideo: boom, listSignedMissingHashtags: boom, clearSignedEvent: boom, resetSignedEvent: boom, @@ -1623,6 +1628,7 @@ describe('POST /messages/:id/invoice', () => { claimUnpublished: (...args) => base.claimUnpublished(...args), listPendingSigned: (limit) => base.listPendingSigned(limit), listSignedMissingPhoto: (limit) => base.listSignedMissingPhoto(limit), + listSignedMissingVideo: (limit) => base.listSignedMissingVideo(limit), listSignedMissingHashtags: (limit) => base.listSignedMissingHashtags(limit), clearSignedEvent: (...args) => base.clearSignedEvent(...args), resetSignedEvent: (...args) => base.resetSignedEvent(...args), @@ -1941,3 +1947,399 @@ describe('GET /messages/:id/photo', () => { expect(parsedEvents(warn).some((e) => e['event'] === 'messages.photo.failed')).toBe(true); }); }); + +describe('forum video', () => { + const mp4 = (): Uint8Array => { + const bytes = new Uint8Array(32); + bytes.set([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d]); + return bytes; + }; + + it('accepts multipart video and serves Range', async () => { + const auth = await namedStore('Ada'); + const store = new InMemoryMessageStore(); + const app = mount(auth, store); + const form = new FormData(); + form.set('text', 'clip'); + form.set('video', new File([mp4()], 'clip.mp4', { type: 'video/mp4' })); + form.set('poster', new File([JPEG_BYTES], 'poster.jpg', { type: 'image/jpeg' })); + const res = await app.request('/messages', { + method: 'POST', + headers: AUTH, + body: form, + }); + expect(res.status).toBe(200); + const created = (await res.json()) as { + id: string; + hasVideo: boolean; + hasPhoto: boolean; + videoContentType: string | null; + }; + expect(created.hasVideo).toBe(true); + expect(created.hasPhoto).toBe(true); + expect(created.videoContentType).toBe('video/mp4'); + const full = await app.request(`/messages/${created.id}/video.mp4`); + expect(full.status).toBe(200); + expect(full.headers.get('Accept-Ranges')).toBe('bytes'); + expect(full.headers.get('Content-Type')).toBe('video/mp4'); + const ranged = await app.request(`/messages/${created.id}/video.mp4`, { + headers: { Range: 'bytes=0-3' }, + }); + expect(ranged.status).toBe(206); + expect(ranged.headers.get('Content-Range')?.startsWith('bytes 0-3/')).toBe(true); + expect(new Uint8Array(await ranged.arrayBuffer())).toEqual(mp4().subarray(0, 4)); + expect((await app.request(`/messages/${created.id}/video.webm`)).status).toBe(404); + expect((await app.request('/messages/not-a-uuid/video.mp4')).status).toBe(404); + }); + + it('rejects an oversized poster part before decoding', async () => { + const form = new FormData(); + form.set('text', 'clip'); + form.set('video', new File([mp4()], 'clip.mp4', { type: 'video/mp4' })); + form.set('poster', new File([new Uint8Array(1_048_577)], 'poster.jpg', { type: 'image/jpeg' })); + const res = await mount(await namedStore('Ada')).request('/messages', { + method: 'POST', + headers: AUTH, + body: form, + }); + expect(res.status).toBe(400); + }); + + it('still returns 200 when video push enqueue throws', async () => { + const authStore = await namedStore('Ada'); + const pushStore = new InMemoryPushStore(); + pushStore.enqueue = async () => { + throw new Error('enqueue failed'); + }; + await pushStore.upsertSubscription({ + endpoint: 'https://push.example/other', + accountId: 'other', + p256dh: 'p256dh', + auth: 'authkey', + createdAt: new Date(now()), + }); + const app = new Hono().route( + '/messages', + messagesRoutes({ + store: new InMemoryMessageStore(), + authStore, + now, + pushStore, + postLimiter: new PostRateLimiter(), + invoiceLimiter: new InvoiceRateLimiter(), + }), + ); + const form = new FormData(); + form.set('text', 'clip'); + form.set('video', new File([mp4()], 'clip.mp4', { type: 'video/mp4' })); + const res = await app.request('/messages', { + method: 'POST', + headers: AUTH, + body: form, + }); + expect(res.status).toBe(200); + expect(parsedEvents(warn).some((e) => e['event'] === 'push.enqueue.failed')).toBe(true); + }); + + it('enqueues a forum push for other subscribed accounts after a video post', async () => { + const authStore = await namedStore('Ada'); + const pushStore = new InMemoryPushStore(); + await pushStore.upsertSubscription({ + endpoint: 'https://push.example/other', + accountId: 'other', + p256dh: 'p256dh', + auth: 'authkey', + createdAt: new Date(now()), + }); + await pushStore.upsertSubscription({ + endpoint: 'https://push.example/author', + accountId: 'acc', + p256dh: 'p256dh', + auth: 'authkey', + createdAt: new Date(now()), + }); + const app = new Hono().route( + '/messages', + messagesRoutes({ + store: new InMemoryMessageStore(), + authStore, + now, + pushStore, + postLimiter: new PostRateLimiter(), + invoiceLimiter: new InvoiceRateLimiter(), + }), + ); + const form = new FormData(); + form.set('text', 'clip'); + form.set('video', new File([mp4()], 'clip.mp4', { type: 'video/mp4' })); + const res = await app.request('/messages', { + method: 'POST', + headers: AUTH, + body: form, + }); + expect(res.status).toBe(200); + const claimed = await pushStore.claimPending(20, now() + 1, 60_000); + expect(claimed).toHaveLength(1); + expect(claimed[0]?.accountId).toBe('other'); + expect(claimed[0]?.type).toBe('forum'); + }); + + it('rejects an oversized video part before decoding', async () => { + const form = new FormData(); + form.set('text', 'clip'); + const file = new File([new Uint8Array(32 * 1024 * 1024 + 1)], 'clip.mp4', { + type: 'video/mp4', + }); + form.set('video', file); + const res = await mount(await namedStore('Ada')).request('/messages', { + method: 'POST', + headers: AUTH, + body: form, + }); + expect(res.status).toBe(400); + }); + + it('rejects overlong multipart text', async () => { + const form = new FormData(); + form.set('text', 'a'.repeat(501)); + const res = await mount(await namedStore('Ada')).request('/messages', { + method: 'POST', + headers: AUTH, + body: form, + }); + expect(res.status).toBe(400); + }); + + it('rejects empty multipart', async () => { + const empty = new FormData(); + empty.set('text', ' '); + const res = await mount(await namedStore('Ada')).request('/messages', { + method: 'POST', + headers: AUTH, + body: empty, + }); + expect(res.status).toBe(400); + }); + + it('ignores an empty poster part', async () => { + const form = new FormData(); + form.set('text', 'hello'); + form.set('poster', new File([], 'p.jpg', { type: 'image/jpeg' })); + const res = await mount(await namedStore('Ada')).request('/messages', { + method: 'POST', + headers: AUTH, + body: form, + }); + expect(res.status).toBe(200); + expect(((await res.json()) as { hasPhoto: boolean }).hasPhoto).toBe(false); + }); + + it('rejects a bad poster', async () => { + const badPoster = new FormData(); + badPoster.set('text', 'x'); + badPoster.set( + 'poster', + new File([new Uint8Array([1, 2, 3])], 'x.bin', { type: 'application/octet-stream' }), + ); + const res = await mount(await namedStore('Ada')).request('/messages', { + method: 'POST', + headers: AUTH, + body: badPoster, + }); + expect(res.status).toBe(400); + }); + + it('rejects multipart when the account has no name', async () => { + const form = new FormData(); + form.set('text', 'clip'); + const res = await mount(await seededStore()).request('/messages', { + method: 'POST', + headers: AUTH, + body: form, + }); + expect(res.status).toBe(400); + }); + + it('returns 503 when video create throws', async () => { + const form = new FormData(); + form.set('text', 'clip'); + form.set('video', new File([mp4()], 'clip.mp4', { type: 'video/mp4' })); + const res = await mount(await namedStore('Ada'), throwingStore()).request('/messages', { + method: 'POST', + headers: AUTH, + body: form, + }); + expect(res.status).toBe(503); + }); + + it('returns 404 when no video is stored', async () => { + const res = await mount(await namedStore('Ada')).request( + '/messages/00000000-0000-4000-8000-000000000001/video.mp4', + ); + expect(res.status).toBe(404); + }); + + it('ignores an empty video part and posts text', async () => { + const form = new FormData(); + form.set('text', 'hello'); + form.set('video', new File([], 'empty.mp4', { type: 'video/mp4' })); + const res = await mount(await namedStore('Ada')).request('/messages', { + method: 'POST', + headers: AUTH, + body: form, + }); + expect(res.status).toBe(200); + expect(((await res.json()) as { hasVideo: boolean }).hasVideo).toBe(false); + }); + + it('rejects a non-video multipart file', async () => { + const form = new FormData(); + form.set('text', 'clip'); + form.set( + 'video', + new File([new Uint8Array([1, 2, 3, 4])], 'x.bin', { type: 'application/octet-stream' }), + ); + const res = await mount(await namedStore('Ada')).request('/messages', { + method: 'POST', + headers: AUTH, + body: form, + }); + expect(res.status).toBe(400); + }); + + it('returns 503 when video GET cannot read the row', async () => { + const res = await mount(await namedStore('Ada'), throwingStore()).request( + '/messages/00000000-0000-4000-8000-000000000001/video.mp4', + ); + expect(res.status).toBe(503); + expect(parsedEvents(warn).some((e) => e['event'] === 'messages.video.failed')).toBe(true); + }); + + it('returns 416 for an unsatisfiable Range', async () => { + const auth = await namedStore('Ada'); + const store = new InMemoryMessageStore(); + const app = mount(auth, store); + const form = new FormData(); + form.set('text', 'clip'); + form.set('video', new File([mp4()], 'clip.mp4', { type: 'video/mp4' })); + const res = await app.request('/messages', { + method: 'POST', + headers: AUTH, + body: form, + }); + expect(res.status).toBe(200); + const created = (await res.json()) as { id: string }; + const size = mp4().byteLength; + const ranged = await app.request(`/messages/${created.id}/video.mp4`, { + headers: { Range: `bytes=${size}-` }, + }); + expect(ranged.status).toBe(416); + expect(ranged.headers.get('Content-Range')).toBe(`bytes */${size}`); + expect(ranged.headers.get('Accept-Ranges')).toBe('bytes'); + expect(ranged.headers.get('Access-Control-Allow-Origin')).toBe('*'); + }); + + it('returns 404 when the video file is missing without logging 503', async () => { + const auth = await namedStore('Ada'); + const store = new InMemoryMessageStore(); + const app = mount(auth, store); + const form = new FormData(); + form.set('text', 'clip'); + form.set('video', new File([mp4()], 'clip.mp4', { type: 'video/mp4' })); + const res = await app.request('/messages', { + method: 'POST', + headers: AUTH, + body: form, + }); + expect(res.status).toBe(200); + const created = (await res.json()) as { id: string }; + await removeForumVideo(created.id, 'video/mp4'); + warn.mockClear(); + const missing = await app.request(`/messages/${created.id}/video.mp4`); + expect(missing.status).toBe(404); + expect(await missing.json()).toEqual({ error: 'Video not found' }); + expect(parsedEvents(warn).some((e) => e['event'] === 'messages.video.failed')).toBe(false); + }); + + it('returns 404 for an empty video file without logging 503', async () => { + const auth = await namedStore('Ada'); + const store = new InMemoryMessageStore(); + const app = mount(auth, store); + const form = new FormData(); + form.set('text', 'clip'); + form.set('video', new File([mp4()], 'clip.mp4', { type: 'video/mp4' })); + const res = await app.request('/messages', { + method: 'POST', + headers: AUTH, + body: form, + }); + expect(res.status).toBe(200); + const created = (await res.json()) as { id: string }; + await writeFile(videoFilePath(resolveMediaDir(), created.id, 'video/mp4'), new Uint8Array()); + warn.mockClear(); + const empty = await app.request(`/messages/${created.id}/video.mp4`); + expect(empty.status).toBe(404); + expect(await empty.json()).toEqual({ error: 'Video not found' }); + expect(parsedEvents(warn).some((e) => e['event'] === 'messages.video.failed')).toBe(false); + }); + + it('returns 404 when the video path is not a file', async () => { + const auth = await namedStore('Ada'); + const store = new InMemoryMessageStore(); + const app = mount(auth, store); + const form = new FormData(); + form.set('text', 'clip'); + form.set('video', new File([mp4()], 'clip.mp4', { type: 'video/mp4' })); + const res = await app.request('/messages', { + method: 'POST', + headers: AUTH, + body: form, + }); + expect(res.status).toBe(200); + const created = (await res.json()) as { id: string }; + await removeForumVideo(created.id, 'video/mp4'); + await mkdir(videoFilePath(resolveMediaDir(), created.id, 'video/mp4')); + warn.mockClear(); + const notFile = await app.request(`/messages/${created.id}/video.mp4`); + expect(notFile.status).toBe(404); + expect(await notFile.json()).toEqual({ error: 'Video not found' }); + expect(parsedEvents(warn).some((e) => e['event'] === 'messages.video.failed')).toBe(false); + }); + + it('returns 503 when video file stat fails for a non-ENOENT reason', async () => { + const prev = process.env['MEDIA_DIR']; + const dir = join(tmpdir(), `21gifts-video-eacces-${Date.now()}`); + process.env['MEDIA_DIR'] = dir; + try { + const auth = await namedStore('Ada'); + const store = new InMemoryMessageStore(); + const app = mount(auth, store); + const form = new FormData(); + form.set('text', 'clip'); + form.set('video', new File([mp4()], 'clip.mp4', { type: 'video/mp4' })); + const res = await app.request('/messages', { + method: 'POST', + headers: AUTH, + body: form, + }); + expect(res.status).toBe(200); + const created = (await res.json()) as { id: string }; + await chmod(dir, 0o000); + warn.mockClear(); + try { + const denied = await app.request(`/messages/${created.id}/video.mp4`); + expect(denied.status).toBe(503); + expect(await denied.json()).toEqual({ error: 'Messages are unavailable' }); + expect(parsedEvents(warn).some((e) => e['event'] === 'messages.video.failed')).toBe(true); + } finally { + await chmod(dir, 0o755); + } + } finally { + if (prev === undefined) { + delete process.env['MEDIA_DIR']; + } else { + process.env['MEDIA_DIR'] = prev; + } + } + }); +}); diff --git a/src/__tests__/routes/well-known.test.ts b/src/__tests__/routes/well-known.test.ts new file mode 100644 index 00000000..15e5b7a2 --- /dev/null +++ b/src/__tests__/routes/well-known.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { createApp } from '@/server'; +import { InMemoryAuthStore } from '@/lib/auth/store'; + +describe('GET /.well-known/nostr.json', () => { + it('returns names and CORS', async () => { + const auth = new InMemoryAuthStore(); + await auth.createAccount({ + id: '00000000-0000-4000-8000-000000000001', + linkingKey: null, + role: 'basis', + name: 'Ada', + lightningAddress: null, + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: 'cd'.repeat(32), + createdAt: 1, + rulesAgreedAt: null, + }); + await auth.setNostrKeyIfAbsent('00000000-0000-4000-8000-000000000001', { + pubkey: 'aa'.repeat(32), + ciphertext: new Uint8Array(16), + kekId: 1, + custody: 'custodial', + }); + const app = createApp({ authStore: auth }); + const res = await app.request('/.well-known/nostr.json?name=ada'); + expect(res.status).toBe(200); + expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*'); + const body = (await res.json()) as { names: Record }; + expect(body.names['ada']).toBe('aa'.repeat(32)); + }); + + it('returns 503 when the store throws', async () => { + const auth = new InMemoryAuthStore(); + auth.listAccounts = async () => { + throw new Error('boom'); + }; + const app = createApp({ authStore: auth }); + const res = await app.request('/.well-known/nostr.json'); + expect(res.status).toBe(503); + }); + + it('keeps CORS * when Origin is a foreign site', async () => { + const app = createApp({ authStore: new InMemoryAuthStore() }); + const res = await app.request('/.well-known/nostr.json', { + headers: { Origin: 'https://example.com' }, + }); + expect(res.status).toBe(200); + expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*'); + }); + + it('answers OPTIONS preflight with CORS *', async () => { + const app = createApp({ authStore: new InMemoryAuthStore() }); + const res = await app.request('/.well-known/nostr.json', { + method: 'OPTIONS', + headers: { + Origin: 'https://example.com', + 'Access-Control-Request-Method': 'GET', + }, + }); + expect([200, 204]).toContain(res.status); + expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*'); + }); +}); diff --git a/src/lib/auth/account-json.ts b/src/lib/auth/account-json.ts index 9dfa5778..9c7c4faa 100644 --- a/src/lib/auth/account-json.ts +++ b/src/lib/auth/account-json.ts @@ -1,3 +1,4 @@ +import { accountSetup, type AccountSetup } from '@/lib/auth/account-setup'; import type { Account } from '@/lib/auth/store'; /** @@ -28,11 +29,17 @@ export interface AccountResponse { /** * Owner-facing account JSON: the nine public fields plus the durable - * view-key capability secret. + * view-key capability secret and the next `setup` step. */ export interface OwnerAccountResponse extends AccountResponse { /** 64 lowercase hex; capability URL secret for `GET /view/:viewKey`. */ viewKey: string; + /** + * Next setup step the owner must complete (`name`, `lightning-address`, + * `rules`), or `null` when the signed-in app is allowed. Computed on the + * api; clients must not invent a parallel sequence. + */ + setup: AccountSetup; } /** @@ -81,12 +88,13 @@ export function serializeAccount(account: Account): AccountResponse { * by the operator debug listing. * * @param account - Stored account. - * @returns Ten fields including `viewKey`. + * @returns Eleven fields including `viewKey` and `setup`. */ export function serializeOwnerAccount(account: Account): OwnerAccountResponse { return { ...serializeAccount(account), viewKey: account.viewKey, + setup: accountSetup(account), }; } diff --git a/src/lib/auth/account-setup.ts b/src/lib/auth/account-setup.ts new file mode 100644 index 00000000..add0b59b --- /dev/null +++ b/src/lib/auth/account-setup.ts @@ -0,0 +1,31 @@ +import type { Account } from '@/lib/auth/store'; + +/** + * Next owner setup step. The api is the source of truth; clients only route. + * + * Order matches onboarding: name, then Lightning Address, then living-room + * rules. `null` means the account may use the signed-in app. + */ +export type AccountSetup = 'name' | 'lightning-address' | 'rules' | null; + +/** + * Compute the next setup step from stored account fields. + * + * Missing name, missing Lightning Address, or missing rules agreement each + * block later screens. Blank strings after trim count as missing. + * + * @param account - Stored account. + * @returns The next required step, or `null` when setup is complete. + */ +export function accountSetup(account: Account): AccountSetup { + if (account.name === null || account.name.trim() === '') { + return 'name'; + } + if (account.lightningAddress === null || account.lightningAddress.trim() === '') { + return 'lightning-address'; + } + if (account.rulesAgreedAt === null) { + return 'rules'; + } + return null; +} diff --git a/src/lib/message-store.ts b/src/lib/message-store.ts index fd50715c..e5f7bb45 100644 --- a/src/lib/message-store.ts +++ b/src/lib/message-store.ts @@ -4,6 +4,8 @@ * v1 default is in-memory. Production boot injects Postgres when * `DATABASE_URL` is set. List queries never select the `photo` bytea column — * only `(photo IS NOT NULL) AS has_photo`. Bytes are loaded via {@link MessageStore.getPhoto}. + * `video_content_type` (MIME) lives in Postgres; video bytes live on disk under + * `MEDIA_DIR`, not as bytea. */ import type { SqlClient } from '@/lib/auth/sql'; @@ -16,6 +18,12 @@ import { } from '@/lib/message'; import { kind1ContentWithHashtags } from '@/lib/nostr/event'; import { normalizeSignedEvent } from '@/lib/nostr/publish'; +import { + removeForumVideo, + writeForumVideo, + type ForumVideo, + type ForumVideoContentType, +} from '@/lib/video'; function kind1MissingPhotoUrl(event: Record | null, messageId: string): boolean { if (event === null) { @@ -25,6 +33,14 @@ function kind1MissingPhotoUrl(event: Record | null, messageId: return typeof content !== 'string' || !content.includes(`/messages/${messageId}/photo.`); } +function kind1MissingVideoUrl(event: Record | null, messageId: string): boolean { + if (event === null) { + return true; + } + const content = event['content']; + return typeof content !== 'string' || !content.includes(`/messages/${messageId}/video.`); +} + function kind1MissingHashtags(event: Record | null): boolean { if (event === null) { return true; @@ -50,7 +66,8 @@ function pendingKind1LacksBitcoinTag(event: Record | null): boo export interface MessageStore { /** * Newest messages first (`createdAt` desc, then `id` desc), capped at - * `limit`. Rows include `hasPhoto` but never photo bytes. + * `limit`. Rows include `hasPhoto`, `hasVideo`, and `videoContentType` but + * never photo or video bytes. * * @param limit - Maximum rows to return. * @returns Message rows (caller-owned copies). @@ -58,13 +75,15 @@ export interface MessageStore { listLatest(limit: number): Promise; /** - * Persist a new message row and optional photo. + * Persist a new message row and optional photo and video. * * @param row - Fully formed row (id, account, name snapshot, text, time, hasPhoto). * @param photo - Optional decoded photo (copied into storage). - * @returns The stored row (a copy is fine) with `hasPhoto` set from `photo`. + * @param video - Optional forum video (MIME on the row; bytes via `writeForumVideo` / disk). + * @returns The stored row (a copy is fine) with `hasPhoto` set from `photo` and + * `hasVideo` / `videoContentType` from `video`. */ - create(row: MessageRow, photo?: ForumPhoto): Promise; + create(row: MessageRow, photo?: ForumPhoto, video?: ForumVideo): Promise; /** * Load photo bytes for a message id. @@ -113,13 +132,24 @@ export interface MessageStore { /** * Published rows with a photo whose kind:1 content lacks the public photo URL. + * Video rows (poster JPEG stored as `photo`) are excluded — their kind:1 + * content has `/video.`, not `/photo.`. `sats = 0` only (zapped rows keep + * their event id). Pending rows are left for fan-out — resetting them renews + * the sign lease and they never EVENT. Oldest `createdAt` then `id` first. + * + * @param limit - Max rows. + */ + listSignedMissingPhoto(limit: number): Promise; + + /** + * Published rows with a video whose kind:1 content lacks the public video URL. * `sats = 0` only (zapped rows keep their event id). Pending rows are left * for fan-out — resetting them renews the sign lease and they never EVENT. * Oldest `createdAt` then `id` first. * * @param limit - Max rows. */ - listSignedMissingPhoto(limit: number): Promise; + listSignedMissingVideo(limit: number): Promise; /** * Published rows whose kind:1 content lacks a `#21gifts` or `#bitcoin` token @@ -248,6 +278,7 @@ export const MESSAGE_SCHEMA_SQL: readonly string[] = [ `ALTER TABLE message ADD COLUMN IF NOT EXISTS nostr_first_attempt_at timestamptz`, `ALTER TABLE message ADD COLUMN IF NOT EXISTS nostr_publish_epoch text`, `ALTER TABLE message ADD COLUMN IF NOT EXISTS nostr_attempts integer NOT NULL DEFAULT 0`, + `ALTER TABLE message ADD COLUMN IF NOT EXISTS video_content_type text`, `CREATE UNIQUE INDEX IF NOT EXISTS message_event_id_uidx ON message (event_id) WHERE event_id IS NOT NULL`, `CREATE TABLE IF NOT EXISTS nostr_zap_receipt ( event_id text PRIMARY KEY, @@ -315,6 +346,8 @@ function copyRow(row: MessageRow): MessageRow { return { ...row, hasPhoto: row.hasPhoto === true, + hasVideo: row.hasVideo === true, + videoContentType: row.videoContentType ?? null, createdAt: new Date(row.createdAt.getTime()), nostrEvent: row.nostrEvent === null ? null : { ...row.nostrEvent }, }; @@ -363,7 +396,8 @@ export class InMemoryMessageStore implements MessageStore { * * @param limit - Maximum rows. * @returns A new array of row copies; mutating it does not change the store. - * Listed objects never expose photo bytes. + * Listed objects include `hasVideo` / `videoContentType` but never expose + * photo or video bytes (video lives on disk under `MEDIA_DIR`). */ listLatest(limit: number): Promise { const sorted = [...this.#rows].sort((a, b) => { @@ -377,30 +411,40 @@ export class InMemoryMessageStore implements MessageStore { sorted.slice(0, limit).map((row) => { const copy = copyRow(row); copy.hasPhoto = this.#photos.has(row.id) || row.hasPhoto === true; + copy.hasVideo = row.hasVideo === true; + copy.videoContentType = row.videoContentType ?? null; return copy; }), ); } /** - * Append a copy of `row` and optional photo; return a copy. + * Append a copy of `row` and optional photo and video; return a copy. * * @param row - Message to store. * @param photo - Optional photo (bytes copied). - * @returns A copy of the stored row with `hasPhoto` from `photo`. + * @param video - Optional forum video (MIME on the row; bytes via `writeForumVideo` / disk). + * @returns A copy of the stored row with `hasPhoto` from `photo` and + * `hasVideo` / `videoContentType` from `video`. */ - create(row: MessageRow, photo?: ForumPhoto): Promise { + async create(row: MessageRow, photo?: ForumPhoto, video?: ForumVideo): Promise { const hasPhoto = photo !== undefined; + const hasVideo = video !== undefined; const stored = copyRow({ ...unsignedNostrDefaults(), ...row, hasPhoto, + hasVideo, + videoContentType: video === undefined ? null : video.contentType, }); + if (video !== undefined) { + await writeForumVideo(stored.id, video); + } this.#rows.push(stored); if (photo !== undefined) { this.#photos.set(stored.id, copyPhoto(photo)); } - return Promise.resolve(copyRow(stored)); + return copyRow(stored); } /** @@ -483,6 +527,7 @@ export class InMemoryMessageStore implements MessageStore { (row) => row.eventId !== null && row.hasPhoto && + row.hasVideo !== true && row.sats === 0 && row.nostrPublishState === 'published' && kind1MissingPhotoUrl(row.nostrEvent, row.id), @@ -496,6 +541,27 @@ export class InMemoryMessageStore implements MessageStore { return Promise.resolve(rows); } + listSignedMissingVideo(limit: number): Promise { + const rows = this.#rows + .filter( + (row) => + row.eventId !== null && + row.hasVideo === true && + row.videoContentType !== null && + row.videoContentType !== undefined && + row.sats === 0 && + row.nostrPublishState === 'published' && + kind1MissingVideoUrl(row.nostrEvent, row.id), + ) + .sort((left, right) => { + const byTime = left.createdAt.getTime() - right.createdAt.getTime(); + return byTime !== 0 ? byTime : left.id.localeCompare(right.id); + }) + .slice(0, limit) + .map((row) => copyRow(row)); + return Promise.resolve(rows); + } + listSignedMissingHashtags(limit: number): Promise { const rows = this.#rows .filter( @@ -637,6 +703,7 @@ interface MessageSqlRow { text: string; created_at: Date | string; has_photo: boolean | number | string | null; + video_content_type?: string | null; event_id?: string | null; nostr_publish_state?: string | null; sats?: string | number | null; @@ -662,6 +729,13 @@ interface MessagePhotoSqlRow { const FORUM_PHOTO_TYPES: ReadonlySet = new Set(['image/jpeg', 'image/png', 'image/webp']); +function parseVideoContentType(value: string | null | undefined): ForumVideoContentType | null { + if (value === 'video/mp4' || value === 'video/webm' || value === 'video/quicktime') { + return value; + } + return null; +} + /** Map a SQL list row onto {@link MessageRow}. Unexported. */ function mapMessageRow(row: MessageSqlRow): MessageRow { const defaults = unsignedNostrDefaults(); @@ -673,6 +747,11 @@ function mapMessageRow(row: MessageSqlRow): MessageRow { text: row.text, createdAt: row.created_at instanceof Date ? row.created_at : new Date(row.created_at), hasPhoto: Boolean(row.has_photo), + hasVideo: + row.video_content_type !== null && + row.video_content_type !== undefined && + row.video_content_type !== '', + videoContentType: parseVideoContentType(row.video_content_type), eventId: row.event_id ?? defaults.eventId, nostrPublishState: state === 'pending' || state === 'published' || state === 'failed' @@ -698,6 +777,7 @@ function toUint8Array(value: Uint8Array | Buffer | number[]): Uint8Array { /** Shared SELECT list: Nostr columns plus has_photo, never photo bytea. */ const MESSAGE_SELECT_COLUMNS = `id, account_id, name, text, created_at, (photo IS NOT NULL) AS has_photo, + video_content_type, event_id, nostr_publish_state, sats, nostr_event, claimed_until, nostr_first_attempt_at, nostr_publish_epoch, nostr_attempts`; @@ -716,7 +796,9 @@ export class PostgresMessageStore implements MessageStore { /** * Newest-first list from `message`, capped at `limit`. - * Selects `(photo IS NOT NULL) AS has_photo` — never the `photo` bytea column. + * Selects `(photo IS NOT NULL) AS has_photo` and `video_content_type` + * (`hasVideo` / `videoContentType`) — never the `photo` bytea column; video + * bytes live on disk under `MEDIA_DIR`, not as bytea. * * @param limit - Maximum rows (`$1`). * @returns Mapped rows. @@ -731,32 +813,49 @@ export class PostgresMessageStore implements MessageStore { } /** - * Insert `row` (and optional photo) into `message` and return it. + * Insert `row` (and optional photo and video) into `message` and return it. * * @param row - Fully formed message. * @param photo - Optional decoded photo. - * @returns The stored row after a successful insert (a copy). + * @param video - Optional forum video (MIME on the row; bytes via `writeForumVideo` / disk). + * @returns The stored row after a successful insert (a copy) with `hasPhoto` + * from `photo` and `hasVideo` / `videoContentType` from `video`. INSERT + * failure unlinks the video (`removeForumVideo`). */ - async create(row: MessageRow, photo?: ForumPhoto): Promise { + async create(row: MessageRow, photo?: ForumPhoto, video?: ForumVideo): Promise { const hasPhoto = photo !== undefined; + const hasVideo = video !== undefined; const stored = copyRow({ ...unsignedNostrDefaults(), ...row, hasPhoto, + hasVideo, + videoContentType: video === undefined ? null : video.contentType, }); - await this.#sql.execute( - `INSERT INTO message (id, account_id, name, text, photo, photo_content_type, created_at, nostr_publish_state, sats) - VALUES ($1,$2,$3,$4,$5,$6,$7,'pending',0)`, - [ - stored.id, - stored.accountId, - stored.name, - stored.text, - photo === undefined ? null : photo.bytes, - photo === undefined ? null : photo.contentType, - stored.createdAt, - ], - ); + if (video !== undefined) { + await writeForumVideo(stored.id, video); + } + try { + await this.#sql.execute( + `INSERT INTO message (id, account_id, name, text, photo, photo_content_type, video_content_type, created_at, nostr_publish_state, sats) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'pending',0)`, + [ + stored.id, + stored.accountId, + stored.name, + stored.text, + photo === undefined ? null : photo.bytes, + photo === undefined ? null : photo.contentType, + stored.videoContentType, + stored.createdAt, + ], + ); + } catch (err) { + if (video !== undefined) { + await removeForumVideo(stored.id, video.contentType); + } + throw err; + } return stored; } @@ -856,6 +955,7 @@ export class PostgresMessageStore implements MessageStore { FROM message WHERE event_id IS NOT NULL AND photo IS NOT NULL AND sats = 0 AND nostr_publish_state = 'published' + AND (video_content_type IS NULL OR video_content_type = '') AND ( nostr_event IS NULL OR COALESCE(nostr_event->>'content', '') NOT LIKE '%/messages/' || id::text || '/photo.%' @@ -867,6 +967,25 @@ export class PostgresMessageStore implements MessageStore { return rows.map((row) => mapMessageRow(row)); } + async listSignedMissingVideo(limit: number): Promise { + const rows = await this.#sql.query( + `SELECT ${MESSAGE_SELECT_COLUMNS} + FROM message + WHERE event_id IS NOT NULL + AND video_content_type IN ('video/mp4', 'video/webm', 'video/quicktime') + AND sats = 0 + AND nostr_publish_state = 'published' + AND ( + nostr_event IS NULL + OR COALESCE(nostr_event->>'content', '') NOT LIKE '%/messages/' || id::text || '/video.%' + ) + ORDER BY created_at ASC, id ASC + LIMIT $1`, + [limit], + ); + return rows.map((row) => mapMessageRow(row)); + } + async listSignedMissingHashtags(limit: number): Promise { const rows = await this.#sql.query( `SELECT ${MESSAGE_SELECT_COLUMNS} diff --git a/src/lib/message.ts b/src/lib/message.ts index b5ce21cb..e380880e 100644 --- a/src/lib/message.ts +++ b/src/lib/message.ts @@ -1,4 +1,5 @@ import type { AccountRole } from '@/lib/auth/store'; +import type { ForumVideoContentType } from '@/lib/video'; /** * Forum message domain: validation, photo decode, and public JSON projection. @@ -6,7 +7,7 @@ import type { AccountRole } from '@/lib/auth/store'; * Text is free-form encouragement (not unique). Over-long or disallowed * control-character input is rejected so a bad value cannot be stored and * re-served on every list response. Empty trimmed text is allowed when a - * photo is attached. Newlines (`\n`, `\r`) are allowed; other C0 controls + * photo is attached or `hasVideo`. Newlines (`\n`, `\r`) are allowed; other C0 controls * and DEL are not. Photos are JPEG/PNG/WebP only, capped at 1 MiB. */ @@ -44,12 +45,16 @@ export interface MessageRow { accountId: string; /** Display name snapshotted at post time. */ name: string; - /** Message body (already normalised; may be empty when `hasPhoto`). */ + /** Message body (already normalised; may be empty when `hasPhoto` or `hasVideo`). */ text: string; /** Creation instant. */ createdAt: Date; /** Whether a photo is stored for this message (bytes never on the row). */ hasPhoto: boolean; + /** Whether a video file is stored for this message. */ + hasVideo?: boolean; + /** Stored video MIME, or `null`. */ + videoContentType?: ForumVideoContentType | null; /** Signed kind:1 id, or `null` until the worker signs. */ eventId: string | null; /** Fan-out state. */ @@ -74,7 +79,7 @@ export interface PublicMessage { id: string; /** Author display name at post time. */ name: string; - /** Message body (may be empty when `hasPhoto` is true). */ + /** Message body (may be empty when `hasPhoto` or `hasVideo` is true). */ text: string; /** ISO-8601 creation timestamp. */ createdAt: string; @@ -84,6 +89,10 @@ export interface PublicMessage { payable: boolean; /** True when a photo can be fetched via GET `/messages/:id/photo`. */ hasPhoto: boolean; + /** True when a video can be fetched via GET `/messages/:id/video.mp4|.webm|.mov`. */ + hasVideo: boolean; + /** Stored video MIME when `hasVideo` is true; otherwise `null`. */ + videoContentType: ForumVideoContentType | null; /** * Author's live `account.role` (not a snapshot). Always present; `"basis"` * when the author account is missing. @@ -94,7 +103,7 @@ export interface PublicMessage { /** * Trim and validate forum message text. * - * Empty / whitespace-only input becomes `''` (valid for photo-only posts). + * Empty / whitespace-only input becomes `''` (valid for photo-only or video-only posts). * Over-long text and disallowed controls still reject. * * @param raw - User input. @@ -126,8 +135,9 @@ export function normalizeForumText(raw: string): string | null { * @param row - Persisted message. * @param payable - Whether the note can accept a NIP-57 zap payment. * @param role - Author's live {@link AccountRole} (or `'basis'` if missing). - * @returns Public fields (`sats`, `payable`, `hasPhoto`, `role`; no `accountId`); - * `createdAt` ISO-8601. Never includes photo bytes. + * @returns Public fields (`sats`, `payable`, `hasPhoto`, `hasVideo`, + * `videoContentType`, `role`; no `accountId`); `createdAt` ISO-8601. Never + * includes photo or video bytes. */ export function serializeMessage( row: MessageRow, @@ -142,6 +152,8 @@ export function serializeMessage( sats: row.sats, payable, hasPhoto: row.hasPhoto, + hasVideo: row.hasVideo === true, + videoContentType: row.videoContentType ?? null, role, }; } diff --git a/src/lib/nip05.ts b/src/lib/nip05.ts new file mode 100644 index 00000000..e3666f58 --- /dev/null +++ b/src/lib/nip05.ts @@ -0,0 +1,180 @@ +/** + * NIP-05 local-parts and directory JSON so Damus can show a domain checkmark. + */ + +import type { Account, AuthStore } from '@/lib/auth/store'; +import { resolveWriteSet, writeRelayUrls } from '@/lib/nostr/relays'; + +/** One NIP-05 mapping ready to publish on kind:0 and in `nostr.json`. */ +export interface Nip05Entry { + /** Account id. */ + accountId: string; + /** Display name. */ + name: string; + /** Hex pubkey. */ + pubkey: string; + /** Local-part (`alice` in `alice@21.gifts`). */ + local: string; +} + +/** + * Hostname used after `@` in `nip05`. + * + * Loopback/IPs are skipped so tests without a public host do not mint junk identifiers. + * + * @param env - Process env. + * @returns Hostname from `PUBLIC_BASE_URL`, or `null`. + */ +export function nip05Domain(env: Record): string | null { + const base = env['PUBLIC_BASE_URL']?.trim() ?? ''; + if (base === '') { + return null; + } + try { + const host = new URL(base).hostname.toLowerCase(); + if ( + host === '' || + host === 'localhost' || + /^\d+\.\d+\.\d+\.\d+$/.test(host) || + host === '::1' || + host.includes(':') + ) { + return null; + } + return host; + } catch { + return null; + } +} + +/** + * Slug a display name into a NIP-05 local-part. + * + * @param name - Account display name. + * @returns `a-z0-9-` slug, or `user` when empty. + */ +export function nip05Slug(name: string): string { + const slug = name + .normalize('NFKD') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 24); + return slug === '' ? 'user' : slug; +} + +/** + * Allocate a unique local-part. First account with a slug keeps it; later + * collisions append 8 hex chars of the account id. + * + * @param name - Display name. + * @param accountId - Account id. + * @param taken - Locals already assigned in this pass. + * @returns Unique local-part. + */ +export function allocateNip05Local(name: string, accountId: string, taken: Set): string { + const base = nip05Slug(name); + if (!taken.has(base)) { + return base; + } + const hex = accountId.replace(/-/g, ''); + for (let n = 8; n <= hex.length; n += 1) { + const candidate = `${base}-${hex.slice(0, n)}`.slice(0, 32); + if (!taken.has(candidate)) { + return candidate; + } + } + let i = 2; + while (true) { + const candidate = `${base}-${String(i)}`.slice(0, 32); + if (!taken.has(candidate)) { + return candidate; + } + i += 1; + } +} + +/** + * Build the NIP-05 identifier for one named account, matching `nostr.json`. + * + * @param account - Named account. + * @param namedOldestFirst - All named accounts, oldest first. + * @param domain - Hostname (e.g. `21.gifts`). + * @returns `local@domain`. + */ +export function nip05Identifier( + account: Account, + namedOldestFirst: readonly Account[], + domain: string, +): string { + const taken = new Set(); + for (const row of namedOldestFirst) { + if (row.name === null || row.name.trim() === '') { + continue; + } + const local = allocateNip05Local(row.name, row.id, taken); + taken.add(local); + if (row.id === account.id) { + return `${local}@${domain}`; + } + } + /* v8 ignore next -- caller always includes the account in namedOldestFirst */ + return `${allocateNip05Local(account.name ?? 'user', account.id, taken)}@${domain}`; +} + +/** + * Load named accounts that already have a Nostr pubkey, oldest first. + * + * @param auth - Auth store. + * @returns Directory rows. + */ +export async function listNip05Entries(auth: AuthStore): Promise { + const accounts = await auth.listAccounts(); + const named = accounts + .filter((row): row is Account & { name: string } => row.name !== null && row.name.trim() !== '') + .sort((left, right) => { + const byTime = left.createdAt - right.createdAt; + /* v8 ignore next -- same createdAt, sort by id */ + return byTime !== 0 ? byTime : left.id.localeCompare(right.id); + }); + const taken = new Set(); + const entries: Nip05Entry[] = []; + for (const account of named) { + const local = allocateNip05Local(account.name, account.id, taken); + taken.add(local); + const pubkey = await auth.getNostrPublicKey(account.id); + if (pubkey === undefined) { + continue; + } + entries.push({ accountId: account.id, name: account.name, pubkey, local }); + } + return entries; +} + +/** + * NIP-05 `nostr.json` body (names + recommended relays). + * + * @param auth - Auth store. + * @param env - Process env for write-set relays. + * @param nameFilter - Optional `?name=` filter (NIP-05 clients send this). + * @returns JSON-serialisable directory. + */ +export async function buildNostrJson( + auth: AuthStore, + env: Record, + nameFilter?: string, +): Promise<{ names: Record; relays: Record }> { + const entries = await listNip05Entries(auth); + const filtered = + nameFilter === undefined || nameFilter.trim() === '' + ? entries + : entries.filter((row) => row.local === nameFilter.trim().toLowerCase()); + const names: Record = {}; + const relays: Record = {}; + const relayList = writeRelayUrls(resolveWriteSet(env)); + for (const row of filtered) { + names[row.local] = row.pubkey; + relays[row.pubkey] = relayList; + } + return { names, relays }; +} diff --git a/src/lib/nostr/event.ts b/src/lib/nostr/event.ts index 9309b12e..fef8789b 100644 --- a/src/lib/nostr/event.ts +++ b/src/lib/nostr/event.ts @@ -25,12 +25,14 @@ export const KIND1_CONTENT_HASHTAGS: readonly ['#bitcoin', '#21gifts'] = [ /** Public PNG used as every kind:0 `picture` so Damus shows 21.gifts branding. */ export const KIND0_PICTURE_URL = 'https://21.gifts/apple-touch-icon.png'; -/** Optional NIP-92 image attached to a kind:1. */ +/** Optional NIP-92 media (image or video) attached to a kind:1. */ export interface Kind1Photo { /** Absolute HTTPS URL clients fetch. */ url: string; - /** Stored MIME type. */ - mime: 'image/jpeg' | 'image/png' | 'image/webp'; + /** Stored MIME type (image or video). */ + mime: 'image/jpeg' | 'image/png' | 'image/webp' | 'video/mp4' | 'video/webm' | 'video/quicktime'; + /** Optional poster image URL (video `imeta` `image` field). */ + posterUrl?: string; } /** @@ -145,7 +147,7 @@ export interface UnsignedKind1 { * * @param content - Already-normalised forum text (may be empty when `photo` is set). * @param createdAtUnix - Unix seconds for the event. - * @param photo - Optional public image (URL in content + NIP-92 `imeta`). + * @param photo - Optional public media (image URL or video URL + MIME; poster URL when video). * @returns Unsigned event fields for `finalizeEvent`. */ export function buildKind1Event( @@ -157,7 +159,11 @@ export function buildKind1Event( let body = content; if (photo !== undefined) { body = content === '' ? photo.url : `${content}\n${photo.url}`; - tags.push(['imeta', `url ${photo.url}`, `m ${photo.mime}`]); + const imeta = ['imeta', `url ${photo.url}`, `m ${photo.mime}`]; + if (photo.posterUrl !== undefined && photo.posterUrl !== '') { + imeta.push(`image ${photo.posterUrl}`); + } + tags.push(imeta); } body = kind1ContentWithHashtags(body); return { @@ -180,28 +186,42 @@ export interface Kind0ProfileContent { picture: string; /** LUD-16 when the account has a linked address. */ lud16?: string; + /** NIP-05 identifier (`name@21.gifts`) when the public host is set. */ + nip05?: string; + /** Short bio. */ + about?: string; } /** * Build kind:0 `content` JSON (no extra whitespace). * * Omit `lud16` when the account has no Lightning Address. Always set `picture` - * to {@link KIND0_PICTURE_URL}. Do not set `nip05` or `bot` in v1. + * to {@link KIND0_PICTURE_URL} and `about` to `21.gifts`. Set `nip05` when a + * public host is available. Never set `bot`. * * @param name - Non-null display name. * @param lightningAddress - Linked LUD-16, or `null`. + * @param nip05 - NIP-05 identifier, or `null`. * @returns JSON string for the kind:0 `content` field. */ -export function buildKind0Content(name: string, lightningAddress: string | null): string { +export function buildKind0Content( + name: string, + lightningAddress: string | null, + nip05: string | null = null, +): string { const body: Kind0ProfileContent = { name, display_name: name, website: 'https://21.gifts', picture: KIND0_PICTURE_URL, + about: '21.gifts', }; if (lightningAddress !== null) { body.lud16 = lightningAddress; } + if (nip05 !== null && nip05 !== '') { + body.nip05 = nip05; + } return JSON.stringify(body); } @@ -223,16 +243,18 @@ export interface UnsignedKind0 { * @param name - Non-null display name. * @param lightningAddress - Linked LUD-16, or `null`. * @param createdAtUnix - Unix seconds at enqueue/publish. + * @param nip05 - NIP-05 identifier, or `null`. * @returns Unsigned event fields for `finalizeEvent`. */ export function buildKind0Event( name: string, lightningAddress: string | null, createdAtUnix: number, + nip05: string | null = null, ): UnsignedKind0 { return { kind: 0, - content: buildKind0Content(name, lightningAddress), + content: buildKind0Content(name, lightningAddress, nip05), tags: [], created_at: createdAtUnix, }; diff --git a/src/lib/nostr/worker.ts b/src/lib/nostr/worker.ts index 1273a5a1..da9bf156 100644 --- a/src/lib/nostr/worker.ts +++ b/src/lib/nostr/worker.ts @@ -9,7 +9,10 @@ import { buildKind1Event, buildKind10002Event, forumPhotoUrl, + type Kind1Photo, } from '@/lib/nostr/event'; +import { nip05Domain, nip05Identifier } from '@/lib/nip05'; +import { forumVideoUrl } from '@/lib/video'; import { ensureAccountNostrKey } from '@/lib/nostr/keys'; import { publicAcked, spaceAcked, type NostrPublisher } from '@/lib/nostr/publish'; import type { NostrEventFrame, NostrQuerier } from '@/lib/nostr/query'; @@ -104,15 +107,16 @@ function reservedContent( * `published`/`space`. With public on, space-only ACK parks `pending`/`space` * until a public ACK makes `published`/`public`. Pending kind:1 JSON without * `t=bitcoin` is dropped and re-signed before fan-out. Then unsigned rows are - * signed. Then published unpaid rows missing a photo URL (`PUBLIC_BASE_URL` - * set) or Damus `#bitcoin`/`#21gifts` in content are reset for the next tick. - * Pending rows EVENT as-is — resetting them first renews the 60s sign lease - * and they never reach a relay. Zapped rows (`sats !== 0`) keep their event - * id so receipts still resolve. An empty API base skips photo-URL resign so - * it cannot un-publish and loop. When publishing, also fans out a replaceable - * kind:0 profile (`name` / `display_name` / `picture`) and a NIP-65 - * kind:10002 relay list. Kind:1 photo posts include the public image URL - * and an `imeta` tag. Kind:0 + * signed. Then published unpaid rows missing a photo URL or a video URL + * (`PUBLIC_BASE_URL` set) or Damus `#bitcoin`/`#21gifts` in content are reset + * for the next tick. Pending rows EVENT as-is — resetting them first renews + * the 60s sign lease and they never reach a relay. Zapped rows (`sats !== 0`) + * keep their event id so receipts still resolve. An empty API base skips + * photo- and video-URL resign so it cannot un-publish and loop. When + * publishing, also fans out a replaceable kind:0 profile (`name` / + * `display_name` / `picture`, optional `nip05`) and a NIP-65 kind:10002 + * relay list. Kind:1 photo and video posts include the public media URL and + * an `imeta` tag (video may add poster `image`). Kind:0 * `created_at` is `max(wall clock, last issued + 1)` so an in-flight older * profile cannot win a same-second replaceable-event tie. Each tick also queries * zap relays (space plus the public list, even when `NOSTR_PUBLISH_PUBLIC` is @@ -127,6 +131,7 @@ export async function runNostrWorkerTick(deps: NostrWorkerDeps): Promise { await resignLegacyKind1Tags(deps); await signBatch(deps, nowMs); await resignPhotoKind1(deps); + await resignVideoKind1(deps); await resignHashtagKind1(deps); if (writeSet.publishEnabled) { await publishProfiles(deps, writeSet); @@ -172,6 +177,13 @@ async function resignPhotoKind1(deps: NostrWorkerDeps): Promise { await resetPublishedBatch(deps, await deps.messages.listSignedMissingPhoto(WORKER_BATCH)); } +async function resignVideoKind1(deps: NostrWorkerDeps): Promise { + if (resolvePublicApiBase(deps.env) === '') { + return; + } + await resetPublishedBatch(deps, await deps.messages.listSignedMissingVideo(WORKER_BATCH)); +} + async function resignHashtagKind1(deps: NostrWorkerDeps): Promise { await resetPublishedBatch(deps, await deps.messages.listSignedMissingHashtags(WORKER_BATCH)); } @@ -203,10 +215,19 @@ async function signBatch(deps: NostrWorkerDeps, nowMs: number): Promise { let createdAt = Math.floor(row.createdAt.getTime() / 1000); let stored = false; const apiBase = resolvePublicApiBase(deps.env); - let photo: { url: string; mime: 'image/jpeg' | 'image/png' | 'image/webp' } | undefined; + let photo: Kind1Photo | undefined; if (apiBase !== '') { const storedPhoto = await deps.messages.getPhoto(row.id); - if (storedPhoto !== null) { + const videoMime = row.videoContentType; + if (videoMime !== null && videoMime !== undefined && row.hasVideo === true) { + photo = { + url: forumVideoUrl(apiBase, row.id, videoMime), + mime: videoMime, + ...(storedPhoto !== null + ? { posterUrl: forumPhotoUrl(apiBase, row.id, storedPhoto.contentType) } + : {}), + }; + } else if (storedPhoto !== null) { photo = { url: forumPhotoUrl(apiBase, row.id, storedPhoto.contentType), mime: storedPhoto.contentType, @@ -245,6 +266,8 @@ async function publishProfiles(deps: NostrWorkerDeps, writeSet: ResolvedWriteSet const watermarks = profileWatermarkFor(deps.auth); const urls = writeRelayUrls(writeSet); const accounts = await deps.auth.listAccounts(); + const named = accounts.filter((row) => row.name !== null && row.name.trim() !== ''); + const domain = nip05Domain(deps.env); let attempted = 0; for (const account of accounts) { if (attempted >= WORKER_BATCH) { @@ -254,7 +277,9 @@ async function publishProfiles(deps: NostrWorkerDeps, writeSet: ResolvedWriteSet if (live === undefined || live.name === null) { continue; } - const content = buildKind0Content(live.name, live.lightningAddress); + const namedForLive = named.map((row) => (row.id === live.id ? live : row)); + const nip05 = domain === null ? null : nip05Identifier(live, namedForLive, domain); + const content = buildKind0Content(live.name, live.lightningAddress, nip05); if (reservedContent(cache, live.id) === content) { continue; } @@ -279,7 +304,12 @@ async function publishProfiles(deps: NostrWorkerDeps, writeSet: ResolvedWriteSet const wall = Math.floor(deps.now() / 1000); reservation.createdAt = Math.max(wall, reservation.createdAt + 1); watermarks.set(live.id, reservation.createdAt); - const unsigned = buildKind0Event(live.name, live.lightningAddress, reservation.createdAt); + const unsigned = buildKind0Event( + live.name, + live.lightningAddress, + reservation.createdAt, + nip05, + ); const signed = await signEventForAccount(deps.auth, live.id, deps.kek, unsigned); if (cache.get(live.id) !== reservation) { continue; diff --git a/src/lib/video.ts b/src/lib/video.ts new file mode 100644 index 00000000..afb4ff58 --- /dev/null +++ b/src/lib/video.ts @@ -0,0 +1,272 @@ +/** + * Forum video validation: magic-byte MIME, size cap, and filename extension + * so Damus embeds the URL as a player instead of a website card. + */ + +import { createReadStream } from 'node:fs'; +import { mkdir, writeFile, unlink } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { Readable } from 'node:stream'; + +/** Maximum decoded video size (32 MiB). */ +export const MESSAGE_VIDEO_MAX_BYTES = 32 * 1024 * 1024; + +/** Allowed stored video types. */ +export type ForumVideoContentType = 'video/mp4' | 'video/webm' | 'video/quicktime'; + +/** ISO-BMFF major brands treated as MP4 (lowercased 4-byte brand). */ +const MP4_BRANDS = new Set([ + 'isom', + 'iso2', + 'iso3', + 'iso4', + 'iso5', + 'iso6', + 'mp41', + 'mp42', + 'mp71', + 'avc1', + 'avc3', + 'dash', + 'm4v ', +]); + +/** Decoded forum video ready for disk. */ +export interface ForumVideo { + /** MIME from magic bytes. */ + contentType: ForumVideoContentType; + /** Raw container bytes. */ + bytes: Uint8Array; +} + +/** + * Resolve the on-disk media directory. + * + * @param env - Process env (injected for tests). + * @returns `MEDIA_DIR` when set, otherwise a process-local temp dir. + */ +export function resolveMediaDir(env: Record = process.env): string { + const raw = env['MEDIA_DIR']; + if (raw !== undefined && raw.trim() !== '') { + return raw.trim(); + } + return join(tmpdir(), '21gifts-media'); +} + +/** + * Filename extension Damus treats as inline video. + * + * @param mime - Stored type. + * @returns `mp4`, `webm`, or `mov`. + */ +export function forumVideoExt(mime: ForumVideoContentType): 'mp4' | 'webm' | 'mov' { + if (mime === 'video/webm') { + return 'webm'; + } + if (mime === 'video/quicktime') { + return 'mov'; + } + return 'mp4'; +} + +/** + * Absolute public video URL for kind:1 + `imeta`. + * + * @param apiBase - Public API origin. + * @param messageId - Message id. + * @param mime - Stored type. + * @returns `GET /messages/:id/video.mp4` (or `.webm` / `.mov`). + */ +export function forumVideoUrl( + apiBase: string, + messageId: string, + mime: ForumVideoContentType, +): string { + return `${apiBase.replace(/\/$/, '')}/messages/${messageId}/video.${forumVideoExt(mime)}`; +} + +/** + * On-disk path for a message video. + * + * @param dir - Media directory. + * @param messageId - Message id. + * @param mime - Stored type. + * @returns Absolute file path. + */ +export function videoFilePath(dir: string, messageId: string, mime: ForumVideoContentType): string { + return join(dir, `${messageId}.${forumVideoExt(mime)}`); +} + +/** + * Detect MP4 / QuickTime / WebM from magic bytes. + * + * @param bytes - Raw candidate. + * @returns Matching type, or `null`. + */ +export function detectVideoContentType(bytes: Uint8Array): ForumVideoContentType | null { + if ( + bytes.length >= 12 && + bytes[4] === 0x66 && + bytes[5] === 0x74 && + bytes[6] === 0x79 && + bytes[7] === 0x70 + ) { + const brand = String.fromCharCode( + bytes[8] as number, + bytes[9] as number, + bytes[10] as number, + bytes[11] as number, + ); + if (brand.startsWith('qt')) { + return 'video/quicktime'; + } + if (MP4_BRANDS.has(brand.toLowerCase())) { + return 'video/mp4'; + } + return null; + } + if ( + bytes.length >= 4 && + bytes[0] === 0x1a && + bytes[1] === 0x45 && + bytes[2] === 0xdf && + bytes[3] === 0xa3 + ) { + const head = String.fromCharCode(...bytes.subarray(0, Math.min(256, bytes.length))); + if (head.toLowerCase().includes('webm')) { + return 'video/webm'; + } + return null; + } + return null; +} + +/** + * Validate raw video bytes (size + magic). + * + * @param bytes - Uploaded bytes. + * @returns A {@link ForumVideo}, or `null` when empty, oversize, or unrecognized. + */ +export function decodeForumVideo(bytes: Uint8Array): ForumVideo | null { + if (bytes.length === 0 || bytes.length > MESSAGE_VIDEO_MAX_BYTES) { + return null; + } + const contentType = detectVideoContentType(bytes); + if (contentType === null) { + return null; + } + return { contentType, bytes: bytes.slice() }; +} + +/** + * Write video bytes to `MEDIA_DIR`. Creates the directory when missing. + * + * @param messageId - Message id (filename stem). + * @param video - Validated video. + * @param env - Process env. + */ +export async function writeForumVideo( + messageId: string, + video: ForumVideo, + env: Record = process.env, +): Promise { + const dir = resolveMediaDir(env); + await mkdir(dir, { recursive: true }); + await writeFile(videoFilePath(dir, messageId, video.contentType), video.bytes); +} + +/** + * Delete a video file if present (best-effort). + * + * @param messageId - Message id. + * @param mime - Stored type. + * @param env - Process env. + */ +export async function removeForumVideo( + messageId: string, + mime: ForumVideoContentType, + env: Record = process.env, +): Promise { + try { + await unlink(videoFilePath(resolveMediaDir(env), messageId, mime)); + } catch { + /* missing file is fine */ + } +} + +/** + * Result of {@link parseBytesRange} (RFC 7233 single `bytes` range). + * + * - `full` — ignore Range and send the whole file (200). + * - `partial` — inclusive `start`/`end` for a 206 slice. + * - `unsatisfiable` — well-formed but `start >= size` (416). + */ +export type ParsedBytesRange = + { type: 'full' } | { type: 'partial'; start: number; end: number } | { type: 'unsatisfiable' }; + +/** + * Parse a single `bytes=start-end` Range header (RFC 7233). + * + * Missing, blank, or malformed headers (wrong unit, empty bounds, inverted + * range, `bytes=-0`, or any range when `size === 0`) become `{ type: 'full' }`. + * A well-formed range whose start is past the last byte becomes + * `{ type: 'unsatisfiable' }`. + * + * @param header - Raw Range header, or undefined. + * @param size - File size in bytes. + * @returns Discriminated parse result for 200 / 206 / 416. + */ +export function parseBytesRange(header: string | undefined, size: number): ParsedBytesRange { + if (header === undefined || header.trim() === '') { + return { type: 'full' }; + } + const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim()); + if (match === null || size <= 0) { + return { type: 'full' }; + } + /* v8 ignore next 2 -- capturing groups are always strings */ + const startRaw = match[1] === undefined ? '' : match[1]; + const endRaw = match[2] === undefined ? '' : match[2]; + if (startRaw === '' && endRaw === '') { + return { type: 'full' }; + } + if (startRaw === '') { + const suffix = Number(endRaw); + if (!Number.isFinite(suffix) || suffix <= 0) { + return { type: 'full' }; + } + const start = Math.max(0, size - suffix); + return { type: 'partial', start, end: size - 1 }; + } + const start = Number(startRaw); + /* v8 ignore next 3 -- \d* from the regex is always a finite non-negative */ + if (!Number.isFinite(start) || start < 0) { + return { type: 'full' }; + } + if (start >= size) { + return { type: 'unsatisfiable' }; + } + const end = endRaw === '' ? size - 1 : Number(endRaw); + if (!Number.isFinite(end) || start > end) { + return { type: 'full' }; + } + return { type: 'partial', start, end: Math.min(end, size - 1) }; +} + +/** + * Stream a byte-inclusive file slice without loading the file into RAM. + * + * @param path - Absolute path. + * @param start - Inclusive start offset. + * @param end - Inclusive end offset. + * @returns A web `ReadableStream` suitable as a `Response` body. + */ +export function streamForumVideo( + path: string, + start: number, + end: number, +): ReadableStream { + const nodeStream = createReadStream(path, { start, end }); + return Readable.toWeb(nodeStream) as ReadableStream; +} diff --git a/src/routes/debug.ts b/src/routes/debug.ts index 2100eda7..2d711fb0 100644 --- a/src/routes/debug.ts +++ b/src/routes/debug.ts @@ -12,7 +12,8 @@ import { normalizeDisplayName } from '@/lib/name'; /** * Operator debug surface for registered accounts. * Authenticated by `DEBUG_TOKEN` (Bearer), not by an end-user session. - * Exposes `GET /` (list), `POST /` (provision), and `PATCH /:id` (set role). + * Exposes `GET /` (list), `POST /` (provision), and `PATCH /:id` + * (set role and/or unlink Lightning Address). */ /** Collaborators the debug routes need. */ @@ -23,10 +24,13 @@ export interface DebugRouteDeps { debugToken: string | undefined; } -/** Body schema for operator role assignment. */ -const roleBody = z.object({ - role: z.enum(['basis', 'verified', 'moderator', 'founder']), -}); +/** Body schema for operator role assignment and Lightning Address unlink. */ +const patchBody = z + .object({ + role: z.enum(['basis', 'verified', 'moderator', 'founder']).optional(), + lightningAddress: z.null().optional(), + }) + .refine((body) => body.role !== undefined || body.lightningAddress === null); /** One row in the operator provision body. */ const provisionAccountRow = z.object({ @@ -161,17 +165,33 @@ export function debugRoutes(deps: DebugRouteDeps): Hono { return c.json({ accounts: results }, 200); }) .patch('/:id', async (c) => { - const parsed = roleBody.safeParse(await c.req.json().catch(() => null)); + const parsed = patchBody.safeParse(await c.req.json().catch(() => null)); if (!parsed.success) { - return c.json({ error: 'Expected a JSON body with a "role" string' }, 400); + return c.json( + { error: 'Expected a JSON body with a "role" string and/or lightningAddress null' }, + 400, + ); } const existing = await deps.store.getAccount(c.req.param('id')); if (existing === undefined) { return c.json({ error: 'Not found' }, 404); } - const updated = { ...existing, role: parsed.data.role }; + const updated = { ...existing }; + if (parsed.data.role !== undefined) { + updated.role = parsed.data.role; + } + if (parsed.data.lightningAddress === null) { + updated.lightningAddress = null; + updated.lightningAddressVerified = false; + } await deps.store.updateAccount(updated); - logEvent('debug.accounts.role_set', { accountId: updated.id, role: updated.role }); + if (parsed.data.lightningAddress === null) { + await deps.store.deleteVerification(updated.id); + logEvent('debug.accounts.lightning_address.cleared', { accountId: updated.id }); + } + if (parsed.data.role !== undefined) { + logEvent('debug.accounts.role_set', { accountId: updated.id, role: updated.role }); + } return c.json(serializeAccount(updated), 200); }); } diff --git a/src/routes/messages.ts b/src/routes/messages.ts index d7e073e3..affa4682 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -1,4 +1,4 @@ -import { Hono } from 'hono'; +import { Hono, type Context } from 'hono'; import { z } from 'zod'; import { resolveSession } from '@/lib/auth/service'; import type { Account, AuthStore } from '@/lib/auth/store'; @@ -9,6 +9,7 @@ import type { FetchFn } from '@/lib/lnurlp'; import { requestZapInvoice } from '@/lib/lnurl-pay'; import { MESSAGE_LIST_LIMIT, + MESSAGE_PHOTO_MAX_BYTES, decodeForumPhoto, normalizeForumText, serializeMessage, @@ -29,6 +30,27 @@ import { buildZapRequest } from '@/lib/nostr/zap-request'; import type { PushStore } from '@/lib/push-store'; import { enqueueForumPushes } from '@/lib/push-worker'; import { bearerToken } from '@/routes/me'; +import { + MESSAGE_VIDEO_MAX_BYTES, + decodeForumVideo, + forumVideoExt, + parseBytesRange, + resolveMediaDir, + streamForumVideo, + videoFilePath, + type ForumVideo, +} from '@/lib/video'; +import { stat } from 'node:fs/promises'; + +/** True when `err` is a Node errno with `code === 'ENOENT'`. */ +function isPathNotFound(err: unknown): boolean { + return ( + typeof err === 'object' && + err !== null && + 'code' in err && + (err as NodeJS.ErrnoException).code === 'ENOENT' + ); +} /** Placeholder author id when the message/author is unknown at persist time. */ const UNKNOWN_ACCOUNT_ID = '00000000-0000-0000-0000-000000000000'; @@ -175,6 +197,159 @@ async function serveForumPhoto(deps: MessagesRouteDeps, id: string): Promise { + if (!MESSAGE_ID_RE.test(id)) { + return Response.json({ error: 'Video not found' }, { status: 404 }); + } + try { + const row = await deps.store.getById(id); + const mime = row?.videoContentType ?? null; + if (row === undefined || row.hasVideo !== true || mime === null) { + return Response.json({ error: 'Video not found' }, { status: 404 }); + } + if (forumVideoExt(mime) !== ext) { + return Response.json({ error: 'Video not found' }, { status: 404 }); + } + const path = videoFilePath(resolveMediaDir(), id, mime); + let size: number; + try { + const fileStat = await stat(path); + if (!fileStat.isFile() || fileStat.size === 0) { + return Response.json({ error: 'Video not found' }, { status: 404 }); + } + size = fileStat.size; + } catch (err) { + if (isPathNotFound(err)) { + return Response.json({ error: 'Video not found' }, { status: 404 }); + } + throw err; + } + const range = parseBytesRange(c.req.header('range') ?? undefined, size); + const headers: Record = { + 'Content-Type': mime, + 'Accept-Ranges': 'bytes', + 'Cache-Control': 'public, max-age=86400', + 'Access-Control-Allow-Origin': '*', + 'Content-Disposition': `inline; filename="video.${ext}"`, + }; + if (range.type === 'unsatisfiable') { + headers['Content-Range'] = `bytes */${size}`; + return new Response(null, { status: 416, headers }); + } + if (range.type === 'full') { + headers['Content-Length'] = String(size); + return new Response(streamForumVideo(path, 0, size - 1), { status: 200, headers }); + } + headers['Content-Length'] = String(range.end - range.start + 1); + headers['Content-Range'] = `bytes ${range.start}-${range.end}/${size}`; + return new Response(streamForumVideo(path, range.start, range.end), { + status: 206, + headers, + }); + } catch { + logEvent('messages.video.failed'); + return Response.json({ error: 'Messages are unavailable' }, { status: 503 }); + } +} + +/** + * `POST /messages` as multipart (`video` file + optional `poster` + `text`). + * The caller applies `postLimiter` (429 + `Retry-After: 10`) before invoking this helper. + * + * @param deps - Store and clock. + * @param c - Request. + * @param account - Authenticated account (already named). + * @returns 200 / 400 / 503. + */ +async function postMultipartMessage( + deps: MessagesRouteDeps, + c: Context, + account: Account, +): Promise { + /* v8 ignore next 3 -- named accounts; trim-empty is the same 400 as JSON POST */ + if (account.name === null || account.name.trim() === '') { + return c.json({ error: 'Set a name before posting' }, 400); + } + const form = await c.req.formData(); + /* v8 ignore next -- form.get is string or File */ + const rawText = String(form.get('text') ?? ''); + const text = normalizeForumText(rawText); + if (text === null) { + return c.json({ error: 'Text must be 1–500 characters' }, 400); + } + const videoPart = form.get('video'); + let video: ForumVideo | undefined; + if (videoPart instanceof File && videoPart.size > 0) { + if (videoPart.size > MESSAGE_VIDEO_MAX_BYTES) { + return c.json({ error: 'Video must be an MP4, WebM, or MOV under 32 MiB' }, 400); + } + const decoded = decodeForumVideo(new Uint8Array(await videoPart.arrayBuffer())); + if (decoded === null) { + return c.json({ error: 'Video must be an MP4, WebM, or MOV under 32 MiB' }, 400); + } + video = decoded; + } + const posterPart = form.get('poster'); + let photo: ForumPhoto | undefined; + if (posterPart instanceof File && posterPart.size > 0) { + if (posterPart.size > MESSAGE_PHOTO_MAX_BYTES) { + return c.json({ error: 'Poster must be a JPEG, PNG, or WebP under 1 MiB' }, 400); + } + const raw = new Uint8Array(await posterPart.arrayBuffer()); + const decoded = decodeForumPhoto('image/jpeg', Buffer.from(raw).toString('base64')); + if (decoded === null) { + return c.json({ error: 'Poster must be a JPEG, PNG, or WebP under 1 MiB' }, 400); + } + photo = decoded; + } + if (text === '' && photo === undefined && video === undefined) { + return c.json({ error: 'Text must be 1–500 characters or include a photo or video' }, 400); + } + const row: MessageRow = { + id: crypto.randomUUID(), + accountId: account.id, + name: account.name.trim(), + text, + createdAt: new Date(deps.now()), + hasPhoto: photo !== undefined, + hasVideo: video !== undefined, + videoContentType: video === undefined ? null : video.contentType, + ...unsignedNostrDefaults(), + }; + try { + const created = await deps.store.create(row, photo, video); + if (deps.pushStore !== undefined) { + try { + await enqueueForumPushes(deps.pushStore, account.id, created.id, deps.now()); + } catch { + logEvent('push.enqueue.failed'); + } + } + return c.json(serializeMessage(created, false, account.role), 200); + } catch { + logEvent('messages.create.failed'); + return c.json({ error: 'Messages are unavailable' }, 503); + } +} + /** Body schema for posting a forum message (text and/or photo). */ const postBody = z .object({ @@ -195,12 +370,14 @@ const invoiceBody = z.object({ sats: z.number().int().positive() }); * Build the `/messages` route group. * * Mounted at `/messages` so the public paths are `GET /messages`, - * `POST /messages`, `GET /messages/:id/photo` (and `.jpg` / `.jpeg` / `.png` / - * `.webp`), and `POST /messages/:id/invoice`. + * `POST /messages` (JSON photo or multipart `video` + optional `poster`), + * `GET /messages/:id/photo` (and `.jpg` / `.jpeg` / `.png` / `.webp`), + * `GET /messages/:id/video.mp4|.webm|.mov`, and `POST /messages/:id/invoice`. * * @param deps - Message store, auth store, clock, and optional `pushStore`. * @returns A Hono app with `GET /`, `POST /`, `GET /:id/photo` plus `.jpg` / - * `.jpeg` / `.png` / `.webp`, and `POST /:id/invoice`. + * `.jpeg` / `.png` / `.webp`, `GET /:id/video.mp4|.webm|.mov`, and + * `POST /:id/invoice`. */ export function messagesRoutes(deps: MessagesRouteDeps): Hono { const postLimiter = deps.postLimiter ?? defaultPostLimiter; @@ -239,6 +416,11 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { c.header('Retry-After', '10'); return c.json({ error: 'Too many messages' }, 429); } + /* v8 ignore next -- missing content-type is JSON parse 400 */ + const requestType = c.req.header('content-type') ?? ''; + if (requestType.toLowerCase().includes('multipart/form-data')) { + return postMultipartMessage(deps, c, account); + } const parsed = postBody.safeParse(await c.req.json().catch(() => null)); if (!parsed.success) { return c.json({ error: 'Expected a JSON body with text and/or photo' }, 400); @@ -292,6 +474,9 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { .get('/:id/photo.png', (c) => serveForumPhoto(deps, c.req.param('id'))) .get('/:id/photo.webp', (c) => serveForumPhoto(deps, c.req.param('id'))) .get('/:id/photo', (c) => serveForumPhoto(deps, c.req.param('id'))) + .get('/:id/video.mp4', (c) => serveForumVideo(deps, c, c.req.param('id'), 'mp4')) + .get('/:id/video.webm', (c) => serveForumVideo(deps, c, c.req.param('id'), 'webm')) + .get('/:id/video.mov', (c) => serveForumVideo(deps, c, c.req.param('id'), 'mov')) .post('/:id/invoice', async (c) => { const account = await authedAccount(deps, c.req.header('authorization')); if (account === null) { diff --git a/src/routes/well-known.ts b/src/routes/well-known.ts new file mode 100644 index 00000000..e6d6a073 --- /dev/null +++ b/src/routes/well-known.ts @@ -0,0 +1,44 @@ +/** + * `GET /.well-known/nostr.json` — NIP-05 directory (CORS `*`). + * + * Damus fetches this from the site apex (`21.gifts` / `dev.21.gifts`); the + * app proxies same-origin. Direct hits on the API host also work. + */ + +import { Hono } from 'hono'; +import type { AuthStore } from '@/lib/auth/store'; +import { buildNostrJson } from '@/lib/nip05'; +import { logEvent } from '@/lib/log'; + +/** Collaborators for the well-known routes. */ +export interface WellKnownRouteDeps { + /** Auth store (names + pubkeys). */ + auth: AuthStore; + /** Process env for write-set relays. */ + env?: Record; +} + +/** + * Build the `/.well-known` route group. + * + * @param deps - Auth store and env. + * @returns Hono app with `GET /nostr.json`. + */ +export function wellKnownRoutes(deps: WellKnownRouteDeps): Hono { + const env = deps.env ?? process.env; + return new Hono().get('/nostr.json', async (c) => { + const cors = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Cache-Control': 'public, max-age=60', + }; + try { + const name = c.req.query('name') ?? undefined; + const body = await buildNostrJson(deps.auth, env, name); + return c.json(body, 200, cors); + } catch { + logEvent('nostr.nip05.failed'); + return c.json({ error: 'Directory is unavailable' }, 503, cors); + } + }); +} diff --git a/src/server.ts b/src/server.ts index ee63513d..806d6191 100644 --- a/src/server.ts +++ b/src/server.ts @@ -15,6 +15,7 @@ import { giftsStatsRoutes } from '@/routes/stats'; import { giftsRoutes } from '@/routes/gifts'; import { invoiceRoutes } from '@/routes/invoices'; import { messagesRoutes } from '@/routes/messages'; +import { wellKnownRoutes } from '@/routes/well-known'; import { contactRoutes } from '@/routes/contact'; import { debugContactsRoutes } from '@/routes/debug-contacts'; import { debugPaymentsRoutes } from '@/routes/debug-payments'; @@ -177,23 +178,38 @@ export function createApp(deps: AppDeps = {}): Hono { const app = new Hono(); app.use('*', requestLog()); + // NIP-05 must stay CORS `*` for any Origin (Damus / browsers). Register this + // before the restrictive allowlist cors so `*` is applied last on the way out + // (Hono middleware registered first wraps later middleware). + app.use( + '/.well-known/*', + cors({ + origin: '*', + allowMethods: ['GET', 'OPTIONS'], + maxAge: 86400, + }), + ); // Browser origin is the apex (21.gifts); the api still listens on api.21.gifts. // CORS covers the apex, transitional app.* aliases, and localhost. // Bearer sessions are headers (no cookies), credentials off. - app.use( - '*', - cors({ + app.use('*', async (c, next) => { + if (c.req.path.startsWith('/.well-known')) { + await next(); + return; + } + return cors({ origin: allowedOrigins, allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'], allowHeaders: ['Authorization', 'Content-Type'], maxAge: 86400, - }), - ); + })(c, next); + }); app.route('/', brandRoutes({ read: readBrand })); app.route('/', pushRoutes({ authStore: store, pushStore, now, vapidPublicKey })); app.route('/healthz', healthRoute); app.route('/info', infoRoute); + app.route('/.well-known', wellKnownRoutes({ auth: store })); app.route( '/auth', authRoutes({