From 68f44725fc49c769fcdbc127229afc51846701a1 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Sun, 30 Aug 2026 14:28:58 +0200 Subject: [PATCH 01/12] 01a04a23 - Publish kind:0 picture, NIP-65, and kind:1 photo imeta (#66) * 01a04a23 - Publish kind:0 picture, NIP-65, and kind:1 photo imeta * 01a04a23 - Keep zapped photo notes on their original event id * 01a04a23 - Filter photo resign to unpaid rows at the store --------- Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- CONTRIBUTING.md | 7 +- FLOWS.md | 2 +- SPEC.md | 19 +- docs/handbook/endpoints.md | 8 +- docs/handbook/functions.md | 45 ++- e2e/functions.spec.ts | 13 +- e2e/http.spec.ts | 8 +- src/__tests__/lib/message-store.test.ts | 109 ++++++ src/__tests__/lib/nostr/event.test.ts | 27 ++ src/__tests__/lib/nostr/relays.test.ts | 34 ++ src/__tests__/lib/nostr/worker.test.ts | 441 +++++++++++++++++++++++- src/__tests__/routes/messages.test.ts | 39 ++- src/lib/message-store.ts | 81 +++++ src/lib/nostr/event.ts | 50 ++- src/lib/nostr/relays.ts | 30 ++ src/lib/nostr/worker.ts | 167 ++++++++- src/routes/messages.ts | 11 +- 17 files changed, 1016 insertions(+), 75 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5693ecf9..c0ef3050 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -360,11 +360,12 @@ Currently: | `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 and replaceable kind:0 profiles over WebSockets. Unchanged kind:0 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 and kind:0 profiles to Damus / Primal / nos.lol. Unset: space ACK is terminal `published`. Does not gate zap ingest or invoice `relays`. | +| `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 and kind:0 write when `NOSTR_PUBLISH_PUBLIC=1`, and always for zap ingest plus invoice `relays` tags (even when that flag is off). | +| `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`. | 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/FLOWS.md b/FLOWS.md index a0f8d949..556c4032 100644 --- a/FLOWS.md +++ b/FLOWS.md @@ -150,7 +150,7 @@ Public comment / encouragement is a v1 surface. The composer POSTs `{ text }` and/or `{ photo: { contentType, data } }` to `POST /messages`; the public thread is listed via `GET /messages` (newest first, name snapshotted at post, `sats`, `payable`, `hasPhoto`, and live author `role` -— never photo bytes). Bytes are `GET /messages/:id/photo`. The shipped UI +— never photo bytes). Bytes are public `GET /messages/:id/photo` (Nostr `imeta`). The shipped UI is a messenger-group thread: oldest notes at the top, newest at the bottom, composer under the newest note. The welcome-forum living-room laws hint is dismissed via `POST /me/forum-laws-dismissed`. Posts are standalone kind:1 diff --git a/SPEC.md b/SPEC.md index e87d3a81..069a96a9 100644 --- a/SPEC.md +++ b/SPEC.md @@ -4,7 +4,7 @@ > Product decisions live in [`CONCEPT.md`](./CONCEPT.md); this file owns > request/response contracts for routes that exist in code today. -**Status**: living document. Last revised 2026-08-29 (forum `account.role` `basis`\|`verified`\|`moderator`\|`founder`; live `role` on `GET/POST /messages`; `PATCH /debug/accounts/:id`; private in-app `POST /contact` + `GET /debug/contacts`; `POST /me/lightning-address` live-resolves and requires zap metadata; invoice limiter after payable checks; public forum `GET/POST /messages` with `sats`/`payable`/`hasPhoto`; `GET /messages/:id/photo`; worker indexes kind:9735 zap receipts onto `sats`; `POST /messages/:id/invoice` NIP-57 zap; SQL boot requires `NOSTR_NSEC_KEK`; passkey-only login; gift stats BTC + historical USD via Coinbase daily close; `GET /gifts?day=`). +**Status**: living document. Last revised 2026-08-30 (kind:0 `picture` + NIP-65 kind:10002; kind:1 NIP-92 `imeta` photo URLs; public `GET /messages/:id/photo`; forum `account.role` `basis`\|`verified`\|`moderator`\|`founder`; live `role` on `GET/POST /messages`; `PATCH /debug/accounts/:id`; private in-app `POST /contact` + `GET /debug/contacts`; `POST /me/lightning-address` live-resolves and requires zap metadata; invoice limiter after payable checks; public forum `GET/POST /messages` with `sats`/`payable`/`hasPhoto`; worker indexes kind:9735 zap receipts onto `sats`; `POST /messages/:id/invoice` NIP-57 zap; SQL boot requires `NOSTR_NSEC_KEK`; passkey-only login; gift stats BTC + historical USD via Coinbase daily close; `GET /gifts?day=`). --- @@ -77,7 +77,7 @@ Public base URLs used in examples: | POST | `/me/lightning-address/verification/confirm` | Bearer | Confirm nonce from wallet history | | GET | `/messages` | Bearer | List public forum thread | | POST | `/messages` | Bearer | Post text and/or one photo to the forum | -| GET | `/messages/:id/photo` | Bearer | Fetch forum message photo bytes | +| GET | `/messages/:id/photo` | none | Fetch forum message photo bytes | | POST | `/messages/:id/invoice` | Bearer | NIP-57 zap / BOLT11 | | POST | `/contact` | Bearer | Send private in-app contact `{ text }` | | GET | `/lightning-address` | none | Resolve LUD-16 metadata (cached) | @@ -1113,15 +1113,10 @@ limiter still counts. LNURL/zap failure → ### `GET /messages/:id/photo` -Fetch the optional photo bytes for one forum message. Bearer session -required. Missing message, message-without-photo, and a non-UUID `id` are -the same **404** (Postgres would otherwise throw on `uuid` and become 503). - -Missing/invalid/expired bearer → **Response** `401`: - -```json -{ "error": "Unauthorized" } -``` +Fetch the optional photo bytes for one forum message. **No bearer** — Damus +loads this URL from kind:1 `imeta`. Missing message, message-without-photo, +and a non-UUID `id` are the same **404** (Postgres would otherwise throw on +`uuid` and become 503). No photo for `id` → **Response** `404`: @@ -1137,7 +1132,7 @@ Store failure → **Response** `503`: Success → **Response** `200`: raw image body, `Content-Type` one of `image/jpeg` / `image/png` / `image/webp` (from stored magic-derived type), -`Cache-Control: private`. Not JSON. +`Cache-Control: public, max-age=86400`. Not JSON. ### `POST /contact` diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index fc774b04..9c55be2a 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -149,10 +149,10 @@ ## Endpoint: GET /messages/:id/photo -- **Purpose:** Bearer required. Returns raw photo bytes for one message (`Content-Type` jpeg/png/webp, `Cache-Control: private`). List JSON never embeds bytes — clients fetch here when `hasPhoto` is true. -- **Errors:** 401 `{ error: 'Unauthorized' }`; 404 `{ error: 'Photo not found' }` when the id is missing, not a UUID, or has no photo; 503 `{ error: 'Messages are unavailable' }` (`messages.photo.failed`). -- **Used by:** App forum photo display. -- **Auth:** `Authorization: Bearer` session. +- **Purpose:** Public. Returns raw photo bytes for one message (`Content-Type` jpeg/png/webp, `Cache-Control: public, max-age=86400`) so Nostr clients can load NIP-92 `imeta` URLs. List JSON never embeds bytes — clients fetch here when `hasPhoto` is true. +- **Errors:** 404 `{ error: 'Photo not found' }` when the id is missing, not a UUID, or has no photo; 503 `{ error: 'Messages are unavailable' }` (`messages.photo.failed`). +- **Used by:** App forum photo display; Damus/Primal via kind:1 photo URLs. +- **Auth:** none. ## Endpoint: POST /messages diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 1054479c..dfbabc57 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -149,7 +149,7 @@ ## Function: PostgresMessageStore -- **Purpose:** Durable `MessageStore` over Postgres (`message` table). `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; `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; `updateSignedEvent` (false on `event_id` collision); `updatePublishState`; `addSats`; `recordZapReceipt` (one statement: `INSERT nostr_zap_receipt ON CONFLICT DO NOTHING` plus `UPDATE message.sats`). +- **Purpose:** Durable `MessageStore` over Postgres (`message` table). `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; `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 signed rows with a photo whose kind:1 content lacks `/messages/:id/photo` (any publish state, `sats = 0`, `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`). - **Inputs:** Constructor takes a shared boot `SqlClient` (already migrated). - **Returns / side effects:** Parameter-bound SQL; maps snake_case rows to `MessageRow` / `ForumPhoto`. Claim uses `FOR UPDATE SKIP LOCKED`. Errors propagate to the route (503). - **Used by:** `openBootStores` when `DATABASE_URL` is set. @@ -240,7 +240,7 @@ ## 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, `listPendingSigned` (pending, no `t=bitcoin`, oldest-first), `clearSignedEvent` (pending and `eventId` still matches `expectedEventId`, then nulls `eventId` / `nostrEvent` / `claimedUntil`), `addSats`, `recordZapReceipt` (duplicate receipt id does not add sats); `updateSignedEvent` returns false on duplicate `eventId`. Store/HTTP order is newest-first; product UX is a messenger group (clients reverse). +- **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, `listPendingSigned` (pending, no `t=bitcoin`, oldest-first), `clearSignedEvent` (pending and `eventId` still matches `expectedEventId`, then nulls `eventId` / `nostrEvent` / `claimedUntil`), `listSignedMissingPhoto` (signed + photo, kind:1 content lacks `/messages/:id/photo`, oldest-first, any publish state, `sats === 0`), `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); `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. - **Used by:** `createApp` default `messageStore`. @@ -436,9 +436,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; `POST /:id/invoice` issues a NIP-57 zap BOLT11 (invoice limiter after payable/KEK checks; post limiter on create). 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`, `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` issues a NIP-57 zap BOLT11 (invoice limiter after payable/KEK checks; post limiter on create). 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`. -- **Returns / side effects:** Hono app mounted at `/messages`. 401 without session; 400 on bad body / missing name / invalid text / bad photo / unpaid note; 404 photo missing; 429 on post or invoice rate limits (invoice only after payable checks); 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 / unpaid note; 404 photo missing; 429 on post or invoice rate limits (invoice only after payable checks); 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). - **Used by:** `createApp`. ## Function: contactRoutes @@ -742,18 +742,25 @@ - **Returns / side effects:** `[["t","bitcoin"],["t","21gifts"],["r","https://21.gifts"]]`. - **Used by:** `buildKind1Event`. +## Function: forumPhotoUrl + +- **Purpose:** Absolute `GET /messages/:id/photo` URL for kind:1 content and `imeta`. +- **Inputs:** API origin, message id. +- **Returns / side effects:** URL string. +- **Used by:** Worker sign path. + ## Function: buildKind1Event -- **Purpose:** Unsigned top-level kind:1 for a forum line. -- **Inputs:** content, unix created_at. +- **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. +- **Inputs:** content, unix created_at, optional `{ url, mime }`. - **Returns / side effects:** Unsigned fields. - **Used by:** Worker sign path. ## Function: buildKind0Content -- **Purpose:** Kind:0 JSON without extra whitespace. +- **Purpose:** Kind:0 JSON without extra whitespace (`name`, `display_name`, `website`, `picture`, optional `lud16`). - **Inputs:** name, lightningAddress or null. -- **Returns / side effects:** JSON string; `lud16` only when address set. +- **Returns / side effects:** JSON string; `picture` is always the 21.gifts icon; `lud16` only when address set. - **Used by:** `buildKind0Event`, worker `publishProfiles`. ## Function: buildKind0Event @@ -768,7 +775,7 @@ - **Purpose:** Unsigned NIP-65 relay list. - **Inputs:** relay URLs, unix created_at. - **Returns / side effects:** Unsigned fields. -- **Used by:** Profile publish. +- **Used by:** Worker `publishRelayLists`. ## Function: signEventForAccount @@ -810,7 +817,21 @@ - **Purpose:** Combine flags + URLs for one worker tick. - **Inputs:** env slice. - **Returns / side effects:** `{ spaceUrl, publicUrls, publishEnabled, publicEnabled }`. -- **Used by:** Worker publish (`runNostrWorkerTick` / `publishProfiles` / `publishBatch`). +- **Used by:** Worker publish (`runNostrWorkerTick` / `publishProfiles` / `publishRelayLists` / `publishBatch`). + +## Function: writeRelayUrls + +- **Purpose:** Space URL plus public URLs when public write is on. +- **Inputs:** resolved write set. +- **Returns / side effects:** URL list for EVENT fan-out. +- **Used by:** Worker publish. + +## Function: resolvePublicApiBase + +- **Purpose:** HTTP origin for kind:1 photo URLs. Maps `https://21.gifts` → `https://api.21.gifts` and `https://dev.21.gifts` → `https://dev-api.21.gifts`; otherwise the trimmed `PUBLIC_BASE_URL`. +- **Inputs:** env slice. +- **Returns / side effects:** Origin without trailing slash, or empty. +- **Used by:** Worker sign path. ## Function: resolveZapRelays @@ -891,11 +912,11 @@ ## 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. When publishing, also fans out kind:0 profiles with the account `name` from the database to the space relay, and to the public list when `NOSTR_PUBLISH_PUBLIC=1`. 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. Signed photo posts missing the public photo URL are reset and re-signed only when `PUBLIC_BASE_URL` is set and `sats` is 0 (zapped rows keep `eventId` so receipts still resolve); an empty API base leaves them alone and still EVENT's the URL-less note. A publish snapshot whose photo note still lacks that URL is reset and skipped only when the API base is set and `sats` is 0. 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. - **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. -- **Returns / side effects:** Store updates; logs `nostr.sign.failed` / `nostr.publish.*` / `nostr.profile.ok` / `nostr.profile.nack`. Event-id collision retries once with `created_at + 1`. +- **Returns / side effects:** Store updates; logs `nostr.sign.failed` / `nostr.publish.*` / `nostr.profile.ok` / `nostr.profile.nack` / `nostr.relays.ok` / `nostr.relays.nack`. Event-id collision retries once with `created_at + 1`. - **Used by:** `startNostrWorker`. ## Function: startNostrWorker diff --git a/e2e/functions.spec.ts b/e2e/functions.spec.ts index 4ed5517f..045c6661 100644 --- a/e2e/functions.spec.ts +++ b/e2e/functions.spec.ts @@ -347,11 +347,11 @@ test('Function: detectImageContentType — POST /messages with a photo without b expect(res.status()).toBe(401); }); -test('Function: messagesRoutes — GET /messages/:id/photo without bearer is 401', async ({ +test('Function: messagesRoutes — GET /messages/:id/photo without bearer is 404', async ({ request, }) => { const res = await request.get('/messages/:id/photo'); - expect(res.status()).toBe(401); + expect(res.status()).toBe(404); }); test('Function: serializeMessage — GET /messages without bearer is 401', async ({ request }) => { @@ -745,6 +745,9 @@ test('Function: kind1Tags — default boot has no DATABASE_URL', async ({ reques test('Function: buildKind1Event — default boot has no DATABASE_URL', async ({ request }) => { expect((await request.get('/healthz')).status()).toBe(200); }); +test('Function: forumPhotoUrl — default boot has no DATABASE_URL', async ({ request }) => { + expect((await request.get('/healthz')).status()).toBe(200); +}); test('Function: buildKind0Content — default boot has no DATABASE_URL', async ({ request }) => { expect((await request.get('/healthz')).status()).toBe(200); }); @@ -774,6 +777,12 @@ test('Function: resolveRelayPublic — default boot has no DATABASE_URL', async test('Function: resolveWriteSet — default boot has no DATABASE_URL', async ({ request }) => { expect((await request.get('/healthz')).status()).toBe(200); }); +test('Function: writeRelayUrls — default boot has no DATABASE_URL', async ({ request }) => { + expect((await request.get('/healthz')).status()).toBe(200); +}); +test('Function: resolvePublicApiBase — default boot has no DATABASE_URL', async ({ request }) => { + expect((await request.get('/healthz')).status()).toBe(200); +}); test('Function: resolveZapRelays — default boot has no DATABASE_URL', async ({ request }) => { expect((await request.get('/healthz')).status()).toBe(200); }); diff --git a/e2e/http.spec.ts b/e2e/http.spec.ts index a100ffd0..1df188ff 100644 --- a/e2e/http.spec.ts +++ b/e2e/http.spec.ts @@ -116,14 +116,14 @@ test('POST /messages with a photo without bearer is 401', async ({ request }) => expect(await res.json()).toEqual({ error: 'Unauthorized' }); }); -test('GET /messages/:id/photo without bearer is 401', async ({ request }) => { +test('GET /messages/:id/photo without bearer is 404', async ({ request }) => { const res = await request.get('/messages/:id/photo'); - expect(res.status()).toBe(401); + expect(res.status()).toBe(404); }); -test('GET /messages/:id/photo UUID path without bearer is 401', async ({ request }) => { +test('GET /messages/:id/photo UUID path without bearer is 404', async ({ request }) => { const res = await request.get('/messages/00000000-0000-0000-0000-000000000000/photo'); - expect(res.status()).toBe(401); + expect(res.status()).toBe(404); }); test('POST /me/name without bearer is 401', async ({ request }) => { diff --git a/src/__tests__/lib/message-store.test.ts b/src/__tests__/lib/message-store.test.ts index 06772695..d8f03f61 100644 --- a/src/__tests__/lib/message-store.test.ts +++ b/src/__tests__/lib/message-store.test.ts @@ -230,6 +230,87 @@ describe('InMemoryMessageStore', () => { expect((await store.getById('a'))?.eventId).toBe('cd'.repeat(32)); }); + it('listSignedMissingPhoto and resetSignedEvent re-queue photo posts', async () => { + const store = new InMemoryMessageStore(); + const jpeg: ForumPhoto = { + contentType: 'image/jpeg', + bytes: new Uint8Array([0xff, 0xd8, 0xff, 0xd9]), + }; + await store.create({ ...EARLY, text: '', hasPhoto: true }, jpeg); + 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'), + hasPhoto: true, + eventId: '11'.repeat(32), + nostrEvent: null, + }, + jpeg, + ); + await store.create( + { + ...EARLY, + id: 'z', + createdAt: new Date('2026-09-01T00:00:00.000Z'), + hasPhoto: true, + eventId: '22'.repeat(32), + nostrEvent: { content: 1 }, + }, + jpeg, + ); + const tiedAt = new Date('2026-08-15T00:00:00.000Z'); + await store.create( + { + ...EARLY, + id: 'q', + createdAt: tiedAt, + hasPhoto: true, + eventId: '33'.repeat(32), + nostrEvent: { content: '' }, + }, + jpeg, + ); + await store.create( + { + ...EARLY, + id: 'p', + createdAt: tiedAt, + hasPhoto: true, + eventId: '44'.repeat(32), + nostrEvent: { content: '' }, + }, + jpeg, + ); + expect((await store.listSignedMissingPhoto(10)).map((row) => row.id)).toEqual([ + 'n', + 'a', + 'p', + 'q', + 'z', + ]); + await store.addSats('z', 21); + expect((await store.listSignedMissingPhoto(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/photo', + }); + expect((await store.listSignedMissingPhoto(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('listPendingSigned skips pending rows that already have t=bitcoin', async () => { const store = new InMemoryMessageStore(); await store.create(LATE); @@ -598,6 +679,34 @@ describe('PostgresMessageStore', () => { expect(sql.executes.at(-1)?.text).toMatch(/nostr_publish_state = 'pending'/); }); + it('listSignedMissingPhoto and resetSignedEvent hit Postgres', async () => { + const sql = new MockSql(); + sql.nextRows = [ + { + id: 'm1', + account_id: 'acc', + name: 'Ada', + text: '', + created_at: new Date(0), + has_photo: true, + event_id: 'ab'.repeat(32), + nostr_publish_state: 'published', + sats: 0, + }, + ]; + const store = new PostgresMessageStore(sql); + const missing = await store.listSignedMissingPhoto(4); + expect(missing[0]?.id).toBe('m1'); + const listSql = sql.queries.at(-1)?.text ?? ''; + expect(listSql).toMatch(/photo IS NOT NULL/); + expect(listSql).toMatch(/sats = 0/); + expect(listSql).toMatch(/\/messages\/' \|\| id::text \|\| '\/photo/); + await store.resetSignedEvent('m1', 'ab'.repeat(32)); + expect(sql.executes.at(-1)?.text).toMatch(/nostr_publish_state = 'pending'/); + expect(sql.executes.at(-1)?.text).toMatch(/event_id IS NOT DISTINCT FROM/); + expect(sql.executes.at(-1)?.text).toMatch(/sats = 0/); + }); + it('propagates getPhoto query errors', async () => { const sql = new MockSql(); sql.queryError = new Error('photo boom'); diff --git a/src/__tests__/lib/nostr/event.test.ts b/src/__tests__/lib/nostr/event.test.ts index e2ce91e8..8275e33f 100644 --- a/src/__tests__/lib/nostr/event.test.ts +++ b/src/__tests__/lib/nostr/event.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from 'vitest'; import { + KIND0_PICTURE_URL, buildKind0Content, buildKind0Event, buildKind1Event, buildKind10002Event, + forumPhotoUrl, kind1Tags, } from '@/lib/nostr/event'; @@ -19,6 +21,27 @@ describe('kind1', () => { ]); expect(kind1Tags()).not.toBe(event.tags); }); + + it('appends the photo URL and imeta when a photo is set', () => { + const event = buildKind1Event('hello', 1, { + url: 'http://127.0.0.1:3000/messages/m1/photo', + mime: 'image/jpeg', + }); + expect(event.content).toBe('hello\nhttp://127.0.0.1:3000/messages/m1/photo'); + expect(event.tags.at(-1)).toEqual([ + 'imeta', + 'url http://127.0.0.1:3000/messages/m1/photo', + 'm image/jpeg', + ]); + }); + + it('uses the photo URL as content when text is empty', () => { + const event = buildKind1Event('', 1, { + url: 'http://127.0.0.1:3000/messages/m1/photo', + mime: 'image/png', + }); + expect(event.content).toBe('http://127.0.0.1:3000/messages/m1/photo'); + }); }); describe('kind0', () => { @@ -27,7 +50,11 @@ describe('kind0', () => { name: 'Ada', display_name: 'Ada', website: 'https://21.gifts', + picture: KIND0_PICTURE_URL, }); + expect(forumPhotoUrl('https://api.21.gifts/', 'm1')).toBe( + 'https://api.21.gifts/messages/m1/photo', + ); expect(buildKind0Event('Ada', null, 1).tags).toEqual([]); }); diff --git a/src/__tests__/lib/nostr/relays.test.ts b/src/__tests__/lib/nostr/relays.test.ts index 222cf8d9..e2354c7e 100644 --- a/src/__tests__/lib/nostr/relays.test.ts +++ b/src/__tests__/lib/nostr/relays.test.ts @@ -7,7 +7,9 @@ import { resolveRelayPublic, resolveRelaySpace, resolveWriteSet, + resolvePublicApiBase, resolveZapRelays, + writeRelayUrls, } from '@/lib/nostr/relays'; describe('relays', () => { @@ -84,4 +86,36 @@ describe('relays', () => { }), ).toEqual(['wss://space', 'wss://a']); }); + + it('maps site PUBLIC_BASE_URL to the API origin', () => { + expect(resolvePublicApiBase({})).toBe(''); + expect(resolvePublicApiBase({ PUBLIC_BASE_URL: 'https://21.gifts/' })).toBe( + 'https://api.21.gifts', + ); + expect(resolvePublicApiBase({ PUBLIC_BASE_URL: 'https://dev.21.gifts' })).toBe( + 'https://dev-api.21.gifts', + ); + expect(resolvePublicApiBase({ PUBLIC_BASE_URL: 'http://127.0.0.1:3000' })).toBe( + 'http://127.0.0.1:3000', + ); + }); + + it('lists write URLs from the write set', () => { + expect( + writeRelayUrls({ + spaceUrl: 'wss://space', + publicUrls: ['wss://a'], + publishEnabled: true, + publicEnabled: false, + }), + ).toEqual(['wss://space']); + expect( + writeRelayUrls({ + spaceUrl: 'wss://space', + publicUrls: ['wss://a'], + publishEnabled: true, + publicEnabled: true, + }), + ).toEqual(['wss://space', 'wss://a']); + }); }); diff --git a/src/__tests__/lib/nostr/worker.test.ts b/src/__tests__/lib/nostr/worker.test.ts index b8f798e6..45914592 100644 --- a/src/__tests__/lib/nostr/worker.test.ts +++ b/src/__tests__/lib/nostr/worker.test.ts @@ -314,10 +314,444 @@ describe('runNostrWorkerTick', () => { name: 'Ada', display_name: 'Ada', website: 'https://21.gifts', + picture: 'https://21.gifts/apple-touch-icon.png', }); + expect(kinds).toContain(10002); expect(kinds).toContain(1); }); + it('publishes kind:10002 with the write-set relays', async () => { + const { auth, messages } = await seed(); + const publisher = new RecordingPublisher(); + const env = { + NOSTR_PUBLISH: '1', + NOSTR_PUBLISH_PUBLIC: '1', + NOSTR_RELAY_SPACE: 'wss://relay.nostr.space', + NOSTR_RELAY_PUBLIC: 'wss://relay.damus.io', + }; + await runNostrWorkerTick( + deps({ + messages, + auth, + kek: KEK, + publisher, + now: () => 1_700_000_000_000, + env, + }), + ); + const relays = publisher.calls.find((call) => call.event['kind'] === 10002); + expect(relays?.event['tags']).toEqual([ + ['r', 'wss://relay.nostr.space'], + ['r', 'wss://relay.damus.io'], + ]); + expect(relays?.urls).toEqual(['wss://relay.nostr.space', 'wss://relay.damus.io']); + }); + + it('republishes kind:10002 when the write-set grows', async () => { + const { auth, messages } = await seed(); + const publisher = new RecordingPublisher(); + const space = { NOSTR_PUBLISH: '1', NOSTR_RELAY_SPACE: 'wss://relay.nostr.space' }; + const both = { + ...space, + NOSTR_PUBLISH_PUBLIC: '1', + NOSTR_RELAY_PUBLIC: 'wss://relay.damus.io', + }; + await runNostrWorkerTick( + deps({ + messages, + auth, + kek: KEK, + publisher, + now: () => 1_700_000_000_000, + env: space, + }), + ); + await runNostrWorkerTick( + deps({ + messages, + auth, + kek: KEK, + publisher, + now: () => 1_700_000_060_000, + env: both, + }), + ); + const lists = publisher.calls.filter((call) => call.event['kind'] === 10002); + expect(lists).toHaveLength(2); + expect(lists[0]?.event['tags']).toEqual([['r', 'wss://relay.nostr.space']]); + expect(lists[1]?.event['tags']).toEqual([ + ['r', 'wss://relay.nostr.space'], + ['r', 'wss://relay.damus.io'], + ]); + expect(Number(lists[1]?.event['created_at'])).toBeGreaterThan( + Number(lists[0]?.event['created_at']), + ); + }); + + it('embeds a public photo URL and imeta on kind:1', async () => { + const { auth, messages } = await seed(); + await messages.create( + { + id: 'm-pic', + accountId: 'acc', + name: 'Ada', + text: 'pic', + createdAt: new Date('2026-08-28T00:02:00.000Z'), + hasPhoto: true, + ...unsignedNostrDefaults(), + }, + { contentType: 'image/jpeg', bytes: new Uint8Array([0xff, 0xd8, 0xff, 0xd9]) }, + ); + 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 notes = publisher.calls + .filter((call) => call.event['kind'] === 1) + .map((call) => String(call.event['content'])); + expect(notes).toContain('pic\nhttps://dev-api.21.gifts/messages/m-pic/photo'); + const note = publisher.calls.find( + (call) => call.event['kind'] === 1 && String(call.event['content']).includes('m-pic/photo'), + ); + expect(note?.event['tags']).toEqual([ + ['t', 'bitcoin'], + ['t', '21gifts'], + ['r', 'https://21.gifts'], + ['imeta', 'url https://dev-api.21.gifts/messages/m-pic/photo', 'm image/jpeg'], + ]); + }); + + it('re-signs published photo posts that lack the photo URL', async () => { + const { auth, messages } = await seed(); + await messages.create( + { + id: 'm-photo', + accountId: 'acc', + name: 'Ada', + text: '', + createdAt: new Date('2026-08-28T00:03:00.000Z'), + hasPhoto: true, + ...unsignedNostrDefaults(), + }, + { contentType: 'image/png', bytes: new Uint8Array([0x89, 0x50, 0x4e, 0x47]) }, + ); + await messages.updateSignedEvent('m-photo', 'ab'.repeat(32), { + kind: 1, + content: '', + tags: [ + ['t', 'bitcoin'], + ['t', '21gifts'], + ['r', 'https://21.gifts'], + ], + }); + await messages.updatePublishState('m-photo', '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-photo'); + expect(row?.eventId).not.toBe('ab'.repeat(32)); + expect(String(row?.nostrEvent?.['content'])).toContain('/messages/m-photo/photo'); + }); + + it('does not reset published photo posts when PUBLIC_BASE_URL is unset', async () => { + const { auth, messages } = await seed(); + await messages.create( + { + id: 'm-nophoto-url', + accountId: 'acc', + name: 'Ada', + text: '', + createdAt: new Date('2026-08-28T00:04:00.000Z'), + hasPhoto: true, + ...unsignedNostrDefaults(), + }, + { contentType: 'image/png', bytes: new Uint8Array([0x89, 0x50, 0x4e, 0x47]) }, + ); + await messages.updateSignedEvent('m-nophoto-url', 'ab'.repeat(32), { + kind: 1, + content: '', + tags: [ + ['t', 'bitcoin'], + ['t', '21gifts'], + ['r', 'https://21.gifts'], + ], + }); + await messages.updatePublishState('m-nophoto-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-nophoto-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( + { + id: 'm-zapped-photo', + accountId: 'acc', + name: 'Ada', + text: '', + createdAt: new Date('2026-08-28T00:08:00.000Z'), + hasPhoto: true, + ...unsignedNostrDefaults(), + }, + { contentType: 'image/png', bytes: new Uint8Array([0x89, 0x50, 0x4e, 0x47]) }, + ); + await messages.updateSignedEvent('m-zapped-photo', 'ab'.repeat(32), { + kind: 1, + content: '', + tags: [ + ['t', 'bitcoin'], + ['t', '21gifts'], + ['r', 'https://21.gifts'], + ], + }); + await messages.updatePublishState('m-zapped-photo', 'published', 'space'); + await messages.addSats('m-zapped-photo', 21); + 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', + PUBLIC_BASE_URL: 'http://127.0.0.1:3000', + }, + }), + ); + const row = await messages.getById('m-zapped-photo'); + expect(row?.eventId).toBe('ab'.repeat(32)); + expect(row?.nostrPublishState).toBe('published'); + expect(row?.sats).toBe(21); + }); + + it('does not publish a claimed photo snapshot that lacks the photo URL', async () => { + const { auth, messages } = await seed(); + const jpeg: { contentType: 'image/jpeg'; bytes: Uint8Array } = { + contentType: 'image/jpeg', + bytes: new Uint8Array([0xff, 0xd8, 0xff, 0xd9]), + }; + await messages.create( + { + id: 'm-stale', + accountId: 'acc', + name: 'Ada', + text: '', + createdAt: new Date('2026-08-28T00:05:00.000Z'), + hasPhoto: true, + ...unsignedNostrDefaults(), + }, + jpeg, + ); + await messages.updateSignedEvent('m-stale', 'ab'.repeat(32), { + kind: 1, + content: '', + tags: [ + ['t', 'bitcoin'], + ['t', '21gifts'], + ['r', 'https://21.gifts'], + ], + }); + await messages.create( + { + id: 'm-bad-content', + accountId: 'acc', + name: 'Ada', + text: '', + createdAt: new Date('2026-08-28T00:06:00.000Z'), + hasPhoto: true, + ...unsignedNostrDefaults(), + }, + jpeg, + ); + await messages.updateSignedEvent('m-bad-content', 'cd'.repeat(32), { + kind: 1, + content: 1, + tags: [ + ['t', 'bitcoin'], + ['t', '21gifts'], + ['r', 'https://21.gifts'], + ], + }); + messages.listSignedMissingPhoto = async () => []; + const publisher = new RecordingPublisher(); + await runNostrWorkerTick( + deps({ + messages, + auth, + kek: KEK, + publisher, + now: () => 1_700_000_000_000, + env: { + NOSTR_PUBLISH: '1', + NOSTR_RELAY_SPACE: 'wss://relay.nostr.space', + PUBLIC_BASE_URL: 'http://127.0.0.1:3000', + }, + }), + ); + const kind1 = publisher.calls.filter((call) => call.event['kind'] === 1); + expect(kind1.map((call) => call.event['id'])).not.toContain('ab'.repeat(32)); + expect(kind1.map((call) => call.event['id'])).not.toContain('cd'.repeat(32)); + expect((await messages.getById('m-stale'))?.eventId).toBeNull(); + expect((await messages.getById('m-bad-content'))?.eventId).toBeNull(); + expect((await messages.getById('m-stale'))?.nostrPublishState).toBe('pending'); + }); + + it('publishes a zapped URL-less photo snapshot instead of resetting it', async () => { + const { auth, messages } = await seed(); + await messages.create( + { + id: 'm-zap-pending', + accountId: 'acc', + name: 'Ada', + text: '', + createdAt: new Date('2026-08-28T00:09:00.000Z'), + hasPhoto: true, + ...unsignedNostrDefaults(), + }, + { contentType: 'image/jpeg', bytes: new Uint8Array([0xff, 0xd8, 0xff, 0xd9]) }, + ); + await messages.updateSignedEvent('m-zap-pending', 'ab'.repeat(32), { + kind: 1, + id: 'ab'.repeat(32), + content: '', + tags: [ + ['t', 'bitcoin'], + ['t', '21gifts'], + ['r', 'https://21.gifts'], + ], + }); + await messages.addSats('m-zap-pending', 7); + messages.listSignedMissingPhoto = async () => []; + const publisher = new RecordingPublisher(); + await runNostrWorkerTick( + deps({ + messages, + auth, + kek: KEK, + publisher, + now: () => 1_700_000_000_000, + env: { + NOSTR_PUBLISH: '1', + NOSTR_RELAY_SPACE: 'wss://relay.nostr.space', + PUBLIC_BASE_URL: 'http://127.0.0.1:3000', + }, + }), + ); + expect((await messages.getById('m-zap-pending'))?.eventId).toBe('ab'.repeat(32)); + expect( + publisher.calls.some( + (call) => call.event['kind'] === 1 && call.event['id'] === 'ab'.repeat(32), + ), + ).toBe(true); + }); + + it('publishes URL-less photo notes when PUBLIC_BASE_URL is unset', async () => { + const { auth, messages } = await seed(); + await messages.create( + { + id: 'm-plain-photo', + accountId: 'acc', + name: 'Ada', + text: '', + createdAt: new Date('2026-08-28T00:07:00.000Z'), + hasPhoto: true, + ...unsignedNostrDefaults(), + }, + { contentType: 'image/jpeg', bytes: new Uint8Array([0xff, 0xd8, 0xff, 0xd9]) }, + ); + const publisher = new RecordingPublisher(); + const env = { NOSTR_PUBLISH: '1', NOSTR_RELAY_SPACE: 'wss://relay.nostr.space' }; + 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 row = await messages.getById('m-plain-photo'); + expect(row?.eventId).toMatch(/^[0-9a-f]{64}$/); + expect(row?.nostrEvent?.['content']).toBe(''); + expect(row?.nostrPublishState).toBe('published'); + expect( + publisher.calls.some( + (call) => + call.event['kind'] === 1 && + call.event['content'] === '' && + call.event['id'] === row?.eventId, + ), + ).toBe(true); + }); + it('includes lud16 on kind:0 when the account has a Lightning Address', async () => { const { auth, messages } = await seed(); const acc = await auth.getAccount('acc'); @@ -346,7 +780,12 @@ describe('runNostrWorkerTick', () => { }), ); const profile = publisher.calls.find((call) => call.event['kind'] === 0); - expect(JSON.parse(String(profile?.event['content'])).lud16).toBe('ada@walletofsatoshi.com'); + const profileJson = JSON.parse(String(profile?.event['content'])) as { + lud16: string; + picture: string; + }; + expect(profileJson.lud16).toBe('ada@walletofsatoshi.com'); + expect(profileJson.picture).toBe('https://21.gifts/apple-touch-icon.png'); }); it('publishes a name that changed after listAccounts', async () => { diff --git a/src/__tests__/routes/messages.test.ts b/src/__tests__/routes/messages.test.ts index 7dc65af6..63e36c09 100644 --- a/src/__tests__/routes/messages.test.ts +++ b/src/__tests__/routes/messages.test.ts @@ -89,7 +89,9 @@ function throwingStore(overrides: Partial = {}): MessageStore { claimUnsigned: boom, claimUnpublished: boom, listPendingSigned: boom, + listSignedMissingPhoto: boom, clearSignedEvent: boom, + resetSignedEvent: boom, updateSignedEvent: boom, updatePublishState: boom, addSats: boom, @@ -479,7 +481,7 @@ describe('POST /messages', () => { const photo = await app.request(`/messages/${created.id}/photo`, { headers: AUTH }); expect(photo.status).toBe(200); expect(photo.headers.get('content-type')).toBe('image/jpeg'); - expect(photo.headers.get('cache-control')).toBe('private'); + expect(photo.headers.get('cache-control')).toBe('public, max-age=86400'); expect(new Uint8Array(await photo.arrayBuffer())).toEqual(JPEG_BYTES); }); @@ -1050,18 +1052,40 @@ describe('POST /messages/:id/invoice', () => { }); describe('GET /messages/:id/photo', () => { - it('returns 401 without an Authorization header', async () => { + it('returns 404 without an Authorization header when no photo exists', async () => { const res = await mount(new InMemoryAuthStore()).request( '/messages/00000000-0000-0000-0000-000000000000/photo', ); - expect(res.status).toBe(401); - expect(await res.json()).toEqual({ error: 'Unauthorized' }); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'Photo not found' }); + }); + + it('returns bytes without a bearer when the photo exists', async () => { + const store = new InMemoryMessageStore(); + await store.create( + { + id: '00000000-0000-4000-8000-000000000001', + accountId: 'acc', + name: 'Ada', + text: '', + createdAt: new Date(now()), + hasPhoto: true, + ...unsignedNostrDefaults(), + }, + { contentType: 'image/jpeg', bytes: JPEG_BYTES }, + ); + const res = await mount(await seededStore(), store).request( + '/messages/00000000-0000-4000-8000-000000000001/photo', + ); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe('image/jpeg'); + expect(res.headers.get('Cache-Control')).toBe('public, max-age=86400'); + expect(new Uint8Array(await res.arrayBuffer())).toEqual(JPEG_BYTES); }); it('returns 404 when the photo is missing', async () => { const res = await mount(await seededStore()).request( '/messages/00000000-0000-0000-0000-000000000000/photo', - { headers: AUTH }, ); expect(res.status).toBe(404); expect(await res.json()).toEqual({ error: 'Photo not found' }); @@ -1073,7 +1097,6 @@ describe('GET /messages/:id/photo', () => { }); const res = await mount(await seededStore(), throwingStore({ getPhoto })).request( '/messages/not-a-uuid/photo', - { headers: AUTH }, ); expect(res.status).toBe(404); expect(await res.json()).toEqual({ error: 'Photo not found' }); @@ -1088,9 +1111,7 @@ describe('GET /messages/:id/photo', () => { listLatest: async () => [], create: async (row) => row, }), - ).request('/messages/00000000-0000-0000-0000-000000000000/photo', { - headers: AUTH, - }); + ).request('/messages/00000000-0000-0000-0000-000000000000/photo'); expect(res.status).toBe(503); expect(await res.json()).toEqual({ error: 'Messages are unavailable' }); expect(parsedEvents(warn).some((e) => e['event'] === 'messages.photo.failed')).toBe(true); diff --git a/src/lib/message-store.ts b/src/lib/message-store.ts index 8a5e7e1c..395763df 100644 --- a/src/lib/message-store.ts +++ b/src/lib/message-store.ts @@ -16,6 +16,14 @@ import { } from '@/lib/message'; import { normalizeSignedEvent } from '@/lib/nostr/publish'; +function kind1MissingPhotoUrl(event: Record | null, messageId: string): boolean { + if (event === null) { + return true; + } + const content = event['content']; + return typeof content !== 'string' || !content.includes(`/messages/${messageId}/photo`); +} + function pendingKind1LacksBitcoinTag(event: Record | null): boolean { if (event === null) { return true; @@ -94,6 +102,24 @@ export interface MessageStore { */ clearSignedEvent(id: string, expectedEventId: string | null): Promise; + /** + * Signed rows with a photo whose kind:1 content lacks the public photo URL. + * Any publish state, `sats = 0` only (zapped rows keep their event id). + * Oldest `createdAt` then `id` first. + * + * @param limit - Max rows. + */ + listSignedMissingPhoto(limit: number): Promise; + + /** + * Clear the signed event and park the row `pending` so it is signed again. + * No-op unless `eventId` still matches `expectedEventId` and `sats` is 0. + * + * @param id - Message id. + * @param expectedEventId - Event id observed when the row was listed. + */ + resetSignedEvent(id: string, expectedEventId: string | null): Promise; + /** Persist a signed event id + JSON. Returns false on event-id collision. */ updateSignedEvent( id: string, @@ -306,6 +332,36 @@ export class InMemoryMessageStore implements MessageStore { return Promise.resolve(); } + listSignedMissingPhoto(limit: number): Promise { + const rows = this.#rows + .filter( + (row) => + row.eventId !== null && + row.hasPhoto && + row.sats === 0 && + kind1MissingPhotoUrl(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); + } + + resetSignedEvent(id: string, expectedEventId: string | null): Promise { + const row = this.#rows.find((item) => item.id === id); + if (row !== undefined && row.eventId === expectedEventId && row.sats === 0) { + row.eventId = null; + row.nostrEvent = null; + row.claimedUntil = null; + row.nostrPublishState = 'pending'; + row.nostrPublishEpoch = null; + } + return Promise.resolve(); + } + updateSignedEvent( id: string, eventId: string, @@ -598,6 +654,31 @@ export class PostgresMessageStore implements MessageStore { ); } + async listSignedMissingPhoto(limit: number): Promise { + const rows = await this.#sql.query( + `SELECT ${MESSAGE_SELECT_COLUMNS} + FROM message + WHERE event_id IS NOT NULL AND photo IS NOT NULL AND sats = 0 + AND ( + nostr_event IS NULL + OR COALESCE(nostr_event->>'content', '') NOT LIKE '%/messages/' || id::text || '/photo%' + ) + ORDER BY created_at ASC, id ASC + LIMIT $1`, + [limit], + ); + return rows.map((row) => mapMessageRow(row)); + } + + async resetSignedEvent(id: string, expectedEventId: string | null): Promise { + await this.#sql.execute( + `UPDATE message SET event_id = NULL, nostr_event = NULL, claimed_until = NULL, + nostr_publish_state = 'pending', nostr_publish_epoch = NULL + WHERE id = $1 AND event_id IS NOT DISTINCT FROM $2 AND sats = 0`, + [id, expectedEventId], + ); + } + async updateSignedEvent( id: string, eventId: string, diff --git a/src/lib/nostr/event.ts b/src/lib/nostr/event.ts index 7a399508..cd83f142 100644 --- a/src/lib/nostr/event.ts +++ b/src/lib/nostr/event.ts @@ -5,7 +5,7 @@ * top-level notes for discovery-feed virality. */ -/** Frozen kind:1 tags, in this order. */ +/** Frozen kind:1 tags, in this order. Extra `imeta` rows may follow. */ export const KIND1_TAGS: readonly [ readonly ['t', 'bitcoin'], readonly ['t', '21gifts'], @@ -16,6 +16,28 @@ export const KIND1_TAGS: readonly [ ['r', 'https://21.gifts'], ] as const; +/** 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. */ +export interface Kind1Photo { + /** Absolute HTTPS URL clients fetch. */ + url: string; + /** Stored MIME type. */ + mime: 'image/jpeg' | 'image/png' | 'image/webp'; +} + +/** + * Absolute photo URL for a forum message. + * + * @param apiBase - Public API origin (no trailing slash). + * @param messageId - Message id. + * @returns `GET /messages/:id/photo` URL. + */ +export function forumPhotoUrl(apiBase: string, messageId: string): string { + return `${apiBase.replace(/\/$/, '')}/messages/${messageId}/photo`; +} + /** Mutable tag arrays for `finalizeEvent` (copy of {@link KIND1_TAGS}). */ export function kind1Tags(): string[][] { return KIND1_TAGS.map((tag) => [...tag]); @@ -38,15 +60,26 @@ export interface UnsignedKind1 { * * Content is plaintext (no name prefix). Tags are frozen — no `e`/`p`/`q`. * - * @param content - Already-normalised forum text. + * @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`). * @returns Unsigned event fields for `finalizeEvent`. */ -export function buildKind1Event(content: string, createdAtUnix: number): UnsignedKind1 { +export function buildKind1Event( + content: string, + createdAtUnix: number, + photo?: Kind1Photo, +): UnsignedKind1 { + const tags = kind1Tags(); + let body = content; + if (photo !== undefined) { + body = content === '' ? photo.url : `${content}\n${photo.url}`; + tags.push(['imeta', `url ${photo.url}`, `m ${photo.mime}`]); + } return { kind: 1, - content, - tags: kind1Tags(), + content: body, + tags, created_at: createdAtUnix, }; } @@ -59,6 +92,8 @@ export interface Kind0ProfileContent { display_name: string; /** Fixed site URL. */ website: string; + /** 21.gifts icon so Damus shows a branded avatar. */ + picture: string; /** LUD-16 when the account has a linked address. */ lud16?: string; } @@ -66,8 +101,8 @@ export interface Kind0ProfileContent { /** * Build kind:0 `content` JSON (no extra whitespace). * - * Omit `lud16` when the account has no Lightning Address. Do not set `nip05` - * or `bot` in v1. + * 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. * * @param name - Non-null display name. * @param lightningAddress - Linked LUD-16, or `null`. @@ -78,6 +113,7 @@ export function buildKind0Content(name: string, lightningAddress: string | null) name, display_name: name, website: 'https://21.gifts', + picture: KIND0_PICTURE_URL, }; if (lightningAddress !== null) { body.lud16 = lightningAddress; diff --git a/src/lib/nostr/relays.ts b/src/lib/nostr/relays.ts index 2b284813..100a6f18 100644 --- a/src/lib/nostr/relays.ts +++ b/src/lib/nostr/relays.ts @@ -122,3 +122,33 @@ export function resolveZapRelays(env: Record): strin } return urls; } + +/** + * URLs the worker writes kind:0 / kind:1 / kind:10002 to this tick. + * + * @param writeSet - Resolved flags and relays. + * @returns Space, plus public URLs when public write is on. + */ +export function writeRelayUrls(writeSet: ResolvedWriteSet): string[] { + return writeSet.publicEnabled ? [writeSet.spaceUrl, ...writeSet.publicUrls] : [writeSet.spaceUrl]; +} + +/** + * Public HTTP origin for photo URLs in kind:1. + * + * Maps the site `PUBLIC_BASE_URL` to the API host. Tests that point + * `PUBLIC_BASE_URL` at the API itself keep that origin. + * + * @param env - Environment slice. + * @returns Origin without a trailing slash, or empty when unset. + */ +export function resolvePublicApiBase(env: Record): string { + const raw = (env['PUBLIC_BASE_URL'] ?? '').trim().replace(/\/$/, ''); + if (raw === 'https://21.gifts') { + return 'https://api.21.gifts'; + } + if (raw === 'https://dev.21.gifts') { + return 'https://dev-api.21.gifts'; + } + return raw; +} diff --git a/src/lib/nostr/worker.ts b/src/lib/nostr/worker.ts index eaad65f2..90a8999f 100644 --- a/src/lib/nostr/worker.ts +++ b/src/lib/nostr/worker.ts @@ -2,11 +2,23 @@ import type { AuthStore } from '@/lib/auth/store'; import type { FetchFn } from '@/lib/lnurlp'; import type { MessageStore } from '@/lib/message-store'; import { logEvent } from '@/lib/log'; -import { buildKind0Event, buildKind0Content, buildKind1Event } from '@/lib/nostr/event'; +import { + buildKind0Event, + buildKind0Content, + buildKind1Event, + buildKind10002Event, + forumPhotoUrl, +} from '@/lib/nostr/event'; import { ensureAccountNostrKey } from '@/lib/nostr/keys'; import { publicAcked, spaceAcked, type NostrPublisher } from '@/lib/nostr/publish'; import type { NostrEventFrame, NostrQuerier } from '@/lib/nostr/query'; -import { resolveWriteSet, resolveZapRelays, type ResolvedWriteSet } from '@/lib/nostr/relays'; +import { + resolvePublicApiBase, + resolveWriteSet, + resolveZapRelays, + writeRelayUrls, + type ResolvedWriteSet, +} from '@/lib/nostr/relays'; import { signEventForAccount } from '@/lib/nostr/sign'; import { indexOpenZapReceipts } from '@/lib/nostr/zap-index'; @@ -87,10 +99,14 @@ function reservedContent( * when `NOSTR_PUBLISH_PUBLIC=1`. Space ACK with public off is terminal * `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. When publishing, also - * fans out a replaceable kind:0 profile (`name` / `display_name` from the - * account row) to the space relay, and to the public list when - * `NOSTR_PUBLISH_PUBLIC=1`, so Damus/Primal show the forum name. Kind:0 + * `t=bitcoin` is dropped and re-signed before fan-out. Signed photo posts + * whose kind:1 lacks the public photo URL are reset and re-signed when + * `PUBLIC_BASE_URL` is set and `sats === 0`; zapped rows keep their event + * id so receipts still resolve. An empty API base leaves them alone 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 * `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 @@ -103,9 +119,11 @@ export async function runNostrWorkerTick(deps: NostrWorkerDeps): Promise { const writeSet = resolveWriteSet(deps.env); const nowMs = deps.now(); await resignLegacyKind1Tags(deps); + await resignPhotoKind1(deps); await signBatch(deps, nowMs); if (writeSet.publishEnabled) { await publishProfiles(deps, writeSet); + await publishRelayLists(deps, writeSet); await publishBatch(deps, writeSet, nowMs); } const urls = resolveZapRelays(deps.env); @@ -133,6 +151,16 @@ async function resignLegacyKind1Tags(deps: NostrWorkerDeps): Promise { } } +async function resignPhotoKind1(deps: NostrWorkerDeps): Promise { + if (resolvePublicApiBase(deps.env) === '') { + return; + } + const rows = await deps.messages.listSignedMissingPhoto(WORKER_BATCH); + for (const row of rows) { + await deps.messages.resetSignedEvent(row.id, row.eventId); + } +} + function kind1HasBitcoinTag(event: Record | null): boolean { if (event === null) { return false; @@ -159,8 +187,22 @@ async function signBatch(deps: NostrWorkerDeps, nowMs: number): Promise { await ensureAccountNostrKey(deps.auth, row.accountId, deps.kek); 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; + if (row.hasPhoto && apiBase !== '') { + const storedPhoto = await deps.messages.getPhoto(row.id); + if (storedPhoto !== null) { + photo = { + url: forumPhotoUrl(apiBase, row.id), + mime: storedPhoto.contentType, + }; + } + } for (let attempt = 0; attempt < 2 && !stored; attempt += 1) { - const unsigned = buildKind1Event(row.text, createdAt); + const unsigned = + photo === undefined + ? buildKind1Event(row.text, createdAt) + : buildKind1Event(row.text, createdAt, photo); const signed = await signEventForAccount(deps.auth, row.accountId, deps.kek, unsigned); stored = await deps.messages.updateSignedEvent( row.id, @@ -184,9 +226,7 @@ async function signBatch(deps: NostrWorkerDeps, nowMs: number): Promise { async function publishProfiles(deps: NostrWorkerDeps, writeSet: ResolvedWriteSet): Promise { const cache = profileCacheFor(deps.auth); const watermarks = profileWatermarkFor(deps.auth); - const urls = writeSet.publicEnabled - ? [writeSet.spaceUrl, ...writeSet.publicUrls] - : [writeSet.spaceUrl]; + const urls = writeRelayUrls(writeSet); const accounts = await deps.auth.listAccounts(); let attempted = 0; for (const account of accounts) { @@ -251,15 +291,106 @@ async function publishProfiles(deps: NostrWorkerDeps, writeSet: ResolvedWriteSet } } +const relayListCaches = new WeakMap>(); +const relayListWatermarks = new WeakMap>(); + +function relayListCacheFor(auth: AuthStore): Map { + const existing = relayListCaches.get(auth); + if (existing !== undefined) { + return existing; + } + const created = new Map(); + relayListCaches.set(auth, created); + return created; +} + +function relayListWatermarkFor(auth: AuthStore): Map { + const existing = relayListWatermarks.get(auth); + if (existing !== undefined) { + return existing; + } + const created = new Map(); + relayListWatermarks.set(auth, created); + return created; +} + +async function publishRelayLists(deps: NostrWorkerDeps, writeSet: ResolvedWriteSet): Promise { + const cache = relayListCacheFor(deps.auth); + const watermarks = relayListWatermarkFor(deps.auth); + const urls = writeRelayUrls(writeSet); + const content = urls.join('\n'); + const accounts = await deps.auth.listAccounts(); + let attempted = 0; + for (const account of accounts) { + if (attempted >= WORKER_BATCH) { + break; + } + const live = await deps.auth.getAccount(account.id); + if (live === undefined || live.name === null) { + continue; + } + if (reservedContent(cache, live.id) === content) { + continue; + } + const previous = cache.get(live.id); + const reservation: Kind0Reservation = { + content, + createdAt: Math.max(previous?.createdAt ?? 0, watermarks.get(live.id) ?? 0), + }; + cache.set(live.id, reservation); + try { + const pubkey = await deps.auth.getNostrPublicKey(live.id); + if (pubkey === undefined) { + if (cache.get(live.id) === reservation) { + cache.delete(live.id); + } + continue; + } + attempted += 1; + /* v8 ignore next 3 -- overlapping tick replaced the reservation */ + if (cache.get(live.id) !== reservation) { + continue; + } + const wall = Math.floor(deps.now() / 1000); + reservation.createdAt = Math.max(wall, reservation.createdAt + 1); + watermarks.set(live.id, reservation.createdAt); + const unsigned = buildKind10002Event(urls, reservation.createdAt); + const signed = await signEventForAccount(deps.auth, live.id, deps.kek, unsigned); + /* v8 ignore next 3 -- overlapping tick replaced the reservation */ + if (cache.get(live.id) !== reservation) { + continue; + } + const acks = await deps.publisher.publish( + signed as unknown as Record, + urls, + RELAY_TIMEOUT_MS, + ); + const spaceOk = spaceAcked(acks, writeSet.spaceUrl); + const publicOk = !writeSet.publicEnabled || publicAcked(acks, writeSet.spaceUrl); + if (!spaceOk || !publicOk) { + if (cache.get(live.id) === reservation) { + cache.delete(live.id); + } + logEvent('nostr.relays.nack', { accountId: live.id }); + continue; + } + logEvent('nostr.relays.ok', { accountId: live.id }); + } catch { + if (cache.get(live.id) === reservation) { + cache.delete(live.id); + } + logEvent('nostr.relays.nack', { accountId: live.id }); + } + } +} + async function publishBatch( deps: NostrWorkerDeps, writeSet: ResolvedWriteSet, nowMs: number, ): Promise { const rows = await deps.messages.claimUnpublished(WORKER_BATCH, nowMs, WORKER_LEASE_MS); - const urls = writeSet.publicEnabled - ? [writeSet.spaceUrl, ...writeSet.publicUrls] - : [writeSet.spaceUrl]; + const urls = writeRelayUrls(writeSet); for (const row of rows) { /* v8 ignore next 3 -- signed rows always store nostrEvent */ if (row.nostrEvent === null) { @@ -271,6 +402,16 @@ async function publishBatch( continue; } /* v8 ignore stop */ + const photoContent = row.nostrEvent['content']; + if ( + resolvePublicApiBase(deps.env) !== '' && + row.hasPhoto && + row.sats === 0 && + (typeof photoContent !== 'string' || !photoContent.includes(`/messages/${row.id}/photo`)) + ) { + await deps.messages.resetSignedEvent(row.id, row.eventId); + continue; + } try { const acks = await deps.publisher.publish(row.nostrEvent, urls, RELAY_TIMEOUT_MS); const space = spaceAcked(acks, writeSet.spaceUrl); diff --git a/src/routes/messages.ts b/src/routes/messages.ts index 97886cbd..ffeaabb4 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -25,8 +25,9 @@ import { bearerToken } from '@/routes/me'; /** * `/messages` — signed-in member forum: list every message, post text and/or - * one photo when the account has a display name, fetch photo bytes by id, and - * pay a published note. Shares the {@link AuthStore} with `/auth` and `/me`. + * one photo when the account has a display name, serve photo bytes publicly + * for Nostr clients, and pay a published note. Shares the {@link AuthStore} + * with `/auth` and `/me`. */ /** Collaborators the `/messages` routes need. */ @@ -169,10 +170,6 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { } }) .get('/:id/photo', async (c) => { - const account = await authedAccount(deps, c.req.header('authorization')); - if (account === null) { - return c.json({ error: 'Unauthorized' }, 401); - } const id = c.req.param('id'); if (!MESSAGE_ID_RE.test(id)) { return c.json({ error: 'Photo not found' }, 404); @@ -186,7 +183,7 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { status: 200, headers: { 'Content-Type': photo.contentType, - 'Cache-Control': 'private', + 'Cache-Control': 'public, max-age=86400', }, }); } catch { From 4ea6037dd59ae833a4057e5d47ef750f8584ce7a Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Sun, 30 Aug 2026 15:13:56 +0200 Subject: [PATCH 02/12] 01a051eb - Persist forum invoice attempts and zap ingest decisions (#68) * Persist forum invoice attempts and zap ingest decisions. POST /messages/:id/invoice now writes every attempt (success and failure) to message_invoice, including the BOLT11, description vs description_hash, and whether the invoice is a NIP-57 hash invoice. kind:9735 ingest records indexed and rejected outcomes in nostr_zap_ingest. Operators can list both via DEBUG_TOKEN GET /debug/invoices and GET /debug/zap-ingests. * Document invoice and zap-ingest methods on the in-memory store. Handbook bullets for migrateMessageSchema and InMemoryMessageStore now name message_invoice, nostr_zap_ingest, and the matching store methods. * Format CONTRIBUTING and debug-payments with Prettier. CI lint failed prettier --check on three files; this matches the repo style. * Cover invoice persist and zap-ingest mapper edge cases. Adds tests for null/array JSON mapping, sign_failed invoice rows, a non-object signed zap request, and ingest errors without a string pubkey so coverage meets the 100% gate. * Document debug invoice and zap-ingest routes in SPEC. Adds GET /debug/invoices and GET /debug/zap-ingests to the route table and contracts, and names the new tables in the CONTRIBUTING schema tree. * Reject non-uuid message ids on the invoice route. POST /messages/:id/invoice now uses the same uuid check as photo fetch, so Postgres is not asked to cast garbage into message_invoice.message_id. * Persist zap ingest when the receipt pubkey is not a string. Invoice tests with a non-integer sats body now use a uuid path so the new invoice id check does not turn them into 404. * Document invoice UUID check and persist in SPEC and handbook. POST /messages/:id/invoice now states the UUID guard, best-effort message_invoice rows, and the full result union on GET /debug/invoices. * Retrigger CI. GitHub did not start check-runs on the previous two commits. * Format CONTRIBUTING after rebase onto NIP-65 develop. * Forward photo-reset store methods in persist test stubs. After the NIP-65 rebase, MessageStore includes listSignedMissingPhoto and resetSignedEvent; the invoice/ingest test doubles now implement them. * Cover remaining persist branches for the 100% coverage gate. Ignores zod/cap and missing-key persist that sit behind existing v8 guards, records a null zap_request bind, and marks the catch-path pubkey ternary as unreachable after the ingest guard. * Exercise inspectBolt11 fields on a successful forum invoice. The ok persist path records paymentHash and descriptionHash when the BOLT11 inspects, covering the non-null optional-chaining branches. --------- Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- CONTRIBUTING.md | 42 +- SPEC.md | 190 ++++++-- docs/handbook/endpoints.md | 18 +- docs/handbook/functions.md | 43 +- docs/schema/message.sql | 42 ++ e2e/functions.spec.ts | 23 + scripts/check-handbook.mjs | 1 + src/__tests__/lib/bolt11.test.ts | 78 +++- src/__tests__/lib/message-store.test.ts | 338 ++++++++++++++ src/__tests__/lib/nostr/zap-index.test.ts | 186 +++++++- src/__tests__/routes/debug-payments.test.ts | 156 +++++++ src/__tests__/routes/messages.test.ts | 462 +++++++++++++++++++- src/lib/bolt11.ts | 137 +++++- src/lib/message-store.ts | 317 ++++++++++++++ src/lib/nostr/zap-index.ts | 265 ++++++++++- src/routes/debug-payments.ts | 98 +++++ src/routes/messages.ts | 286 +++++++++++- src/server.ts | 7 +- 18 files changed, 2596 insertions(+), 93 deletions(-) create mode 100644 src/__tests__/routes/debug-payments.test.ts create mode 100644 src/routes/debug-payments.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c0ef3050..44715060 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,6 +40,7 @@ api/ │ │ ├── lightning-address.ts # GET /lightning-address (public LUD-16 resolve) │ │ ├── debug.ts # GET /debug/accounts; PATCH /debug/accounts/:id (DEBUG_TOKEN) │ │ ├── debug-contacts.ts # GET /debug/contacts (operator DEBUG_TOKEN) +│ │ ├── debug-payments.ts # GET /debug/invoices; GET /debug/zap-ingests (DEBUG_TOKEN) │ │ ├── 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) @@ -60,7 +61,7 @@ api/ │ │ ├── log.ts # JSON event lines (console.warn); requestLog middleware │ │ ├── lnurl-pay.ts # LUD-16 → LNURL-pay invoice (amount + LUD-12 comment) │ │ ├── gift-invoice.ts # LUD-16 → LNURL-pay invoice for gift amounts (no 10-sat cap) -│ │ ├── bolt11.ts # Decode BOLT11 payment hash + amount +│ │ ├── bolt11.ts # Decode/inspect BOLT11 (hash, amount, description / description_hash) │ │ ├── proof.ts # sha256(preimage) === payment hash │ │ ├── spend-auth.ts # Timing-safe SPEND_API_TOKEN Bearer check │ │ ├── invoice-store.ts # In-memory gift invoices awaiting proof @@ -147,6 +148,7 @@ api/ │ ├── messages.test.ts │ ├── contact.test.ts │ ├── debug-contacts.test.ts +│ ├── debug-payments.test.ts │ └── view.test.ts ├── docs/handbook/ # Mandatory: every function + HTTP endpoint │ ├── README.md @@ -155,7 +157,7 @@ api/ ├── docs/schema/ │ ├── gift.sql # gift table used by GET /gifts and GET /gifts/stats │ ├── btc_usd_daily.sql # UTC daily BTC-USD closes for historical USD stats -│ ├── message.sql # public forum message table for GET/POST /messages, GET /messages/:id/photo +│ ├── message.sql # forum `message` plus `message_invoice` and `nostr_zap_ingest` │ ├── contact.sql # private contact mailbox table for POST /contact │ └── db_change.sql # append-only row-change log ├── scripts/ @@ -348,24 +350,24 @@ 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`, `contact`, 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`, and a matching `POST /invoices/proof` inserts into `gift`. Unset keeps `InMemoryAuthStore`, in-memory forum and contact stores, empty gift stats, empty day lists, and a no-op gift recorder. | -| `DEBUG_TOKEN` | _(unset → debug off)_ | Operator bearer for `GET /debug/accounts`, `PATCH /debug/accounts/:id`, and `GET /debug/contacts`. 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`. | +| 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`, 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 and contact stores, empty gift stats, empty day lists, and a no-op gift recorder. | +| `DEBUG_TOKEN` | _(unset → debug off)_ | Operator bearer for `GET /debug/accounts`, `PATCH /debug/accounts/:id`, `GET /debug/contacts`, `GET /debug/invoices`, and `GET /debug/zap-ingests`. 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`. | 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/SPEC.md b/SPEC.md index 069a96a9..c153bcc9 100644 --- a/SPEC.md +++ b/SPEC.md @@ -4,7 +4,7 @@ > Product decisions live in [`CONCEPT.md`](./CONCEPT.md); this file owns > request/response contracts for routes that exist in code today. -**Status**: living document. Last revised 2026-08-30 (kind:0 `picture` + NIP-65 kind:10002; kind:1 NIP-92 `imeta` photo URLs; public `GET /messages/:id/photo`; forum `account.role` `basis`\|`verified`\|`moderator`\|`founder`; live `role` on `GET/POST /messages`; `PATCH /debug/accounts/:id`; private in-app `POST /contact` + `GET /debug/contacts`; `POST /me/lightning-address` live-resolves and requires zap metadata; invoice limiter after payable checks; public forum `GET/POST /messages` with `sats`/`payable`/`hasPhoto`; worker indexes kind:9735 zap receipts onto `sats`; `POST /messages/:id/invoice` NIP-57 zap; SQL boot requires `NOSTR_NSEC_KEK`; passkey-only login; gift stats BTC + historical USD via Coinbase daily close; `GET /gifts?day=`). +**Status**: living document. Last revised 2026-08-30 (`GET /debug/invoices` and `GET /debug/zap-ingests`; kind:0 `picture` + NIP-65 kind:10002; kind:1 NIP-92 `imeta` photo URLs; public `GET /messages/:id/photo`; forum `account.role` `basis`\|`verified`\|`moderator`\|`founder`; live `role` on `GET/POST /messages`; `PATCH /debug/accounts/:id`; private in-app `POST /contact` + `GET /debug/contacts`; `POST /me/lightning-address` live-resolves and requires zap metadata; invoice limiter after payable checks; public forum `GET/POST /messages` with `sats`/`payable`/`hasPhoto`; worker indexes kind:9735 zap receipts onto `sats`; `POST /messages/:id/invoice` NIP-57 zap; SQL boot requires `NOSTR_NSEC_KEK`; passkey-only login; gift stats BTC + historical USD via Coinbase daily close; `GET /gifts?day=`). --- @@ -55,39 +55,41 @@ Public base URLs used in examples: | PRD | `https://api.21.gifts` | `https://21.gifts` | | DEV | `https://dev-api.21.gifts` | `https://dev.21.gifts` | -| Method | Path | Auth | Purpose | -| ------ | -------------------------------------------- | ------------------------ | ------------------------------------------- | -| GET | `/healthz` | none | Liveness | -| GET | `/info` | none | Service identity | -| GET | `/favicon.ico` | none | Brand mark (favicon) | -| GET | `/favicon.svg` | none | Brand mark (SVG favicon) | -| GET | `/apple-touch-icon.png` | none | Brand mark (Apple touch icon) | -| POST | `/auth/passkey/register/begin` | none | Issue WebAuthn creation options | -| 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 | `/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 | -| POST | `/me/rules-agreement` | Bearer | Record living-room rules agreement | -| POST | `/me/lightning-address` | Bearer | Link/replace after live LNURL resolve | -| DELETE | `/me/lightning-address` | Bearer | Unlink address | -| POST | `/me/lightning-address/verification` | Bearer | Start address proof-of-control payment | -| POST | `/me/lightning-address/verification/confirm` | Bearer | Confirm nonce from wallet history | -| GET | `/messages` | Bearer | List public forum thread | -| POST | `/messages` | Bearer | Post text and/or one photo to the forum | -| GET | `/messages/:id/photo` | none | Fetch forum message photo bytes | -| POST | `/messages/:id/invoice` | Bearer | NIP-57 zap / BOLT11 | -| POST | `/contact` | Bearer | Send private in-app contact `{ text }` | -| GET | `/lightning-address` | none | Resolve LUD-16 metadata (cached) | -| GET | `/debug/accounts` | `Authorization: Bearer` | Operator account listing (`DEBUG_TOKEN`) | -| PATCH | `/debug/accounts/:id` | `Authorization: Bearer` | Operator set `account.role` (`DEBUG_TOKEN`) | -| GET | `/debug/contacts` | `Authorization: Bearer` | Operator contact listing (`DEBUG_TOKEN`) | -| GET | `/gifts` | none | Outbound gifts for one UTC day (`?day=`) | -| GET | `/gifts/stats` | none | Aggregated outbound gift statistics | -| POST | `/invoices` | Bearer `SPEND_API_TOKEN` | Fetch a recipient BOLT11 (LNURL-pay) | -| POST | `/invoices/proof` | Bearer `SPEND_API_TOKEN` | Accept payment preimage as proof | +| Method | Path | Auth | Purpose | +| ------ | -------------------------------------------- | ------------------------ | ----------------------------------------------- | +| GET | `/healthz` | none | Liveness | +| GET | `/info` | none | Service identity | +| GET | `/favicon.ico` | none | Brand mark (favicon) | +| GET | `/favicon.svg` | none | Brand mark (SVG favicon) | +| GET | `/apple-touch-icon.png` | none | Brand mark (Apple touch icon) | +| POST | `/auth/passkey/register/begin` | none | Issue WebAuthn creation options | +| 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 | `/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 | +| POST | `/me/rules-agreement` | Bearer | Record living-room rules agreement | +| POST | `/me/lightning-address` | Bearer | Link/replace after live LNURL resolve | +| DELETE | `/me/lightning-address` | Bearer | Unlink address | +| POST | `/me/lightning-address/verification` | Bearer | Start address proof-of-control payment | +| POST | `/me/lightning-address/verification/confirm` | Bearer | Confirm nonce from wallet history | +| GET | `/messages` | Bearer | List public forum thread | +| POST | `/messages` | Bearer | Post text and/or one photo to the forum | +| GET | `/messages/:id/photo` | none | Fetch forum message photo bytes | +| POST | `/messages/:id/invoice` | Bearer | NIP-57 zap / BOLT11 | +| POST | `/contact` | Bearer | Send private in-app contact `{ text }` | +| GET | `/lightning-address` | none | Resolve LUD-16 metadata (cached) | +| GET | `/debug/accounts` | `Authorization: Bearer` | Operator account listing (`DEBUG_TOKEN`) | +| PATCH | `/debug/accounts/:id` | `Authorization: Bearer` | Operator set `account.role` (`DEBUG_TOKEN`) | +| 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`) | +| GET | `/gifts` | none | Outbound gifts for one UTC day (`?day=`) | +| GET | `/gifts/stats` | none | Aggregated outbound gift statistics | +| POST | `/invoices` | Bearer `SPEND_API_TOKEN` | Fetch a recipient BOLT11 (LNURL-pay) | +| POST | `/invoices/proof` | Bearer `SPEND_API_TOKEN` | Accept payment preimage as proof | ### `GET /healthz` @@ -707,6 +709,113 @@ Environment: | `DATABASE_URL` | When set, contacts are stored in Postgres; when unset, in-memory only. | | `DEBUG_TOKEN` | Operator bearer for this route. Unset → 503; process still boots. | +### `GET /debug/invoices` + +Operator listing of forum `POST /messages/:id/invoice` attempts. Authenticated +with `Authorization: Bearer` matching `DEBUG_TOKEN`. This is not an end-user +session. + +`DEBUG_TOKEN` unset or blank → **Response** `503`: + +```json +{ "error": "Debug is not configured" } +``` + +Missing or non-matching bearer → **Response** `401`: + +```json +{ "error": "Unauthorized" } +``` + +Success → **Response** `200`: + +```json +{ + "invoices": [ + { + "id": "", + "createdAt": "2026-08-30T12:00:00.000Z", + "messageId": "", + "payerAccountId": "", + "authorAccountId": "", + "amountSats": 21, + "lightningAddress": "user@walletofsatoshi.com", + "zapRequest": { "kind": 9734 }, + "result": "ok", + "httpStatus": 200, + "pr": "lnbc21n1...", + "paymentHash": "<64-hex>", + "description": null, + "descriptionHash": "<64-hex>", + "isNip57Invoice": true + } + ] +} +``` + +Rows are newest-first, capped at **200**. Never includes nsec. `result` is one +of `ok`, `noZap`, `unreachable`, `no_event`, `no_author`, `no_key`, +`sign_failed`, `rate_limited`, `bad_body`, `not_found`. `isNip57Invoice` is +true only when `descriptionHash` equals SHA-256 of the zap-request JSON string +sent as LNURL `nostr=`. Failure rows have `pr` null and `isNip57Invoice` +false. When `DATABASE_URL` is unset the in-memory store starts empty. + +Environment: + +| Variable | Meaning | +| -------------- | ----------------------------------------------------------------- | +| `DATABASE_URL` | When set, attempts are stored in Postgres `message_invoice`. | +| `DEBUG_TOKEN` | Operator bearer for this route. Unset → 503; process still boots. | + +### `GET /debug/zap-ingests` + +Operator listing of kind:9735 ingest decisions (`indexed` or `rejected`). +Authenticated with `Authorization: Bearer` matching `DEBUG_TOKEN`. This is not +an end-user session. + +`DEBUG_TOKEN` unset or blank → **Response** `503`: + +```json +{ "error": "Debug is not configured" } +``` + +Missing or non-matching bearer → **Response** `401`: + +```json +{ "error": "Unauthorized" } +``` + +Success → **Response** `200`: + +```json +{ + "ingests": [ + { + "id": "", + "createdAt": "2026-08-30T12:00:00.000Z", + "receiptId": "<64-hex>", + "noteEventId": "<64-hex>", + "messageId": "", + "outcome": "indexed", + "reason": null, + "amountSats": 21, + "receiptPubkey": "<64-hex>", + "receipt": { "id": "<64-hex>", "kind": 9735 } + } + ] +} +``` + +Rows are newest-first, capped at **200**. Never includes nsec. When +`DATABASE_URL` is unset the in-memory store starts empty. + +Environment: + +| Variable | Meaning | +| -------------- | ----------------------------------------------------------------- | +| `DATABASE_URL` | When set, ingest rows are stored in Postgres `nostr_zap_ingest`. | +| `DEBUG_TOKEN` | Operator bearer for this route. Unset → 503; process still boots. | + ### `GET /gifts` Public list of outbound gifts for one UTC calendar day. Query `day=YYYY-MM-DD`. @@ -1088,10 +1197,15 @@ Success → **Response** `200`: ### `POST /messages/:id/invoice` -Signed-in pay-on-note. Bearer session required. Body `{ "sats": }`. -The api signs a NIP-57 zap request with the **payer** key and returns a BOLT11 -invoice for the **author** Lightning Address. It does **not** increment -`sats` (that happens when a validated kind:9735 receipt is indexed). +Signed-in pay-on-note. Bearer session required. `:id` is a UUID (`MESSAGE_ID_RE`). +Body `{ "sats": }`. The api signs a NIP-57 zap request with the +**payer** key and returns a BOLT11 invoice for the **author** Lightning Address. +It does **not** increment `sats` (that happens when a validated kind:9735 +receipt is indexed). After auth, every attempt with a valid UUID is persisted +best-effort to `message_invoice` (result, HTTP status, `pr`, description vs +`description_hash`, `isNip57Invoice`). Store failures log +`message.invoice.record_failed` and do not change the HTTP response. A +non-UUID `:id` is **404** without a persist row. Success → **Response** `200`: diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 9c55be2a..5c7f0cb2 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -35,6 +35,20 @@ - **Used by:** Operators reading the private mailbox. - **Auth:** `Authorization: Bearer` with `DEBUG_TOKEN`. Not an end-user session. +## Endpoint: GET /debug/invoices + +- **Purpose:** Operator listing of forum `POST /messages/:id/invoice` attempts newest-first (cap 200): result, HTTP status, BOLT11 `pr`, payment hash, description / description_hash, and `isNip57Invoice`. ISO `createdAt`. Never includes nsec. +- **Errors:** 503 `{ error: 'Debug is not configured' }` when `DEBUG_TOKEN` is unset or blank; 401 `{ error: 'Unauthorized' }` when the Bearer token does not match. +- **Used by:** Operators debugging zap invoice issuance (including non-NIP-57 fallbacks). +- **Auth:** `Authorization: Bearer` with `DEBUG_TOKEN`. Not an end-user session. + +## Endpoint: GET /debug/zap-ingests + +- **Purpose:** Operator listing of kind:9735 ingest decisions newest-first (cap 200): `outcome` (`indexed` \| `rejected`), `reason`, receipt id, note/message ids, amount, and the receipt event frame. ISO `createdAt`. Never includes nsec. +- **Errors:** 503 `{ error: 'Debug is not configured' }` when `DEBUG_TOKEN` is unset or blank; 401 `{ error: 'Unauthorized' }` when the Bearer token does not match. +- **Used by:** Operators debugging zap receipt indexing. +- **Auth:** `Authorization: Bearer` with `DEBUG_TOKEN`. Not an end-user session. + ## Endpoint: POST /auth/passkey/authenticate/begin - **Purpose:** Issues WebAuthn request options for a discoverable credential. JSON: challengeId, options. @@ -163,8 +177,8 @@ ## Endpoint: POST /messages/:id/invoice -- **Purpose:** Bearer required. Body `{ sats }` (positive integer). Builds a NIP-57 kind:9734 zap request for the note, signs it with the payer's custodial key (ensuring one exists when KEK is present), and returns a BOLT11 `{ pr, amountSats }` via the author's LNURL-pay. The invoice rate limit is applied only after auth, amount, payable, and KEK checks. -- **Errors:** 401 Unauthorized; 400 bad body / note not yet payable; 404 Not found; 429 Too many payments (`Retry-After: 10`, after payable checks); 503 Messages are unavailable (missing KEK before limiter, or keygen/sign failure after). +- **Purpose:** Bearer required. `:id` is a UUID. Body `{ sats }` (positive integer). Builds a NIP-57 kind:9734 zap request for the note, signs it with the payer's custodial key (ensuring one exists when KEK is present), and returns a BOLT11 `{ pr, amountSats }` via the author's LNURL-pay. After auth, valid-UUID attempts are persisted best-effort (`message_invoice`); persist failures do not change the HTTP response. The invoice rate limit is applied only after auth, amount, payable, and KEK checks. +- **Errors:** 401 Unauthorized; 400 bad body / note not yet payable; 404 Not found (unknown id or non-UUID `:id`, the latter without a persist row); 429 Too many payments (`Retry-After: 10`, after payable checks); 503 Messages are unavailable (missing KEK before limiter, or keygen/sign failure after). - **Used by:** App pay sheet for forum notes. - **Auth:** `Authorization: Bearer` session. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index dfbabc57..bcf057dc 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, then additive `ALTER … ADD COLUMN IF NOT EXISTS` for existing databases). +- **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). - **Inputs:** `SqlClient`. - **Returns / side effects:** Void; idempotent DDL execute matching `docs/schema/message.sql`. - **Used by:** `openBootStores` when SQL opens. @@ -149,9 +149,9 @@ ## Function: PostgresMessageStore -- **Purpose:** Durable `MessageStore` over Postgres (`message` table). `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; `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 signed rows with a photo whose kind:1 content lacks `/messages/:id/photo` (any publish state, `sats = 0`, `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`). +- **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; `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 signed rows with a photo whose kind:1 content lacks `/messages/:id/photo` (any publish state, `sats = 0`, `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`. Claim uses `FOR UPDATE SKIP LOCKED`. Errors propagate to the route (503). +- **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. ## Function: PostgresContactStore @@ -208,7 +208,7 @@ - **Purpose:** Constant-time compare of `DEBUG_TOKEN` against `Authorization: Bearer`. - **Inputs:** Configured token (non-empty) and raw header or `undefined`. - **Returns / side effects:** `true` only on an exact Bearer match (trim on the presented token). -- **Used by:** `debugRoutes`, `debugContactsRoutes`. +- **Used by:** `debugRoutes`, `debugContactsRoutes`, `debugPaymentsRoutes`. ## Function: compareAccountsForList @@ -231,6 +231,27 @@ - **Returns / side effects:** Hono app. 503 if token unset; 401 if bearer mismatches; 200 `{ contacts }` newest-first (cap 200); 503 on store throw (`contact.list.failed`). Logs `debug.contacts.listed` with count, never the token. - **Used by:** `createApp` at `/debug/contacts`. +## Function: debugPaymentsRoutes + +- **Purpose:** Operator listing of forum invoice attempts (`message_invoice`) and kind:9735 ingest decisions (`nostr_zap_ingest`). +- **Inputs:** `DebugPaymentsRouteDeps`: message store, optional debugToken. +- **Returns / side effects:** Hono app. 503 if token unset; 401 if bearer mismatches; 200 `{ invoices }` on `GET /invoices` and `{ ingests }` on `GET /zap-ingests`, newest-first (cap 200). Logs `debug.invoices.listed` / `debug.zap_ingests.listed` with count, never the token or nsec. +- **Used by:** `createApp` at `/debug`. + +## Function: inspectBolt11 + +- **Purpose:** Decode BOLT11 payment hash, amount, plaintext description, description_hash, and expiry for operator debug (does not change `decodeBolt11`). +- **Inputs:** BOLT11 string; optional decoder inject for tests. +- **Returns / side effects:** `InspectedBolt11` or `null` when malformed / zero-amount. +- **Used by:** `POST /messages/:id/invoice` when persisting an ok attempt. + +## Function: isNip57Invoice + +- **Purpose:** True when `descriptionHash` equals `sha256(utf8(zapRequestJson))`. +- **Inputs:** description hash (or null) and zap request JSON string (or null). +- **Returns / side effects:** boolean. +- **Used by:** `POST /messages/:id/invoice` when persisting an ok attempt. + ## Function: InMemoryGiftStore - **Purpose:** Process-local GiftStore seeded at construction. Default empty so the process boots without a database. @@ -240,7 +261,7 @@ ## 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, `listPendingSigned` (pending, no `t=bitcoin`, oldest-first), `clearSignedEvent` (pending and `eventId` still matches `expectedEventId`, then nulls `eventId` / `nostrEvent` / `claimedUntil`), `listSignedMissingPhoto` (signed + photo, kind:1 content lacks `/messages/:id/photo`, oldest-first, any publish state, `sats === 0`), `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); `updateSignedEvent` returns false on duplicate `eventId`. Store/HTTP order is newest-first; product UX is a messenger group (clients reverse). +- **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, `listPendingSigned` (pending, no `t=bitcoin`, oldest-first), `clearSignedEvent` (pending and `eventId` still matches `expectedEventId`, then nulls `eventId` / `nostrEvent` / `claimedUntil`), `listSignedMissingPhoto` (signed + photo, kind:1 content lacks `/messages/:id/photo`, oldest-first, any publish state, `sats === 0`), `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. - **Used by:** `createApp` default `messageStore`. @@ -387,7 +408,7 @@ ## Function: createApp -- **Purpose:** Wires CORS, requestLog, brand, health, info, auth, me, `/view`, lightning-address, `/debug/accounts`, `/debug/contacts`, `/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`, `/gifts`, `/gifts/stats`, `/messages` (incl. invoice), `/contact`, and invoices. - **Inputs:** Optional `AppDeps` (store, clock, payer, fetch, cache, readBrand, origins, `debugToken`, giftStore, `giftRecorder`, `btcUsdRates`, `messageStore`, `contactStore`, `nostrKek`, spendApiToken, invoiceStore, `webAuthnRpId`, `webAuthnRpName`, `passkeyCeremony`). Omitted `giftRecorder` → `invoiceRoutes` uses `NoopGiftRecorder`; omitted `messageStore` → `InMemoryMessageStore`; omitted `contactStore` → `InMemoryContactStore`; omitted `nostrKek` → unsigned forum + invoice 503; SQL boot injects `SqlGiftRecorder`, `PostgresMessageStore`, `PostgresContactStore`, and parsed KEK. - **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. @@ -935,16 +956,16 @@ ## Function: indexZapReceipt -- **Purpose:** Validate provider pubkey (case-insensitive hex) and add sats once per receipt id. Callers verify the Nostr signature first. -- **Inputs:** store, messageId, receipt, providerPubkey, amountSats. -- **Returns / side effects:** boolean; logs indexed/rejected. +- **Purpose:** Validate provider pubkey (case-insensitive hex) and add sats once per receipt id. Callers verify the Nostr signature first. Persists a `nostr_zap_ingest` row (`indexed`, or `rejected` with reason `pubkey` / `amount` / `duplicate`); store throw logs `nostr.zap.ingest.record_failed` and does not change the boolean result. +- **Inputs:** store, messageId, receipt, providerPubkey, amountSats; optional receiptEvent / noteEventId for debug rows. +- **Returns / side effects:** boolean; logs indexed/rejected; records ingest. - **Used by:** `indexOpenZapReceipts` (worker tick). ## Function: indexOpenZapReceipts -- **Purpose:** Each worker tick, query zap relays for kind:9735 on recent notes (chunks of 20 event ids), verify the Nostr signature, validate provider pubkey via LNURL (module TTL cache, lowercased), bolt11 amount, e-tag, and index via `indexZapReceipt`. One throwing receipt does not skip the rest of the tick. +- **Purpose:** Each worker tick, query zap relays for kind:9735 on recent notes (chunks of 20 event ids), verify the Nostr signature, validate provider pubkey via LNURL (module TTL cache, lowercased), bolt11 amount, e-tag, and index via `indexZapReceipt`. Persists every ingest decision (`indexed` / `rejected` with reason). One throwing receipt does not skip the rest of the tick. - **Inputs:** store, auth, querier, urls, timeoutMs, now, fetchImpl; optional `verifyReceipt` (default: nostr-tools `verifyEvent`). -- **Returns / side effects:** void; logs `nostr.zap.rejected` / `indexed`; never logs full bolt11. +- **Returns / side effects:** void; logs `nostr.zap.rejected` / `indexed`; records ingest rows; never logs full bolt11. - **Used by:** `runNostrWorkerTick`. ## Function: requestZapInvoice diff --git a/docs/schema/message.sql b/docs/schema/message.sql index 353ed1f1..f092c7f2 100644 --- a/docs/schema/message.sql +++ b/docs/schema/message.sql @@ -32,3 +32,45 @@ CREATE TABLE IF NOT EXISTS nostr_zap_receipt ( message_id uuid NOT NULL REFERENCES message (id), sats bigint NOT NULL ); + +-- Invoice attempts from POST /messages/:id/invoice (success and failure). +-- No FK on message_id so not_found attempts still persist. +CREATE TABLE IF NOT EXISTS message_invoice ( + id uuid PRIMARY KEY, + created_at timestamptz NOT NULL, + message_id uuid NOT NULL, + payer_account_id uuid NOT NULL, + author_account_id uuid NOT NULL, + amount_sats bigint NOT NULL, + lightning_address text, + zap_request jsonb, + result text NOT NULL, + http_status integer NOT NULL, + pr text, + payment_hash text, + description text, + description_hash text, + is_nip57_invoice boolean NOT NULL DEFAULT false +); +CREATE INDEX IF NOT EXISTS message_invoice_created_at_idx + ON message_invoice (created_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS message_invoice_message_id_idx + ON message_invoice (message_id, created_at DESC); + +-- kind:9735 ingest decisions (indexed or rejected) for operator debug. +CREATE TABLE IF NOT EXISTS nostr_zap_ingest ( + id uuid PRIMARY KEY, + created_at timestamptz NOT NULL, + receipt_id text NOT NULL, + note_event_id text, + message_id uuid, + outcome text NOT NULL, + reason text, + amount_sats bigint, + receipt_pubkey text, + receipt jsonb NOT NULL +); +CREATE INDEX IF NOT EXISTS nostr_zap_ingest_receipt_id_idx + ON nostr_zap_ingest (receipt_id); +CREATE INDEX IF NOT EXISTS nostr_zap_ingest_created_at_idx + ON nostr_zap_ingest (created_at DESC, id DESC); diff --git a/e2e/functions.spec.ts b/e2e/functions.spec.ts index 045c6661..a77b1df1 100644 --- a/e2e/functions.spec.ts +++ b/e2e/functions.spec.ts @@ -390,6 +390,15 @@ test('Function: debugContactsRoutes — GET /debug/contacts without bearer is 40 expect(res.status()).toBe(401); }); +test('Function: debugPaymentsRoutes — GET /debug/invoices without bearer is 401', async ({ + request, +}) => { + const invoices = await request.get('/debug/invoices'); + expect(invoices.status()).toBe(401); + const ingests = await request.get('/debug/zap-ingests'); + expect(ingests.status()).toBe(401); +}); + test('Function: serializeContact — POST /contact without bearer is 401', async ({ request }) => { const res = await request.post('/contact', { data: { text: 'hi' }, @@ -636,6 +645,20 @@ test('Function: decodeBolt11 — POST /invoices unconfigured is 503', async ({ r expect(res.status()).toBe(503); }); +test('Function: inspectBolt11 — POST /invoices unconfigured is 503', async ({ request }) => { + const res = await request.post('/invoices', { + data: { address: 'alice@walletofsatoshi.com', amountMsat: 1000 }, + }); + expect(res.status()).toBe(503); +}); + +test('Function: isNip57Invoice — POST /invoices unconfigured is 503', async ({ request }) => { + const res = await request.post('/invoices', { + data: { address: 'alice@walletofsatoshi.com', amountMsat: 1000 }, + }); + expect(res.status()).toBe(503); +}); + test('Function: newInvoiceId — POST /invoices unconfigured is 503', async ({ request }) => { const res = await request.post('/invoices', { data: { address: 'alice@walletofsatoshi.com', amountMsat: 1000 }, diff --git a/scripts/check-handbook.mjs b/scripts/check-handbook.mjs index cdcd89c6..f3b6d174 100644 --- a/scripts/check-handbook.mjs +++ b/scripts/check-handbook.mjs @@ -135,6 +135,7 @@ function extractEndpoints() { 'brand.ts': '', 'debug.ts': '/debug/accounts', 'debug-contacts.ts': '/debug/contacts', + 'debug-payments.ts': '/debug', }; 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/src/__tests__/lib/bolt11.test.ts b/src/__tests__/lib/bolt11.test.ts index aaf77fe2..49b7b080 100644 --- a/src/__tests__/lib/bolt11.test.ts +++ b/src/__tests__/lib/bolt11.test.ts @@ -1,7 +1,9 @@ +import { createHash } from 'node:crypto'; import { describe, it, expect } from 'vitest'; -import { decodeBolt11 } from '@/lib/bolt11'; +import { decodeBolt11, inspectBolt11, isNip57Invoice } from '@/lib/bolt11'; const HASH = 'aa'.repeat(32); +const DESC_HASH = 'bb'.repeat(32); describe('decodeBolt11', () => { it('reads payment hash and amount from sections', () => { @@ -88,3 +90,77 @@ describe('decodeBolt11', () => { expect(decoded?.paymentHash).toMatch(/^[0-9a-f]{64}$/); }); }); + +describe('inspectBolt11', () => { + it('reads description_hash invoices with description null', () => { + const inspected = inspectBolt11('lnbc1', () => ({ + sections: [ + { name: 'payment_hash', value: HASH }, + { name: 'amount', value: '21000' }, + { name: 'description_hash', value: DESC_HASH.toUpperCase() }, + { name: 'expiry', value: 600 }, + ], + })); + expect(inspected).toEqual({ + paymentHash: HASH, + amountMsat: 21000, + description: null, + descriptionHash: DESC_HASH, + expirySeconds: 600, + }); + }); + + it('reads plaintext description invoices with descriptionHash null', () => { + const description = JSON.stringify({ kind: 9734, content: 'zap' }); + const inspected = inspectBolt11('lnbc1', () => ({ + sections: [ + { name: 'payment_hash', value: HASH }, + { name: 'amount', value: 21000 }, + { name: 'description', value: description }, + ], + })); + expect(inspected).toEqual({ + paymentHash: HASH, + amountMsat: 21000, + description, + descriptionHash: null, + expirySeconds: null, + }); + }); + + it('returns null for a bad payment request', () => { + expect(inspectBolt11('not-an-invoice')).toBeNull(); + expect( + inspectBolt11('lnbc1', () => { + throw new Error('bad'); + }), + ).toBeNull(); + expect( + inspectBolt11('lnbc1', () => ({ + sections: [{ name: 'amount', value: '1000' }], + })), + ).toBeNull(); + }); + + it('ignores a non-hex description_hash', () => { + const inspected = inspectBolt11('lnbc1', () => ({ + sections: [ + { name: 'payment_hash', value: HASH }, + { name: 'amount', value: '1000' }, + { name: 'description_hash', value: 'zz' }, + ], + })); + expect(inspected?.descriptionHash).toBeNull(); + }); +}); + +describe('isNip57Invoice', () => { + it('is true only when sha256(zap json) matches the description hash', () => { + const zapJson = JSON.stringify({ kind: 9734, content: 'pay' }); + const hash = createHash('sha256').update(zapJson, 'utf8').digest('hex'); + expect(isNip57Invoice(hash, zapJson)).toBe(true); + expect(isNip57Invoice(hash, JSON.stringify({ kind: 9734, content: 'other' }))).toBe(false); + expect(isNip57Invoice(null, zapJson)).toBe(false); + expect(isNip57Invoice(hash, null)).toBe(false); + }); +}); diff --git a/src/__tests__/lib/message-store.test.ts b/src/__tests__/lib/message-store.test.ts index d8f03f61..103fbc91 100644 --- a/src/__tests__/lib/message-store.test.ts +++ b/src/__tests__/lib/message-store.test.ts @@ -6,6 +6,8 @@ import { MESSAGE_SCHEMA_SQL, migrateMessageSchema, PostgresMessageStore, + type MessageInvoiceAttempt, + type ZapIngestRow, } from '@/lib/message-store'; class MockSql implements SqlClient { @@ -91,6 +93,10 @@ describe('MESSAGE_SCHEMA_SQL', () => { ); expect(MESSAGE_SCHEMA_SQL.join('\n')).toMatch(/event_id/); expect(MESSAGE_SCHEMA_SQL.join('\n')).toMatch(/nostr_zap_receipt/); + expect(MESSAGE_SCHEMA_SQL.join('\n')).toMatch(/CREATE TABLE IF NOT EXISTS message_invoice/i); + expect(MESSAGE_SCHEMA_SQL.join('\n')).toMatch(/CREATE TABLE IF NOT EXISTS nostr_zap_ingest/i); + expect(MESSAGE_SCHEMA_SQL.join('\n')).toMatch(/message_invoice_created_at_idx/i); + expect(MESSAGE_SCHEMA_SQL.join('\n')).toMatch(/nostr_zap_ingest_receipt_id_idx/i); }); }); @@ -368,6 +374,93 @@ describe('InMemoryMessageStore', () => { it('getPhoto returns null for an unknown id', async () => { expect(await new InMemoryMessageStore().getPhoto('missing')).toBeNull(); }); + + it('recordInvoiceAttempt lists newest-first and copies rows', async () => { + const store = new InMemoryMessageStore(); + const early: MessageInvoiceAttempt = { + id: 'inv-a', + createdAt: new Date('2026-08-01T00:00:00.000Z'), + messageId: 'm1', + payerAccountId: 'payer', + authorAccountId: 'author', + amountSats: 21, + lightningAddress: 'a@b.com', + zapRequest: { kind: 9734 }, + result: 'ok', + httpStatus: 200, + pr: 'lnbc1', + paymentHash: 'aa'.repeat(32), + description: null, + descriptionHash: 'bb'.repeat(32), + isNip57Invoice: true, + }; + const late: MessageInvoiceAttempt = { + ...early, + id: 'inv-b', + createdAt: new Date('2026-08-02T00:00:00.000Z'), + result: 'noZap', + httpStatus: 400, + pr: null, + isNip57Invoice: false, + }; + const tieHigh: MessageInvoiceAttempt = { + ...early, + id: 'inv-z', + createdAt: new Date('2026-08-02T00:00:00.000Z'), + result: 'unreachable', + }; + await store.recordInvoiceAttempt(early); + await store.recordInvoiceAttempt(late); + await store.recordInvoiceAttempt(tieHigh); + const listed = await store.listInvoiceAttempts(2); + expect(listed.map((row) => row.id)).toEqual(['inv-z', 'inv-b']); + if (listed[0] !== undefined) { + listed[0].result = 'bad_body'; + listed[0].zapRequest = { mutated: true }; + } + const again = await store.listInvoiceAttempts(10); + expect(again.map((row) => row.id)).toEqual(['inv-z', 'inv-b', 'inv-a']); + expect(again[0]?.result).toBe('unreachable'); + expect(again[0]?.zapRequest).toEqual({ kind: 9734 }); + }); + + it('recordZapIngest lists newest-first and copies rows', async () => { + const store = new InMemoryMessageStore(); + const early: ZapIngestRow = { + id: 'zi-a', + createdAt: new Date('2026-08-01T00:00:00.000Z'), + receiptId: 'r1', + noteEventId: 'ee'.repeat(32), + messageId: 'm1', + outcome: 'rejected', + reason: 'sig', + amountSats: null, + receiptPubkey: 'aa'.repeat(32), + receipt: { id: 'r1', kind: 9735 }, + }; + const late: ZapIngestRow = { + ...early, + id: 'zi-b', + createdAt: new Date('2026-08-02T00:00:00.000Z'), + outcome: 'indexed', + reason: null, + amountSats: 21, + receipt: { id: 'r2', kind: 9735 }, + }; + await store.recordZapIngest(early); + await store.recordZapIngest(late); + const listed = await store.listZapIngests(1); + expect(listed).toHaveLength(1); + expect(listed[0]?.id).toBe('zi-b'); + if (listed[0] !== undefined) { + listed[0].outcome = 'rejected'; + listed[0].receipt['mutated'] = true; + } + const again = await store.listZapIngests(10); + expect(again.map((row) => row.id)).toEqual(['zi-b', 'zi-a']); + expect(again[0]?.outcome).toBe('indexed'); + expect(again[0]?.receipt).toEqual({ id: 'r2', kind: 9735 }); + }); }); describe('PostgresMessageStore', () => { @@ -712,4 +805,249 @@ describe('PostgresMessageStore', () => { sql.queryError = new Error('photo boom'); await expect(new PostgresMessageStore(sql).getPhoto('m1')).rejects.toThrow('photo boom'); }); + + it('recordInvoiceAttempt inserts into message_invoice with jsonb zap_request', async () => { + const sql = new MockSql(); + const store = new PostgresMessageStore(sql); + const row: MessageInvoiceAttempt = { + id: 'inv-1', + createdAt: new Date('2026-08-28T00:00:00.000Z'), + messageId: 'm1', + payerAccountId: 'payer', + authorAccountId: 'author', + amountSats: 21, + lightningAddress: 'a@b.com', + zapRequest: { kind: 9734 }, + result: 'ok', + httpStatus: 200, + pr: 'lnbc1', + paymentHash: 'aa'.repeat(32), + description: null, + descriptionHash: 'bb'.repeat(32), + isNip57Invoice: true, + }; + await store.recordInvoiceAttempt(row); + expect(sql.executes).toHaveLength(1); + expect(sql.executes[0]?.text).toMatch(/INSERT INTO message_invoice/); + expect(sql.executes[0]?.text).toMatch(/zap_request/); + expect(sql.executes[0]?.params[7]).toBe(JSON.stringify({ kind: 9734 })); + expect(sql.executes[0]?.params[14]).toBe(true); + }); + + it('recordInvoiceAttempt binds null zap_request when the attempt has none', async () => { + const sql = new MockSql(); + const store = new PostgresMessageStore(sql); + const row: MessageInvoiceAttempt = { + id: 'inv-null', + createdAt: new Date('2026-08-28T00:00:00.000Z'), + messageId: 'm1', + payerAccountId: 'payer', + authorAccountId: 'author', + amountSats: 21, + lightningAddress: null, + zapRequest: null, + result: 'not_found', + httpStatus: 404, + pr: null, + paymentHash: null, + description: null, + descriptionHash: null, + isNip57Invoice: false, + }; + await store.recordInvoiceAttempt(row); + expect(sql.executes[0]?.params[7]).toBeNull(); + }); + + it('listInvoiceAttempts maps Date/string created_at, numeric amount, and JSON zap_request', async () => { + const sql = new MockSql(); + sql.nextRows = [ + { + id: 'inv-1', + created_at: new Date('2026-08-28T12:00:00.000Z'), + message_id: 'm1', + payer_account_id: 'payer', + author_account_id: 'author', + amount_sats: '21', + lightning_address: 'a@b.com', + zap_request: { kind: 9734 }, + result: 'ok', + http_status: 200, + pr: 'lnbc1', + payment_hash: 'aa'.repeat(32), + description: null, + description_hash: 'bb'.repeat(32), + is_nip57_invoice: true, + }, + { + id: 'inv-2', + created_at: '2026-08-27T12:00:00.000Z', + message_id: 'm2', + payer_account_id: 'payer', + author_account_id: 'author', + amount_sats: 7, + lightning_address: null, + zap_request: JSON.stringify({ kind: 9734, content: 'x' }), + result: 'noZap', + http_status: 400, + pr: null, + payment_hash: null, + description: 'plain', + description_hash: null, + is_nip57_invoice: 0, + }, + { + id: 'inv-3', + created_at: new Date('2026-08-26T12:00:00.000Z'), + message_id: 'm3', + payer_account_id: 'payer', + author_account_id: 'author', + amount_sats: 0, + lightning_address: null, + zap_request: 'not-json', + result: 'bad_body', + http_status: 400, + pr: null, + payment_hash: null, + description: null, + description_hash: null, + is_nip57_invoice: null, + }, + { + id: 'inv-4', + created_at: new Date('2026-08-25T12:00:00.000Z'), + message_id: 'm4', + payer_account_id: 'payer', + author_account_id: 'author', + amount_sats: 0, + lightning_address: null, + zap_request: null, + result: 'not_found', + http_status: 404, + pr: null, + payment_hash: null, + description: null, + description_hash: null, + is_nip57_invoice: false, + }, + { + id: 'inv-5', + created_at: new Date('2026-08-24T12:00:00.000Z'), + message_id: 'm5', + payer_account_id: 'payer', + author_account_id: 'author', + amount_sats: 0, + lightning_address: null, + zap_request: '[1,2]', + result: 'bad_body', + http_status: 400, + pr: null, + payment_hash: null, + description: null, + description_hash: null, + is_nip57_invoice: false, + }, + ]; + const store = new PostgresMessageStore(sql); + const listed = await store.listInvoiceAttempts(50); + expect(sql.queries[0]?.text).toMatch(/FROM message_invoice/); + expect(sql.queries[0]?.text).toMatch(/ORDER BY created_at DESC, id DESC/); + expect(sql.queries[0]?.text).toMatch(/LIMIT \$1/); + expect(sql.queries[0]?.params).toEqual([50]); + expect(listed[0]?.amountSats).toBe(21); + expect(listed[0]?.zapRequest).toEqual({ kind: 9734 }); + expect(listed[0]?.isNip57Invoice).toBe(true); + expect(listed[1]?.createdAt.toISOString()).toBe('2026-08-27T12:00:00.000Z'); + expect(listed[1]?.zapRequest).toEqual({ kind: 9734, content: 'x' }); + expect(listed[1]?.isNip57Invoice).toBe(false); + expect(listed[2]?.zapRequest).toBeNull(); + expect(listed[3]?.zapRequest).toBeNull(); + expect(listed[4]?.zapRequest).toBeNull(); + }); + + it('recordZapIngest inserts into nostr_zap_ingest', async () => { + const sql = new MockSql(); + const store = new PostgresMessageStore(sql); + const row: ZapIngestRow = { + id: 'zi-1', + createdAt: new Date('2026-08-28T00:00:00.000Z'), + receiptId: 'r1', + noteEventId: 'ee'.repeat(32), + messageId: 'm1', + outcome: 'indexed', + reason: null, + amountSats: 21, + receiptPubkey: 'aa'.repeat(32), + receipt: { id: 'r1', kind: 9735 }, + }; + await store.recordZapIngest(row); + expect(sql.executes).toHaveLength(1); + expect(sql.executes[0]?.text).toMatch(/INSERT INTO nostr_zap_ingest/); + expect(sql.executes[0]?.params[9]).toBe(JSON.stringify({ id: 'r1', kind: 9735 })); + }); + + it('listZapIngests maps receipt JSON string, non-indexed outcome, and null amount', async () => { + const sql = new MockSql(); + sql.nextRows = [ + { + id: 'zi-1', + created_at: new Date('2026-08-28T12:00:00.000Z'), + receipt_id: 'r1', + note_event_id: 'ee'.repeat(32), + message_id: 'm1', + outcome: 'indexed', + reason: null, + amount_sats: '21', + receipt_pubkey: 'aa'.repeat(32), + receipt: JSON.stringify({ id: 'r1', kind: 9735 }), + }, + { + id: 'zi-2', + created_at: '2026-08-27T12:00:00.000Z', + receipt_id: 'r2', + note_event_id: null, + message_id: null, + outcome: 'weird', + reason: 'sig', + amount_sats: null, + receipt_pubkey: null, + receipt: 'not-json', + }, + { + id: 'zi-3', + created_at: new Date('2026-08-26T12:00:00.000Z'), + receipt_id: 'r3', + note_event_id: null, + message_id: null, + outcome: 'rejected', + reason: 'error', + amount_sats: null, + receipt_pubkey: null, + receipt: null, + }, + { + id: 'zi-4', + created_at: new Date('2026-08-25T12:00:00.000Z'), + receipt_id: 'r4', + note_event_id: null, + message_id: null, + outcome: 'rejected', + reason: 'error', + amount_sats: null, + receipt_pubkey: null, + receipt: '[1]', + }, + ]; + const store = new PostgresMessageStore(sql); + const listed = await store.listZapIngests(10); + expect(sql.queries[0]?.text).toMatch(/FROM nostr_zap_ingest/); + expect(sql.queries[0]?.text).toMatch(/ORDER BY created_at DESC, id DESC/); + expect(listed[0]?.outcome).toBe('indexed'); + expect(listed[0]?.amountSats).toBe(21); + expect(listed[0]?.receipt).toEqual({ id: 'r1', kind: 9735 }); + expect(listed[1]?.outcome).toBe('rejected'); + expect(listed[1]?.amountSats).toBeNull(); + expect(listed[1]?.receipt).toEqual({}); + expect(listed[2]?.receipt).toEqual({}); + expect(listed[3]?.receipt).toEqual({}); + }); }); diff --git a/src/__tests__/lib/nostr/zap-index.test.ts b/src/__tests__/lib/nostr/zap-index.test.ts index e20ffeeb..4b530525 100644 --- a/src/__tests__/lib/nostr/zap-index.test.ts +++ b/src/__tests__/lib/nostr/zap-index.test.ts @@ -431,6 +431,9 @@ describe('indexOpenZapReceipts', () => { fetchImpl: lnurlFetch(PROVIDER_PUBKEY), }); expect((await store.getByEventId(NOTE_EVENT_ID))?.sats).toBe(0); + const ingests = await store.listZapIngests(10); + expect(ingests).toHaveLength(2); + expect(ingests.every((row) => row.outcome === 'rejected' && row.reason === 'event')).toBe(true); }); it('does not increment sats for an unknown e-tag event id', async () => { @@ -465,6 +468,11 @@ describe('indexOpenZapReceipts', () => { fetchImpl: lnurlFetch(PROVIDER_PUBKEY), }); expect((await store.getByEventId(NOTE_EVENT_ID))?.sats).toBe(0); + const ingests = await store.listZapIngests(10); + expect(ingests).toHaveLength(1); + expect(ingests[0]?.outcome).toBe('rejected'); + expect(ingests[0]?.reason).toBe('event'); + expect(ingests[0]?.noteEventId).toBe('ff'.repeat(32)); }); it('does not increment sats without bolt11 or when decodeBolt11 returns null', async () => { @@ -505,6 +513,11 @@ describe('indexOpenZapReceipts', () => { fetchImpl: lnurlFetch(PROVIDER_PUBKEY), }); expect((await store.getByEventId(NOTE_EVENT_ID))?.sats).toBe(0); + const ingests = await store.listZapIngests(10); + expect(ingests).toHaveLength(2); + expect(ingests.every((row) => row.outcome === 'rejected' && row.reason === 'bolt11')).toBe( + true, + ); }); it('does not increment sats when bolt11 tag value is empty', async () => { @@ -830,7 +843,7 @@ describe('indexOpenZapReceipts', () => { const store = new InMemoryMessageStore(); const auth = new InMemoryAuthStore(); const querier = new RecordingQuerier(); - await seedStore({ + const messageId = await seedStore({ store, auth, accountId: 'acc-ok', @@ -858,6 +871,62 @@ describe('indexOpenZapReceipts', () => { fetchImpl: lnurlFetch(PROVIDER_PUBKEY), }); expect((await store.getByEventId(NOTE_EVENT_ID))?.sats).toBe(21); + const ingests = await store.listZapIngests(10); + expect(ingests).toHaveLength(1); + expect(ingests[0]?.outcome).toBe('indexed'); + expect(ingests[0]?.reason).toBeNull(); + expect(ingests[0]?.amountSats).toBe(21); + expect(ingests[0]?.messageId).toBe(messageId); + expect(ingests[0]?.receiptId).toBe('r-ok'); + }); + + it('records duplicate ingest when the same receipt is seen again', async () => { + const store = new InMemoryMessageStore(); + const auth = new InMemoryAuthStore(); + const querier = new RecordingQuerier(); + await seedStore({ + store, + auth, + accountId: 'acc-dup', + lightningAddress: 'zap-dup@example.com', + }); + querier.events = [ + { + id: 'r-dup', + pubkey: PROVIDER_PUBKEY, + kind: 9735, + tags: [ + ['e', NOTE_EVENT_ID], + ['bolt11', 'lnbc-dup'], + ], + }, + ]; + mockedDecode.mockReturnValue({ paymentHash: '11'.repeat(32), amountMsat: 21_000 }); + await ingest({ + store, + auth, + querier, + urls: URLS, + timeoutMs: 50, + now: () => 1, + fetchImpl: lnurlFetch(PROVIDER_PUBKEY), + }); + await ingest({ + store, + auth, + querier, + urls: URLS, + timeoutMs: 50, + now: () => 1, + fetchImpl: lnurlFetch(PROVIDER_PUBKEY), + }); + expect((await store.getByEventId(NOTE_EVENT_ID))?.sats).toBe(21); + const ingests = await store.listZapIngests(10); + expect(ingests).toHaveLength(2); + expect(ingests.some((row) => row.outcome === 'indexed' && row.reason === null)).toBe(true); + expect(ingests.some((row) => row.outcome === 'rejected' && row.reason === 'duplicate')).toBe( + true, + ); }); it('caches provider pubkey within TTL and refreshes after expiry', async () => { @@ -966,6 +1035,89 @@ describe('indexOpenZapReceipts', () => { verifyReceipt: () => false, }); expect((await store.getByEventId(NOTE_EVENT_ID))?.sats).toBe(0); + const ingests = await store.listZapIngests(10); + expect(ingests).toHaveLength(1); + expect(ingests[0]?.outcome).toBe('rejected'); + expect(ingests[0]?.reason).toBe('sig'); + expect(ingests[0]?.receiptId).toBe('r-sig'); + }); + + it('logs nostr.zap.ingest.record_failed when recordZapIngest throws', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + const base = new InMemoryMessageStore(); + const auth = new InMemoryAuthStore(); + await seedStore({ + store: base, + auth, + accountId: 'acc-record-fail', + lightningAddress: 'zap-record-fail@example.com', + }); + const store = { + listLatest: (limit: number) => base.listLatest(limit), + create: (...args: Parameters) => base.create(...args), + getPhoto: (id: string) => base.getPhoto(id), + getById: (id: string) => base.getById(id), + getByEventId: (id: string) => base.getByEventId(id), + claimUnsigned: (...args: Parameters) => + base.claimUnsigned(...args), + claimUnpublished: (...args: Parameters) => + base.claimUnpublished(...args), + listPendingSigned: (limit: number) => base.listPendingSigned(limit), + listSignedMissingPhoto: (limit: number) => base.listSignedMissingPhoto(limit), + clearSignedEvent: (...args: Parameters) => + base.clearSignedEvent(...args), + resetSignedEvent: (...args: Parameters) => + base.resetSignedEvent(...args), + updateSignedEvent: (...args: Parameters) => + base.updateSignedEvent(...args), + updatePublishState: (...args: Parameters) => + base.updatePublishState(...args), + addSats: (...args: Parameters) => base.addSats(...args), + recordZapReceipt: (...args: Parameters) => + base.recordZapReceipt(...args), + recordInvoiceAttempt: (...args: Parameters) => + base.recordInvoiceAttempt(...args), + listInvoiceAttempts: (limit: number) => base.listInvoiceAttempts(limit), + recordZapIngest: async () => { + throw new Error('ingest persist boom'); + }, + listZapIngests: (limit: number) => base.listZapIngests(limit), + }; + const querier = new RecordingQuerier(); + querier.events = [ + { + id: 'r-record-fail', + pubkey: PROVIDER_PUBKEY, + kind: 9735, + tags: [ + ['e', NOTE_EVENT_ID], + ['bolt11', 'lnbc-ok'], + ], + }, + ]; + mockedDecode.mockReturnValue({ paymentHash: '11'.repeat(32), amountMsat: 21_000 }); + await expect( + ingest({ + store, + auth, + querier, + urls: URLS, + timeoutMs: 50, + now: () => 1, + fetchImpl: lnurlFetch(PROVIDER_PUBKEY), + }), + ).resolves.toBeUndefined(); + expect((await store.getByEventId(NOTE_EVENT_ID))?.sats).toBe(21); + const events = warn.mock.calls + .map((call) => call[0]) + .filter((arg): arg is string => typeof arg === 'string' && arg.startsWith('{')) + .map((arg) => JSON.parse(arg) as Record); + expect(events.some((e) => e['event'] === 'nostr.zap.ingest.record_failed')).toBe(true); + expect(events.some((e) => e['event'] === 'nostr.zap.indexed')).toBe(true); + } finally { + warn.mockRestore(); + } }); it('indexes a later receipt when an earlier verify throws', async () => { @@ -1112,4 +1264,36 @@ describe('indexOpenZapReceipts', () => { }); expect((await store.getByEventId(NOTE_EVENT_ID))?.sats).toBe(21); }); + + it('records ingest error with null receiptPubkey when pubkey is not a string', async () => { + const store = new InMemoryMessageStore(); + const auth = new InMemoryAuthStore(); + await seedStore({ store, auth, accountId: 'acc-pubkey-type' }); + const querier = new RecordingQuerier(); + querier.events = [ + { + id: 'r-bad-pubkey', + pubkey: 1 as unknown as string, + kind: 9735, + tags: [['e', NOTE_EVENT_ID]], + }, + ]; + await ingest({ + store, + auth, + querier, + urls: URLS, + timeoutMs: 50, + now: () => 1, + fetchImpl: lnurlFetch(PROVIDER_PUBKEY), + verifyReceipt: () => { + throw new Error('verify boom'); + }, + }); + const rows = await store.listZapIngests(10); + const row = rows.find((item) => item.receiptId === 'r-bad-pubkey'); + expect(row?.outcome).toBe('rejected'); + expect(row?.reason).toBe('pubkey'); + expect(row?.receiptPubkey).toBeNull(); + }); }); diff --git a/src/__tests__/routes/debug-payments.test.ts b/src/__tests__/routes/debug-payments.test.ts new file mode 100644 index 00000000..49358b45 --- /dev/null +++ b/src/__tests__/routes/debug-payments.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Hono } from 'hono'; +import { + InMemoryMessageStore, + type MessageInvoiceAttempt, + type ZapIngestRow, +} from '@/lib/message-store'; +import { debugPaymentsRoutes } from '@/routes/debug-payments'; + +function parsedEvents(warn: ReturnType): Array> { + return warn.mock.calls + .map((call) => call[0]) + .filter((arg): arg is string => typeof arg === 'string' && arg.startsWith('{')) + .map((arg) => JSON.parse(arg) as Record); +} + +function mount(store: InMemoryMessageStore, debugToken: string | undefined): Hono { + return new Hono().route('/debug', debugPaymentsRoutes({ store, debugToken })); +} + +describe('debugPaymentsRoutes', () => { + let warn: ReturnType; + + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + warn.mockRestore(); + }); + + it('returns 503 when debug is not configured', async () => { + const app = mount(new InMemoryMessageStore(), undefined); + const invoices = await app.request('/debug/invoices'); + expect(invoices.status).toBe(503); + expect(await invoices.json()).toEqual({ error: 'Debug is not configured' }); + const ingests = await app.request('/debug/zap-ingests'); + expect(ingests.status).toBe(503); + expect(await ingests.json()).toEqual({ error: 'Debug is not configured' }); + }); + + it('returns 503 when the token is blank', async () => { + const app = mount(new InMemoryMessageStore(), ' '); + const invoices = await app.request('/debug/invoices', { + headers: { authorization: 'Bearer ' }, + }); + expect(invoices.status).toBe(503); + const ingests = await app.request('/debug/zap-ingests', { + headers: { authorization: 'Bearer ' }, + }); + expect(ingests.status).toBe(503); + }); + + it('returns 401 without a matching bearer on both paths', async () => { + const app = mount(new InMemoryMessageStore(), 'secret'); + const invoices = await app.request('/debug/invoices'); + expect(invoices.status).toBe(401); + expect(await invoices.json()).toEqual({ error: 'Unauthorized' }); + const ingests = await app.request('/debug/zap-ingests'); + expect(ingests.status).toBe(401); + expect(await ingests.json()).toEqual({ error: 'Unauthorized' }); + }); + + it('lists invoice attempts newest-first with ISO dates', async () => { + const store = new InMemoryMessageStore(); + const early: MessageInvoiceAttempt = { + id: 'inv-a', + createdAt: new Date('2026-08-01T00:00:00.000Z'), + messageId: 'm1', + payerAccountId: 'payer', + authorAccountId: 'author', + amountSats: 21, + lightningAddress: 'a@b.com', + zapRequest: { kind: 9734 }, + result: 'ok', + httpStatus: 200, + pr: 'lnbc21n1test', + paymentHash: 'aa'.repeat(32), + description: null, + descriptionHash: 'bb'.repeat(32), + isNip57Invoice: true, + }; + const late: MessageInvoiceAttempt = { + ...early, + id: 'inv-b', + createdAt: new Date('2026-08-02T00:00:00.000Z'), + result: 'noZap', + httpStatus: 400, + pr: null, + isNip57Invoice: false, + }; + await store.recordInvoiceAttempt(early); + await store.recordInvoiceAttempt(late); + const app = mount(store, 'secret'); + const res = await app.request('/debug/invoices', { + headers: { authorization: 'Bearer secret' }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + invoices: Array>; + }; + expect(body.invoices).toHaveLength(2); + expect(body.invoices[0]?.['id']).toBe('inv-b'); + expect(body.invoices[0]?.['createdAt']).toBe('2026-08-02T00:00:00.000Z'); + expect(body.invoices[0]?.['result']).toBe('noZap'); + expect(body.invoices[0]?.['pr']).toBeNull(); + expect(body.invoices[0]?.['isNip57Invoice']).toBe(false); + expect(body.invoices[1]?.['pr']).toBe('lnbc21n1test'); + expect(body.invoices[1]?.['isNip57Invoice']).toBe(true); + expect(JSON.stringify(body)).not.toMatch(/nsec/i); + expect(parsedEvents(warn).some((e) => e['event'] === 'debug.invoices.listed')).toBe(true); + }); + + it('lists zap ingest rows newest-first with ISO dates', async () => { + const store = new InMemoryMessageStore(); + const early: ZapIngestRow = { + id: 'zi-a', + createdAt: new Date('2026-08-01T00:00:00.000Z'), + receiptId: 'r1', + noteEventId: 'ee'.repeat(32), + messageId: 'm1', + outcome: 'rejected', + reason: 'sig', + amountSats: null, + receiptPubkey: 'aa'.repeat(32), + receipt: { id: 'r1', kind: 9735 }, + }; + const late: ZapIngestRow = { + ...early, + id: 'zi-b', + createdAt: new Date('2026-08-02T00:00:00.000Z'), + outcome: 'indexed', + reason: null, + amountSats: 21, + receipt: { id: 'r2', kind: 9735 }, + }; + await store.recordZapIngest(early); + await store.recordZapIngest(late); + const app = mount(store, 'secret'); + const res = await app.request('/debug/zap-ingests', { + headers: { authorization: 'Bearer secret' }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + ingests: Array>; + }; + expect(body.ingests).toHaveLength(2); + expect(body.ingests[0]?.['id']).toBe('zi-b'); + expect(body.ingests[0]?.['createdAt']).toBe('2026-08-02T00:00:00.000Z'); + expect(body.ingests[0]?.['outcome']).toBe('indexed'); + expect(body.ingests[0]?.['amountSats']).toBe(21); + expect(body.ingests[1]?.['reason']).toBe('sig'); + expect(JSON.stringify(body)).not.toMatch(/nsec/i); + expect(parsedEvents(warn).some((e) => e['event'] === 'debug.zap_ingests.listed')).toBe(true); + }); +}); diff --git a/src/__tests__/routes/messages.test.ts b/src/__tests__/routes/messages.test.ts index 63e36c09..fb8db95f 100644 --- a/src/__tests__/routes/messages.test.ts +++ b/src/__tests__/routes/messages.test.ts @@ -96,6 +96,10 @@ function throwingStore(overrides: Partial = {}): MessageStore { updatePublishState: boom, addSats: boom, recordZapReceipt: boom, + recordInvoiceAttempt: boom, + listInvoiceAttempts: boom, + recordZapIngest: boom, + listZapIngests: boom, ...overrides, }; } @@ -741,7 +745,7 @@ describe('POST /messages/:id/invoice', () => { invoiceLimiter: new InvoiceRateLimiter(), }), ); - const res = await app.request('/messages/m1/invoice', { + const res = await app.request('/messages/11111111-1111-4111-8111-111111111111/invoice', { method: 'POST', headers: { ...AUTH, 'content-type': 'application/json' }, body: JSON.stringify({ sats: 1.5 }), @@ -1049,6 +1053,462 @@ describe('POST /messages/:id/invoice', () => { }); expect(res.status).toBe(404); }); + + it('persists an ok invoice attempt with pr and isNip57Invoice from inspect', async () => { + const { parseNostrKek } = await import('@/lib/nostr/kek'); + const { ensureAccountNostrKey } = await import('@/lib/nostr/keys'); + const kek = parseNostrKek('11'.repeat(32)); + const authStore = await namedStore('Ada'); + const account = await authStore.getAccount('acc'); + expect(account).toBeDefined(); + if (account === undefined) { + throw new Error('expected account'); + } + await authStore.updateAccount({ + ...account, + lightningAddress: 'ada@walletofsatoshi.com', + }); + await ensureAccountNostrKey(authStore, 'acc', kek); + const messageStore = new InMemoryMessageStore(); + await messageStore.create({ + id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + accountId: 'acc', + name: 'Ada', + text: 'hi', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + eventId: 'ee'.repeat(32), + }); + const fetchImpl = async (input: string | URL | Request): Promise => { + const url = String(input); + if (url.includes('/.well-known/lnurlp/')) { + return new Response( + JSON.stringify({ + callback: 'https://walletofsatoshi.com/lnurlp/callback', + minSendable: 1000, + maxSendable: 10_000_000_000, + allowsNostr: true, + nostrPubkey: 'aa'.repeat(32), + }), + { headers: { 'content-type': 'application/json' } }, + ); + } + return new Response(JSON.stringify({ pr: 'lnbc21n1test' }), { + headers: { 'content-type': 'application/json' }, + }); + }; + const bolt11 = await import('@/lib/bolt11'); + const inspectSpy = vi.spyOn(bolt11, 'inspectBolt11').mockReturnValue({ + paymentHash: 'aa'.repeat(32), + amountMsat: 21_000, + description: null, + descriptionHash: 'bb'.repeat(32), + expirySeconds: 86400, + }); + try { + const app = new Hono().route( + '/messages', + messagesRoutes({ + store: messageStore, + authStore, + now, + nostrKek: kek, + fetchImpl, + postLimiter: new PostRateLimiter(), + invoiceLimiter: new InvoiceRateLimiter(), + }), + ); + const res = await app.request('/messages/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/invoice', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ sats: 21 }), + }); + expect(res.status).toBe(200); + const attempts = await messageStore.listInvoiceAttempts(10); + expect(attempts).toHaveLength(1); + expect(attempts[0]?.result).toBe('ok'); + expect(attempts[0]?.pr).toBe('lnbc21n1test'); + expect(attempts[0]?.httpStatus).toBe(200); + expect(attempts[0]?.paymentHash).toBe('aa'.repeat(32)); + expect(attempts[0]?.descriptionHash).toBe('bb'.repeat(32)); + expect(attempts[0]?.zapRequest).not.toBeNull(); + } finally { + inspectSpy.mockRestore(); + } + }); + + it('persists noZap and unreachable with pr null and http 400', async () => { + const { parseNostrKek } = await import('@/lib/nostr/kek'); + const { ensureAccountNostrKey } = await import('@/lib/nostr/keys'); + const kek = parseNostrKek('11'.repeat(32)); + const authStore = await namedStore('Ada'); + const account = await authStore.getAccount('acc'); + expect(account).toBeDefined(); + if (account === undefined) { + throw new Error('expected account'); + } + await authStore.updateAccount({ + ...account, + lightningAddress: 'ada@walletofsatoshi.com', + }); + await ensureAccountNostrKey(authStore, 'acc', kek); + const messageStore = new InMemoryMessageStore(); + await messageStore.create({ + id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + accountId: 'acc', + name: 'Ada', + text: 'hi', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + eventId: 'ee'.repeat(32), + }); + const noZapFetch = async (input: string | URL | Request): Promise => { + const url = String(input); + if (url.includes('/.well-known/lnurlp/')) { + return new Response( + JSON.stringify({ + callback: 'https://walletofsatoshi.com/lnurlp/callback', + minSendable: 1000, + maxSendable: 10_000_000_000, + }), + { headers: { 'content-type': 'application/json' } }, + ); + } + return new Response('{}', { status: 500 }); + }; + const appNoZap = new Hono().route( + '/messages', + messagesRoutes({ + store: messageStore, + authStore, + now, + nostrKek: kek, + fetchImpl: noZapFetch, + postLimiter: new PostRateLimiter(), + invoiceLimiter: new InvoiceRateLimiter(), + }), + ); + const noZapRes = await appNoZap.request( + '/messages/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb/invoice', + { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ sats: 21 }), + }, + ); + expect(noZapRes.status).toBe(400); + expect((await messageStore.listInvoiceAttempts(1))[0]?.result).toBe('noZap'); + expect((await messageStore.listInvoiceAttempts(1))[0]?.pr).toBeNull(); + expect((await messageStore.listInvoiceAttempts(1))[0]?.httpStatus).toBe(400); + + const unreachableFetch = async (): Promise => new Response('{}', { status: 500 }); + const appUnreachable = new Hono().route( + '/messages', + messagesRoutes({ + store: messageStore, + authStore, + now, + nostrKek: kek, + fetchImpl: unreachableFetch, + postLimiter: new PostRateLimiter(), + invoiceLimiter: new InvoiceRateLimiter(), + }), + ); + const unreachableRes = await appUnreachable.request( + '/messages/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb/invoice', + { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ sats: 21 }), + }, + ); + expect(unreachableRes.status).toBe(400); + const attempts = await messageStore.listInvoiceAttempts(2); + expect(attempts.some((row) => row.result === 'unreachable')).toBe(true); + expect(attempts.find((row) => row.result === 'unreachable')?.pr).toBeNull(); + }); + + it('returns 404 for a non-uuid invoice id without persisting', async () => { + const kek = new Uint8Array(32).fill(2); + const authStore = await namedStore('Ada'); + const messageStore = new InMemoryMessageStore(); + const app = new Hono().route( + '/messages', + messagesRoutes({ + store: messageStore, + authStore, + now, + nostrKek: kek, + postLimiter: new PostRateLimiter(), + invoiceLimiter: new InvoiceRateLimiter(), + }), + ); + const res = await app.request('/messages/not-a-uuid/invoice', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ sats: 21 }), + }); + expect(res.status).toBe(404); + expect(await messageStore.listInvoiceAttempts(10)).toHaveLength(0); + }); + + it('persists no_event when the note has no eventId', async () => { + const kek = new Uint8Array(32).fill(2); + const authStore = await namedStore('Ada'); + const messageStore = new InMemoryMessageStore(); + await messageStore.create({ + id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + accountId: 'acc', + name: 'Ada', + text: 'hi', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + }); + const app = new Hono().route( + '/messages', + messagesRoutes({ + store: messageStore, + authStore, + now, + nostrKek: kek, + postLimiter: new PostRateLimiter(), + invoiceLimiter: new InvoiceRateLimiter(), + }), + ); + const res = await app.request('/messages/cccccccc-cccc-4ccc-8ccc-cccccccccccc/invoice', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ sats: 21 }), + }); + expect(res.status).toBe(400); + const attempts = await messageStore.listInvoiceAttempts(10); + expect(attempts).toHaveLength(1); + expect(attempts[0]?.result).toBe('no_event'); + expect(attempts[0]?.httpStatus).toBe(400); + }); + + it('still returns 200 when recordInvoiceAttempt throws after LNURL ok', async () => { + const { parseNostrKek } = await import('@/lib/nostr/kek'); + const { ensureAccountNostrKey } = await import('@/lib/nostr/keys'); + const kek = parseNostrKek('11'.repeat(32)); + const authStore = await namedStore('Ada'); + const account = await authStore.getAccount('acc'); + expect(account).toBeDefined(); + if (account === undefined) { + throw new Error('expected account'); + } + await authStore.updateAccount({ + ...account, + lightningAddress: 'ada@walletofsatoshi.com', + }); + await ensureAccountNostrKey(authStore, 'acc', kek); + const base = new InMemoryMessageStore(); + await base.create({ + id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + accountId: 'acc', + name: 'Ada', + text: 'hi', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + eventId: 'ee'.repeat(32), + }); + const store: MessageStore = { + listLatest: (limit) => base.listLatest(limit), + create: (row, photo) => base.create(row, photo), + getPhoto: (id) => base.getPhoto(id), + getById: (id) => base.getById(id), + getByEventId: (id) => base.getByEventId(id), + claimUnsigned: (...args) => base.claimUnsigned(...args), + claimUnpublished: (...args) => base.claimUnpublished(...args), + listPendingSigned: (limit) => base.listPendingSigned(limit), + listSignedMissingPhoto: (limit) => base.listSignedMissingPhoto(limit), + clearSignedEvent: (...args) => base.clearSignedEvent(...args), + resetSignedEvent: (...args) => base.resetSignedEvent(...args), + updateSignedEvent: (...args) => base.updateSignedEvent(...args), + updatePublishState: (...args) => base.updatePublishState(...args), + addSats: (...args) => base.addSats(...args), + recordZapReceipt: (...args) => base.recordZapReceipt(...args), + recordInvoiceAttempt: async () => { + throw new Error('persist boom'); + }, + listInvoiceAttempts: (limit) => base.listInvoiceAttempts(limit), + recordZapIngest: (row) => base.recordZapIngest(row), + listZapIngests: (limit) => base.listZapIngests(limit), + }; + const fetchImpl = async (input: string | URL | Request): Promise => { + const url = String(input); + if (url.includes('/.well-known/lnurlp/')) { + return new Response( + JSON.stringify({ + callback: 'https://walletofsatoshi.com/lnurlp/callback', + minSendable: 1000, + maxSendable: 10_000_000_000, + allowsNostr: true, + nostrPubkey: 'aa'.repeat(32), + }), + { headers: { 'content-type': 'application/json' } }, + ); + } + return new Response(JSON.stringify({ pr: 'lnbc21n1test' }), { + headers: { 'content-type': 'application/json' }, + }); + }; + const app = new Hono().route( + '/messages', + messagesRoutes({ + store, + authStore, + now, + nostrKek: kek, + fetchImpl, + postLimiter: new PostRateLimiter(), + invoiceLimiter: new InvoiceRateLimiter(), + }), + ); + const res = await app.request('/messages/dddddddd-dddd-4ddd-8ddd-dddddddddddd/invoice', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ sats: 21 }), + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ pr: 'lnbc21n1test', amountSats: 21 }); + expect(parsedEvents(warn).some((e) => e['event'] === 'message.invoice.record_failed')).toBe( + true, + ); + }); + + it('persists sign_failed when signing throws', async () => { + const { parseNostrKek } = await import('@/lib/nostr/kek'); + const { ensureAccountNostrKey } = await import('@/lib/nostr/keys'); + const signMod = await import('@/lib/nostr/sign'); + const kek = parseNostrKek('11'.repeat(32)); + const authStore = await namedStore('Ada'); + const account = await authStore.getAccount('acc'); + expect(account).toBeDefined(); + if (account === undefined) { + throw new Error('expected account'); + } + await authStore.updateAccount({ + ...account, + lightningAddress: 'ada@walletofsatoshi.com', + }); + await ensureAccountNostrKey(authStore, 'acc', kek); + const messageStore = new InMemoryMessageStore(); + await messageStore.create({ + id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', + accountId: 'acc', + name: 'Ada', + text: 'hi', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + eventId: 'ee'.repeat(32), + }); + const spy = vi.spyOn(signMod, 'signEventForAccount').mockRejectedValue(new Error('sign boom')); + try { + const app = new Hono().route( + '/messages', + messagesRoutes({ + store: messageStore, + authStore, + now, + nostrKek: kek, + postLimiter: new PostRateLimiter(), + invoiceLimiter: new InvoiceRateLimiter(), + }), + ); + const res = await app.request('/messages/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee/invoice', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ sats: 21 }), + }); + expect(res.status).toBe(503); + const attempts = await messageStore.listInvoiceAttempts(10); + expect(attempts[0]?.result).toBe('sign_failed'); + expect(attempts[0]?.httpStatus).toBe(503); + } finally { + spy.mockRestore(); + } + }); + + it('persists ok path with null zapRequest when the signed event is not an object', async () => { + const { parseNostrKek } = await import('@/lib/nostr/kek'); + const { ensureAccountNostrKey } = await import('@/lib/nostr/keys'); + const signMod = await import('@/lib/nostr/sign'); + const kek = parseNostrKek('11'.repeat(32)); + const authStore = await namedStore('Ada'); + const account = await authStore.getAccount('acc'); + expect(account).toBeDefined(); + if (account === undefined) { + throw new Error('expected account'); + } + await authStore.updateAccount({ + ...account, + lightningAddress: 'ada@walletofsatoshi.com', + }); + await ensureAccountNostrKey(authStore, 'acc', kek); + const messageStore = new InMemoryMessageStore(); + await messageStore.create({ + id: 'ffffffff-ffff-4fff-8fff-ffffffffffff', + accountId: 'acc', + name: 'Ada', + text: 'hi', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + eventId: 'ee'.repeat(32), + }); + const spy = vi + .spyOn(signMod, 'signEventForAccount') + .mockResolvedValue( + null as unknown as Awaited>, + ); + const fetchImpl = async (input: string | URL | Request): Promise => { + const url = String(input); + if (url.includes('/.well-known/lnurlp/')) { + return new Response( + JSON.stringify({ + callback: 'https://walletofsatoshi.com/lnurlp/callback', + minSendable: 1000, + maxSendable: 100000000000, + allowsNostr: true, + nostrPubkey: 'be1d89794bf92de5dd64c1e60f6a2c70c140abac9932418fee30c5c637fe9479', + }), + { status: 200 }, + ); + } + return new Response(JSON.stringify({ pr: 'lnbc21n1test' }), { status: 200 }); + }; + try { + const app = new Hono().route( + '/messages', + messagesRoutes({ + store: messageStore, + authStore, + now, + nostrKek: kek, + fetchImpl, + postLimiter: new PostRateLimiter(), + invoiceLimiter: new InvoiceRateLimiter(), + }), + ); + const res = await app.request('/messages/ffffffff-ffff-4fff-8fff-ffffffffffff/invoice', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ sats: 21 }), + }); + expect(res.status).toBe(200); + const attempts = await messageStore.listInvoiceAttempts(10); + expect(attempts[0]?.result).toBe('ok'); + expect(attempts[0]?.zapRequest).toBeNull(); + } finally { + spy.mockRestore(); + } + }); }); describe('GET /messages/:id/photo', () => { diff --git a/src/lib/bolt11.ts b/src/lib/bolt11.ts index b90704d3..7d4f2b9a 100644 --- a/src/lib/bolt11.ts +++ b/src/lib/bolt11.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import * as bolt11 from 'light-bolt11-decoder'; /** @@ -10,6 +11,22 @@ export interface DecodedBolt11 { amountMsat: number; } +/** + * Operator-facing BOLT11 fields (description vs description_hash for NIP-57). + */ +export interface InspectedBolt11 { + /** 32-byte payment hash, lowercase hex. */ + paymentHash: string; + /** Invoice amount in millisatoshis. */ + amountMsat: number; + /** Plaintext description when present (not description_hash). */ + description: string | null; + /** 32-byte description hash, lowercase hex, when present. */ + descriptionHash: string | null; + /** Expiry from the invoice, seconds, when present. */ + expirySeconds: number | null; +} + /** One tagged section from `light-bolt11-decoder`. */ interface Bolt11Section { name?: unknown; @@ -27,27 +44,31 @@ function libraryDecode(pr: string): { sections?: Bolt11Section[] } { } /** - * Decode a BOLT11 payment request into payment hash and amount. + * Decode sections with optional test inject; `null` when the library throws. * - * Zero-amount invoices and malformed strings yield `null`. The caller maps - * that to a 502 so provider failures stay collapsed. - * - * @param pr - BOLT11 string from the LNURL-pay callback. - * @param decodeImpl - Optional decoder (tests inject a fake; production uses the library). - * @returns Hash + amount, or `null` when decode fails. + * @param pr - BOLT11 string. + * @param decodeImpl - Optional decoder. + * @returns Sections, or `null` on failure. */ -export function decodeBolt11( +function decodeSections( pr: string, decodeImpl?: (invoice: string) => { sections?: Bolt11Section[] }, -): DecodedBolt11 | null { - let sections: Bolt11Section[]; +): Bolt11Section[] | null { try { const decoded = (decodeImpl ?? libraryDecode)(pr); - sections = decoded.sections ?? []; + return decoded.sections ?? []; } catch { return null; } +} +/** + * Read payment_hash + positive amount from sections, or `null`. + * + * @param sections - Decoded tagged sections. + * @returns Hash and amount, or `null`. + */ +function paymentHashAndAmount(sections: Bolt11Section[]): DecodedBolt11 | null { const hashSection = sections.find((s) => s.name === 'payment_hash'); const amountSection = sections.find((s) => s.name === 'amount'); if (hashSection === undefined || typeof hashSection.value !== 'string') { @@ -66,3 +87,97 @@ export function decodeBolt11( } return { paymentHash, amountMsat }; } + +/** + * Decode a BOLT11 payment request into payment hash and amount. + * + * Zero-amount invoices and malformed strings yield `null`. The caller maps + * that to a 502 so provider failures stay collapsed. + * + * @param pr - BOLT11 string from the LNURL-pay callback. + * @param decodeImpl - Optional decoder (tests inject a fake; production uses the library). + * @returns Hash + amount, or `null` when decode fails. + */ +export function decodeBolt11( + pr: string, + decodeImpl?: (invoice: string) => { sections?: Bolt11Section[] }, +): DecodedBolt11 | null { + const sections = decodeSections(pr, decodeImpl); + if (sections === null) { + return null; + } + return paymentHashAndAmount(sections); +} + +/** + * Inspect a BOLT11 for payment hash, amount, and description fields. + * + * Malformed invoices yield `null`. Does not change {@link decodeBolt11}. + * + * @param pr - BOLT11 string. + * @param decodeImpl - Optional decoder (tests inject a fake). + * @returns Inspected fields, or `null` when decode fails. + */ +export function inspectBolt11( + pr: string, + decodeImpl?: (invoice: string) => { sections?: Bolt11Section[] }, +): InspectedBolt11 | null { + const sections = decodeSections(pr, decodeImpl); + if (sections === null) { + return null; + } + const core = paymentHashAndAmount(sections); + if (core === null) { + return null; + } + + const descriptionSection = sections.find((s) => s.name === 'description'); + const description = + descriptionSection !== undefined && typeof descriptionSection.value === 'string' + ? descriptionSection.value + : null; + + const hashSection = sections.find((s) => s.name === 'description_hash'); + let descriptionHash: string | null = null; + if (hashSection !== undefined && typeof hashSection.value === 'string') { + const hex = hashSection.value.toLowerCase(); + if (/^[0-9a-f]{64}$/.test(hex)) { + descriptionHash = hex; + } + } + + const expirySection = sections.find((s) => s.name === 'expiry'); + let expirySeconds: number | null = null; + if (expirySection !== undefined) { + const expiry = Number(expirySection.value); + if (Number.isInteger(expiry) && expiry >= 0) { + expirySeconds = expiry; + } + } + + return { + paymentHash: core.paymentHash, + amountMsat: core.amountMsat, + description, + descriptionHash, + expirySeconds, + }; +} + +/** + * NIP-57 invoice: description_hash equals sha256(utf8(zap request JSON)). + * + * @param descriptionHash - Lowercase hex hash from the invoice, or null. + * @param zapRequestJson - Exact JSON string sent as `nostr=`, or null. + * @returns Whether the invoice commits to that zap request. + */ +export function isNip57Invoice( + descriptionHash: string | null, + zapRequestJson: string | null, +): boolean { + if (descriptionHash === null || zapRequestJson === null) { + return false; + } + const digest = createHash('sha256').update(zapRequestJson, 'utf8').digest('hex'); + return digest === descriptionHash; +} diff --git a/src/lib/message-store.ts b/src/lib/message-store.ts index 395763df..02385ea4 100644 --- a/src/lib/message-store.ts +++ b/src/lib/message-store.ts @@ -143,6 +143,64 @@ export interface MessageStore { * duplicate receipt id (no second add). */ recordZapReceipt(receiptEventId: string, messageId: string, sats: number): Promise; + + /** Append one POST /messages/:id/invoice attempt (success or failure). */ + recordInvoiceAttempt(row: MessageInvoiceAttempt): Promise; + + /** Newest invoice attempts first, capped at `limit`. */ + listInvoiceAttempts(limit: number): Promise; + + /** Append one kind:9735 ingest decision (indexed or rejected). */ + recordZapIngest(row: ZapIngestRow): Promise; + + /** Newest zap ingest rows first, capped at `limit`. */ + listZapIngests(limit: number): Promise; +} + +/** Outcome of POST /messages/:id/invoice after auth. */ +export type MessageInvoiceResult = + | 'ok' + | 'noZap' + | 'unreachable' + | 'no_event' + | 'no_author' + | 'no_key' + | 'sign_failed' + | 'rate_limited' + | 'bad_body' + | 'not_found'; + +/** One persisted invoice attempt for operator debug. */ +export interface MessageInvoiceAttempt { + id: string; + createdAt: Date; + messageId: string; + payerAccountId: string; + authorAccountId: string; + amountSats: number; + lightningAddress: string | null; + zapRequest: Record | null; + result: MessageInvoiceResult; + httpStatus: number; + pr: string | null; + paymentHash: string | null; + description: string | null; + descriptionHash: string | null; + isNip57Invoice: boolean; +} + +/** One persisted kind:9735 ingest decision for operator debug. */ +export interface ZapIngestRow { + id: string; + createdAt: Date; + receiptId: string; + noteEventId: string | null; + messageId: string | null; + outcome: 'indexed' | 'rejected'; + reason: string | null; + amountSats: number | null; + receiptPubkey: string | null; + receipt: Record; } /** Idempotent DDL for the forum table (matches `docs/schema/message.sql`). */ @@ -173,6 +231,43 @@ export const MESSAGE_SCHEMA_SQL: readonly string[] = [ message_id uuid NOT NULL REFERENCES message (id), sats bigint NOT NULL )`, + `CREATE TABLE IF NOT EXISTS message_invoice ( + id uuid PRIMARY KEY, + created_at timestamptz NOT NULL, + message_id uuid NOT NULL, + payer_account_id uuid NOT NULL, + author_account_id uuid NOT NULL, + amount_sats bigint NOT NULL, + lightning_address text, + zap_request jsonb, + result text NOT NULL, + http_status integer NOT NULL, + pr text, + payment_hash text, + description text, + description_hash text, + is_nip57_invoice boolean NOT NULL DEFAULT false +)`, + `CREATE INDEX IF NOT EXISTS message_invoice_created_at_idx + ON message_invoice (created_at DESC, id DESC)`, + `CREATE INDEX IF NOT EXISTS message_invoice_message_id_idx + ON message_invoice (message_id, created_at DESC)`, + `CREATE TABLE IF NOT EXISTS nostr_zap_ingest ( + id uuid PRIMARY KEY, + created_at timestamptz NOT NULL, + receipt_id text NOT NULL, + note_event_id text, + message_id uuid, + outcome text NOT NULL, + reason text, + amount_sats bigint, + receipt_pubkey text, + receipt jsonb NOT NULL +)`, + `CREATE INDEX IF NOT EXISTS nostr_zap_ingest_receipt_id_idx + ON nostr_zap_ingest (receipt_id)`, + `CREATE INDEX IF NOT EXISTS nostr_zap_ingest_created_at_idx + ON nostr_zap_ingest (created_at DESC, id DESC)`, ]; /** @@ -202,6 +297,24 @@ function copyRow(row: MessageRow): MessageRow { }; } +/** Copy an invoice attempt so callers cannot mutate store internals. */ +function copyInvoiceAttempt(row: MessageInvoiceAttempt): MessageInvoiceAttempt { + return { + ...row, + createdAt: new Date(row.createdAt.getTime()), + zapRequest: row.zapRequest === null ? null : { ...row.zapRequest }, + }; +} + +/** Copy a zap ingest row so callers cannot mutate store internals. */ +function copyZapIngest(row: ZapIngestRow): ZapIngestRow { + return { + ...row, + createdAt: new Date(row.createdAt.getTime()), + receipt: { ...row.receipt }, + }; +} + /** * Process-local {@link MessageStore}. Used in tests and when no database URL * is configured — the process still boots. Photos live in a private map, not @@ -211,6 +324,8 @@ export class InMemoryMessageStore implements MessageStore { readonly #rows: MessageRow[]; readonly #receiptIds = new Set(); readonly #photos = new Map(); + readonly #invoiceAttempts: MessageInvoiceAttempt[] = []; + readonly #zapIngests: ZapIngestRow[] = []; /** * @param seed - Optional seed rows; copied into private storage. Seeded rows @@ -409,6 +524,38 @@ export class InMemoryMessageStore implements MessageStore { return true; } + recordInvoiceAttempt(row: MessageInvoiceAttempt): Promise { + this.#invoiceAttempts.push(copyInvoiceAttempt(row)); + return Promise.resolve(); + } + + listInvoiceAttempts(limit: number): Promise { + const sorted = [...this.#invoiceAttempts].sort((a, b) => { + const byTime = b.createdAt.getTime() - a.createdAt.getTime(); + if (byTime !== 0) { + return byTime; + } + return b.id.localeCompare(a.id); + }); + return Promise.resolve(sorted.slice(0, limit).map((row) => copyInvoiceAttempt(row))); + } + + recordZapIngest(row: ZapIngestRow): Promise { + this.#zapIngests.push(copyZapIngest(row)); + return Promise.resolve(); + } + + listZapIngests(limit: number): Promise { + const sorted = [...this.#zapIngests].sort((a, b) => { + const byTime = b.createdAt.getTime() - a.createdAt.getTime(); + if (byTime !== 0) { + return byTime; + } + return b.id.localeCompare(a.id); + }); + return Promise.resolve(sorted.slice(0, limit).map((row) => copyZapIngest(row))); + } + #claim( predicate: (row: MessageRow) => boolean, limit: number, @@ -732,6 +879,83 @@ export class PostgresMessageStore implements MessageStore { return inserted[0] !== undefined; } + async recordInvoiceAttempt(row: MessageInvoiceAttempt): Promise { + await this.#sql.execute( + `INSERT INTO message_invoice ( + id, created_at, message_id, payer_account_id, author_account_id, + amount_sats, lightning_address, zap_request, result, http_status, + pr, payment_hash, description, description_hash, is_nip57_invoice + ) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9,$10,$11,$12,$13,$14,$15 + )`, + [ + row.id, + row.createdAt, + row.messageId, + row.payerAccountId, + row.authorAccountId, + row.amountSats, + row.lightningAddress, + row.zapRequest === null ? null : JSON.stringify(row.zapRequest), + row.result, + row.httpStatus, + row.pr, + row.paymentHash, + row.description, + row.descriptionHash, + row.isNip57Invoice, + ], + ); + } + + async listInvoiceAttempts(limit: number): Promise { + const rows = await this.#sql.query( + `SELECT id, created_at, message_id, payer_account_id, author_account_id, + amount_sats, lightning_address, zap_request, result, http_status, + pr, payment_hash, description, description_hash, is_nip57_invoice + FROM message_invoice + ORDER BY created_at DESC, id DESC + LIMIT $1`, + [limit], + ); + return rows.map((row) => mapInvoiceAttemptRow(row)); + } + + async recordZapIngest(row: ZapIngestRow): Promise { + await this.#sql.execute( + `INSERT INTO nostr_zap_ingest ( + id, created_at, receipt_id, note_event_id, message_id, + outcome, reason, amount_sats, receipt_pubkey, receipt + ) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb + )`, + [ + row.id, + row.createdAt, + row.receiptId, + row.noteEventId, + row.messageId, + row.outcome, + row.reason, + row.amountSats, + row.receiptPubkey, + JSON.stringify(row.receipt), + ], + ); + } + + async listZapIngests(limit: number): Promise { + const rows = await this.#sql.query( + `SELECT id, created_at, receipt_id, note_event_id, message_id, + outcome, reason, amount_sats, receipt_pubkey, receipt + FROM nostr_zap_ingest + ORDER BY created_at DESC, id DESC + LIMIT $1`, + [limit], + ); + return rows.map((row) => mapZapIngestRow(row)); + } + /** * Load photo bytes for a message id. * @@ -756,3 +980,96 @@ export class PostgresMessageStore implements MessageStore { }; } } + +/** SQL row shape for `message_invoice`. */ +interface MessageInvoiceSqlRow { + id: string; + created_at: Date | string; + message_id: string; + payer_account_id: string; + author_account_id: string; + amount_sats: string | number; + lightning_address: string | null; + zap_request: Record | string | null; + result: string; + http_status: number; + pr: string | null; + payment_hash: string | null; + description: string | null; + description_hash: string | null; + is_nip57_invoice: boolean | number | string | null; +} + +/** SQL row shape for `nostr_zap_ingest`. */ +interface ZapIngestSqlRow { + id: string; + created_at: Date | string; + receipt_id: string; + note_event_id: string | null; + message_id: string | null; + outcome: string; + reason: string | null; + amount_sats: string | number | null; + receipt_pubkey: string | null; + receipt: Record | string; +} + +/** Parse jsonb that may arrive as object or JSON string. */ +function parseJsonObject( + value: Record | string | null | undefined, +): Record | null { + if (value === null || value === undefined) { + return null; + } + if (typeof value === 'string') { + try { + const parsed: unknown = JSON.parse(value); + if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record; + } + return null; + } catch { + return null; + } + } + return { ...value }; +} + +/** Map a `message_invoice` SQL row. */ +function mapInvoiceAttemptRow(row: MessageInvoiceSqlRow): MessageInvoiceAttempt { + const result = row.result as MessageInvoiceResult; + return { + id: row.id, + createdAt: row.created_at instanceof Date ? row.created_at : new Date(row.created_at), + messageId: row.message_id, + payerAccountId: row.payer_account_id, + authorAccountId: row.author_account_id, + amountSats: Number(row.amount_sats), + lightningAddress: row.lightning_address, + zapRequest: parseJsonObject(row.zap_request), + result, + httpStatus: row.http_status, + pr: row.pr, + paymentHash: row.payment_hash, + description: row.description, + descriptionHash: row.description_hash, + isNip57Invoice: Boolean(row.is_nip57_invoice), + }; +} + +/** Map a `nostr_zap_ingest` SQL row. */ +function mapZapIngestRow(row: ZapIngestSqlRow): ZapIngestRow { + const receipt = parseJsonObject(row.receipt) ?? {}; + return { + id: row.id, + createdAt: row.created_at instanceof Date ? row.created_at : new Date(row.created_at), + receiptId: row.receipt_id, + noteEventId: row.note_event_id, + messageId: row.message_id, + outcome: row.outcome === 'indexed' ? 'indexed' : 'rejected', + reason: row.reason, + amountSats: row.amount_sats === null ? null : Number(row.amount_sats), + receiptPubkey: row.receipt_pubkey, + receipt, + }; +} diff --git a/src/lib/nostr/zap-index.ts b/src/lib/nostr/zap-index.ts index f8006d37..3098d9bc 100644 --- a/src/lib/nostr/zap-index.ts +++ b/src/lib/nostr/zap-index.ts @@ -3,7 +3,7 @@ import { decodeBolt11 } from '@/lib/bolt11'; import { LN_ADDRESS_CACHE_TTL_MS } from '@/lib/config'; import { logEvent } from '@/lib/log'; import { MESSAGE_LIST_LIMIT } from '@/lib/message'; -import type { MessageStore } from '@/lib/message-store'; +import type { MessageStore, ZapIngestRow } from '@/lib/message-store'; import type { FetchFn } from '@/lib/lnurlp'; import { resolveLnurlp } from '@/lib/lnurlp'; import type { NostrEventFrame, NostrQuerier } from '@/lib/nostr/query'; @@ -57,6 +57,62 @@ function defaultVerifyReceipt(event: NostrEventFrame): boolean { } } +/** Project a queried frame to the JSON object stored on ingest rows. */ +function receiptFrame(event: NostrEventFrame): Record { + return { + id: event.id, + pubkey: event.pubkey, + kind: event.kind, + tags: event.tags, + created_at: event.created_at, + content: event.content ?? '', + sig: event.sig ?? '', + }; +} + +/** + * Persist an ingest decision without failing the tick. + * + * @param store - Forum store. + * @param row - Ingest row. + */ +async function persistZapIngest(store: MessageStore, row: ZapIngestRow): Promise { + try { + await store.recordZapIngest(row); + } catch { + logEvent('nostr.zap.ingest.record_failed'); + } +} + +/** + * Build a zap ingest row for an indexed or rejected decision. + * + * @param args - Outcome fields plus the receipt frame. + */ +function zapIngestRow(args: { + receiptId: string; + noteEventId: string | null; + messageId: string | null; + outcome: 'indexed' | 'rejected'; + reason: string | null; + amountSats: number | null; + receiptPubkey: string | null; + receipt: Record; +}): ZapIngestRow { + return { + id: crypto.randomUUID(), + createdAt: new Date(), + receiptId: args.receiptId, + noteEventId: args.noteEventId, + messageId: args.messageId, + outcome: args.outcome, + reason: args.reason, + amountSats: args.amountSats, + receiptPubkey: args.receiptPubkey, + receipt: args.receipt, + }; +} + /** * Validate a kind:9735 receipt against the author's LNURL `nostrPubkey` * and add sats to the message once via durable receipt storage. @@ -77,20 +133,88 @@ export async function indexZapReceipt(args: { receipt: ZapReceipt; providerPubkey: string; amountSats: number; + /** Full kind:9735 frame for debug ingest rows. */ + receiptEvent?: Record; + noteEventId?: string | null; }): Promise { + const receipt = + args.receiptEvent ?? + ({ + id: args.receipt.id, + pubkey: args.receipt.pubkey, + kind: 9735, + tags: args.receipt.tags, + created_at: 0, + content: '', + sig: '', + } satisfies Record); + const noteEventId = args.noteEventId ?? null; + if (args.receipt.pubkey.toLowerCase() !== args.providerPubkey.toLowerCase()) { logEvent('nostr.zap.rejected', { reason: 'pubkey' }); + await persistZapIngest( + args.store, + zapIngestRow({ + receiptId: args.receipt.id, + noteEventId, + messageId: args.messageId, + outcome: 'rejected', + reason: 'pubkey', + amountSats: args.amountSats, + receiptPubkey: args.receipt.pubkey, + receipt, + }), + ); return false; } if (!Number.isInteger(args.amountSats) || args.amountSats <= 0) { logEvent('nostr.zap.rejected', { reason: 'amount' }); + await persistZapIngest( + args.store, + zapIngestRow({ + receiptId: args.receipt.id, + noteEventId, + messageId: args.messageId, + outcome: 'rejected', + reason: 'amount', + amountSats: args.amountSats, + receiptPubkey: args.receipt.pubkey, + receipt, + }), + ); return false; } const added = await args.store.recordZapReceipt(args.receipt.id, args.messageId, args.amountSats); if (!added) { + await persistZapIngest( + args.store, + zapIngestRow({ + receiptId: args.receipt.id, + noteEventId, + messageId: args.messageId, + outcome: 'rejected', + reason: 'duplicate', + amountSats: args.amountSats, + receiptPubkey: args.receipt.pubkey, + receipt, + }), + ); return false; } logEvent('nostr.zap.indexed', { messageId: args.messageId, sats: args.amountSats }); + await persistZapIngest( + args.store, + zapIngestRow({ + receiptId: args.receipt.id, + noteEventId, + messageId: args.messageId, + outcome: 'indexed', + reason: null, + amountSats: args.amountSats, + receiptPubkey: args.receipt.pubkey, + receipt, + }), + ); return true; } @@ -145,6 +269,22 @@ export async function indexOpenZapReceipts(args: { await ingestOneReceipt(event, { ...args, verifyReceipt }); } catch { logEvent('nostr.zap.rejected', { reason: 'error' }); + if (typeof event.id === 'string' && event.id !== '') { + await persistZapIngest( + args.store, + zapIngestRow({ + receiptId: event.id, + noteEventId: null, + messageId: null, + outcome: 'rejected', + reason: 'error', + amountSats: null, + /* v8 ignore next -- ingestOneReceipt returns unless pubkey is a string */ + receiptPubkey: typeof event.pubkey === 'string' ? event.pubkey : null, + receipt: receiptFrame(event), + }), + ); + } } } } @@ -173,10 +313,40 @@ async function ingestOneReceipt( return; } if (typeof event.pubkey !== 'string' || event.pubkey === '') { + logEvent('nostr.zap.rejected', { reason: 'pubkey' }); + await persistZapIngest( + args.store, + zapIngestRow({ + receiptId: event.id, + noteEventId: null, + messageId: null, + outcome: 'rejected', + reason: 'pubkey', + amountSats: null, + receiptPubkey: null, + receipt: receiptFrame(event), + }), + ); return; } + + const receipt = receiptFrame(event); + if (!args.verifyReceipt(event)) { logEvent('nostr.zap.rejected', { reason: 'sig' }); + await persistZapIngest( + args.store, + zapIngestRow({ + receiptId: event.id, + noteEventId: null, + messageId: null, + outcome: 'rejected', + reason: 'sig', + amountSats: null, + receiptPubkey: event.pubkey, + receipt, + }), + ); return; } @@ -184,11 +354,37 @@ async function ingestOneReceipt( const noteEventId = eTag?.[1]; if (noteEventId === undefined || noteEventId === '') { logEvent('nostr.zap.rejected', { reason: 'event' }); + await persistZapIngest( + args.store, + zapIngestRow({ + receiptId: event.id, + noteEventId: null, + messageId: null, + outcome: 'rejected', + reason: 'event', + amountSats: null, + receiptPubkey: event.pubkey, + receipt, + }), + ); return; } const row = await args.store.getByEventId(noteEventId); if (row === undefined) { logEvent('nostr.zap.rejected', { reason: 'event' }); + await persistZapIngest( + args.store, + zapIngestRow({ + receiptId: event.id, + noteEventId, + messageId: null, + outcome: 'rejected', + reason: 'event', + amountSats: null, + receiptPubkey: event.pubkey, + receipt, + }), + ); return; } @@ -196,16 +392,55 @@ async function ingestOneReceipt( const pr = bolt11Tag?.[1]; if (pr === undefined || pr === '') { logEvent('nostr.zap.rejected', { reason: 'bolt11' }); + await persistZapIngest( + args.store, + zapIngestRow({ + receiptId: event.id, + noteEventId, + messageId: row.id, + outcome: 'rejected', + reason: 'bolt11', + amountSats: null, + receiptPubkey: event.pubkey, + receipt, + }), + ); return; } const decoded = decodeBolt11(pr); if (decoded === null) { logEvent('nostr.zap.rejected', { reason: 'bolt11' }); + await persistZapIngest( + args.store, + zapIngestRow({ + receiptId: event.id, + noteEventId, + messageId: row.id, + outcome: 'rejected', + reason: 'bolt11', + amountSats: null, + receiptPubkey: event.pubkey, + receipt, + }), + ); return; } const amountSats = Math.floor(decoded.amountMsat / 1000); if (amountSats < 1) { logEvent('nostr.zap.rejected', { reason: 'amount' }); + await persistZapIngest( + args.store, + zapIngestRow({ + receiptId: event.id, + noteEventId, + messageId: row.id, + outcome: 'rejected', + reason: 'amount', + amountSats, + receiptPubkey: event.pubkey, + receipt, + }), + ); return; } @@ -213,6 +448,19 @@ async function ingestOneReceipt( const address = author?.lightningAddress; if (address === undefined || address === null || address.trim() === '') { logEvent('nostr.zap.rejected', { reason: 'address' }); + await persistZapIngest( + args.store, + zapIngestRow({ + receiptId: event.id, + noteEventId, + messageId: row.id, + outcome: 'rejected', + reason: 'address', + amountSats, + receiptPubkey: event.pubkey, + receipt, + }), + ); return; } @@ -223,6 +471,19 @@ async function ingestOneReceipt( }); if (providerPubkey === null) { logEvent('nostr.zap.rejected', { reason: 'provider' }); + await persistZapIngest( + args.store, + zapIngestRow({ + receiptId: event.id, + noteEventId, + messageId: row.id, + outcome: 'rejected', + reason: 'provider', + amountSats, + receiptPubkey: event.pubkey, + receipt, + }), + ); return; } @@ -232,6 +493,8 @@ async function ingestOneReceipt( receipt: { id: event.id, pubkey: event.pubkey, tags: event.tags }, providerPubkey, amountSats, + receiptEvent: receipt, + noteEventId, }); } diff --git a/src/routes/debug-payments.ts b/src/routes/debug-payments.ts new file mode 100644 index 00000000..0e2bcc74 --- /dev/null +++ b/src/routes/debug-payments.ts @@ -0,0 +1,98 @@ +import { Hono } from 'hono'; +import { bearerMatchesDebugToken } from '@/lib/debug-token'; +import { logEvent } from '@/lib/log'; +import type { MessageInvoiceAttempt, MessageStore, ZapIngestRow } from '@/lib/message-store'; + +/** + * Operator debug surface for forum invoice attempts and zap ingest rows. + * Authenticated by `DEBUG_TOKEN` (Bearer), not by an end-user session. + */ + +/** Collaborators the debug payment routes need. */ +export interface DebugPaymentsRouteDeps { + /** Forum persistence port. */ + store: MessageStore; + /** Configured operator token, or `undefined` when debug is disabled. */ + debugToken: string | undefined; +} + +const DEBUG_LIST_LIMIT = 200; + +/** Shared 503/401 gate matching `/debug/accounts`. */ +function gateDebugToken( + debugToken: string | undefined, + authorization: string | undefined, +): { ok: true } | { ok: false; status: 503 | 401; body: { error: string } } { + if (debugToken === undefined || debugToken.trim() === '') { + return { ok: false, status: 503, body: { error: 'Debug is not configured' } }; + } + if (!bearerMatchesDebugToken(debugToken, authorization)) { + return { ok: false, status: 401, body: { error: 'Unauthorized' } }; + } + return { ok: true }; +} + +/** Public JSON for one invoice attempt (ISO dates; no secrets). */ +function serializeInvoice(row: MessageInvoiceAttempt): Record { + return { + id: row.id, + createdAt: row.createdAt.toISOString(), + messageId: row.messageId, + payerAccountId: row.payerAccountId, + authorAccountId: row.authorAccountId, + amountSats: row.amountSats, + lightningAddress: row.lightningAddress, + zapRequest: row.zapRequest, + result: row.result, + httpStatus: row.httpStatus, + pr: row.pr, + paymentHash: row.paymentHash, + description: row.description, + descriptionHash: row.descriptionHash, + isNip57Invoice: row.isNip57Invoice, + }; +} + +/** Public JSON for one zap ingest row (ISO dates; no secrets). */ +function serializeIngest(row: ZapIngestRow): Record { + return { + id: row.id, + createdAt: row.createdAt.toISOString(), + receiptId: row.receiptId, + noteEventId: row.noteEventId, + messageId: row.messageId, + outcome: row.outcome, + reason: row.reason, + amountSats: row.amountSats, + receiptPubkey: row.receiptPubkey, + receipt: row.receipt, + }; +} + +/** + * Build the `/debug` payment debug routes (`/invoices`, `/zap-ingests`). + * + * @param deps - Message store and optional debug token. + * @returns A Hono app exposing `GET /invoices` and `GET /zap-ingests`. + */ +export function debugPaymentsRoutes(deps: DebugPaymentsRouteDeps): Hono { + return new Hono() + .get('/invoices', async (c) => { + const gate = gateDebugToken(deps.debugToken, c.req.header('authorization')); + if (!gate.ok) { + return c.json(gate.body, gate.status); + } + const invoices = await deps.store.listInvoiceAttempts(DEBUG_LIST_LIMIT); + logEvent('debug.invoices.listed', { count: invoices.length }); + return c.json({ invoices: invoices.map(serializeInvoice) }, 200); + }) + .get('/zap-ingests', async (c) => { + const gate = gateDebugToken(deps.debugToken, c.req.header('authorization')); + if (!gate.ok) { + return c.json(gate.body, gate.status); + } + const ingests = await deps.store.listZapIngests(DEBUG_LIST_LIMIT); + logEvent('debug.zap_ingests.listed', { count: ingests.length }); + return c.json({ ingests: ingests.map(serializeIngest) }, 200); + }); +} diff --git a/src/routes/messages.ts b/src/routes/messages.ts index ffeaabb4..dbd0ebd8 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono'; import { z } from 'zod'; import { resolveSession } from '@/lib/auth/service'; import type { Account, AuthStore } from '@/lib/auth/store'; +import { inspectBolt11, isNip57Invoice } from '@/lib/bolt11'; import { GIFT_INVOICE_MAX_MSAT, GIFT_INVOICE_MIN_MSAT } from '@/lib/config'; import { logEvent } from '@/lib/log'; import type { FetchFn } from '@/lib/lnurlp'; @@ -15,7 +16,11 @@ import { type ForumPhoto, type MessageRow, } from '@/lib/message'; -import type { MessageStore } from '@/lib/message-store'; +import type { + MessageInvoiceAttempt, + MessageInvoiceResult, + MessageStore, +} from '@/lib/message-store'; import { ensureAccountNostrKey } from '@/lib/nostr/keys'; import { InvoiceRateLimiter, PostRateLimiter } from '@/lib/nostr/rate-limit'; import { resolveZapRelays } from '@/lib/nostr/relays'; @@ -23,6 +28,65 @@ import { signEventForAccount } from '@/lib/nostr/sign'; import { buildZapRequest } from '@/lib/nostr/zap-request'; import { bearerToken } from '@/routes/me'; +/** Placeholder author id when the message/author is unknown at persist time. */ +const UNKNOWN_ACCOUNT_ID = '00000000-0000-0000-0000-000000000000'; + +/** + * Persist an invoice attempt without failing the HTTP payment response. + * + * @param store - Forum store. + * @param row - Attempt row. + */ +async function persistInvoiceAttempt( + store: MessageStore, + row: MessageInvoiceAttempt, +): Promise { + try { + await store.recordInvoiceAttempt(row); + } catch { + logEvent('message.invoice.record_failed'); + } +} + +/** + * Build an invoice-attempt row (caller sets result-specific fields). + * + * @param args - Common fields for every attempt after auth. + */ +function invoiceAttemptBase(args: { + messageId: string; + payerAccountId: string; + authorAccountId: string; + amountSats: number; + lightningAddress: string | null; + zapRequest: Record | null; + result: MessageInvoiceResult; + httpStatus: number; + pr: string | null; + paymentHash: string | null; + description: string | null; + descriptionHash: string | null; + isNip57Invoice: boolean; +}): MessageInvoiceAttempt { + return { + id: crypto.randomUUID(), + createdAt: new Date(), + messageId: args.messageId, + payerAccountId: args.payerAccountId, + authorAccountId: args.authorAccountId, + amountSats: args.amountSats, + lightningAddress: args.lightningAddress, + zapRequest: args.zapRequest, + result: args.result, + httpStatus: args.httpStatus, + pr: args.pr, + paymentHash: args.paymentHash, + description: args.description, + descriptionHash: args.descriptionHash, + isNip57Invoice: args.isNip57Invoice, + }; +} + /** * `/messages` — signed-in member forum: list every message, post text and/or * one photo when the account has a display name, serve photo bytes publicly @@ -196,37 +260,187 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { if (account === null) { return c.json({ error: 'Unauthorized' }, 401); } + const messageIdParam = c.req.param('id'); + if (!MESSAGE_ID_RE.test(messageIdParam)) { + return c.json({ error: 'Not found' }, 404); + } const parsed = invoiceBody.safeParse(await c.req.json().catch(() => null)); if (!parsed.success) { + await persistInvoiceAttempt( + deps.store, + invoiceAttemptBase({ + messageId: messageIdParam, + payerAccountId: account.id, + authorAccountId: UNKNOWN_ACCOUNT_ID, + amountSats: 0, + lightningAddress: null, + zapRequest: null, + result: 'bad_body', + httpStatus: 400, + pr: null, + paymentHash: null, + description: null, + descriptionHash: null, + isNip57Invoice: false, + }), + ); return c.json({ error: 'Expected a JSON body with a positive "sats" integer' }, 400); } const amountMsat = parsed.data.sats * 1000; - /* v8 ignore next 3 -- zod already requires positive int; cap is extra */ + /* v8 ignore start -- zod already requires positive int; cap is extra */ if (amountMsat < GIFT_INVOICE_MIN_MSAT || amountMsat > GIFT_INVOICE_MAX_MSAT) { + await persistInvoiceAttempt( + deps.store, + invoiceAttemptBase({ + messageId: messageIdParam, + payerAccountId: account.id, + authorAccountId: UNKNOWN_ACCOUNT_ID, + amountSats: 0, + lightningAddress: null, + zapRequest: null, + result: 'bad_body', + httpStatus: 400, + pr: null, + paymentHash: null, + description: null, + descriptionHash: null, + isNip57Invoice: false, + }), + ); return c.json({ error: 'Expected a JSON body with a positive "sats" integer' }, 400); } - const row = await deps.store.getById(c.req.param('id')); + /* v8 ignore stop */ + const row = await deps.store.getById(messageIdParam); if (row === undefined) { + await persistInvoiceAttempt( + deps.store, + invoiceAttemptBase({ + messageId: messageIdParam, + payerAccountId: account.id, + authorAccountId: UNKNOWN_ACCOUNT_ID, + amountSats: parsed.data.sats, + lightningAddress: null, + zapRequest: null, + result: 'not_found', + httpStatus: 404, + pr: null, + paymentHash: null, + description: null, + descriptionHash: null, + isNip57Invoice: false, + }), + ); return c.json({ error: 'Not found' }, 404); } if (row.eventId === null) { + await persistInvoiceAttempt( + deps.store, + invoiceAttemptBase({ + messageId: row.id, + payerAccountId: account.id, + authorAccountId: row.accountId, + amountSats: parsed.data.sats, + lightningAddress: null, + zapRequest: null, + result: 'no_event', + httpStatus: 400, + pr: null, + paymentHash: null, + description: null, + descriptionHash: null, + isNip57Invoice: false, + }), + ); return c.json({ error: 'This message cannot be paid yet' }, 400); } const author = await deps.authStore.getAccount(row.accountId); if (author === undefined || author.lightningAddress === null) { + await persistInvoiceAttempt( + deps.store, + invoiceAttemptBase({ + messageId: row.id, + payerAccountId: account.id, + authorAccountId: row.accountId, + amountSats: parsed.data.sats, + lightningAddress: author?.lightningAddress ?? null, + zapRequest: null, + result: 'no_author', + httpStatus: 400, + pr: null, + paymentHash: null, + description: null, + descriptionHash: null, + isNip57Invoice: false, + }), + ); return c.json({ error: 'This message cannot be paid yet' }, 400); } const recipientPubkey = await deps.authStore.getNostrPublicKey(author.id); - /* v8 ignore next 3 -- payable notes have keys after the worker */ + /* v8 ignore start -- payable notes have keys after the worker */ if (recipientPubkey === undefined) { + await persistInvoiceAttempt( + deps.store, + invoiceAttemptBase({ + messageId: row.id, + payerAccountId: account.id, + authorAccountId: author.id, + amountSats: parsed.data.sats, + lightningAddress: author.lightningAddress, + zapRequest: null, + result: 'no_key', + httpStatus: 400, + pr: null, + paymentHash: null, + description: null, + descriptionHash: null, + isNip57Invoice: false, + }), + ); return c.json({ error: 'This message cannot be paid yet' }, 400); } + /* v8 ignore stop */ const kek = deps.nostrKek; if (kek === undefined) { + await persistInvoiceAttempt( + deps.store, + invoiceAttemptBase({ + messageId: row.id, + payerAccountId: account.id, + authorAccountId: author.id, + amountSats: parsed.data.sats, + lightningAddress: author.lightningAddress, + zapRequest: null, + result: 'no_key', + httpStatus: 503, + pr: null, + paymentHash: null, + description: null, + descriptionHash: null, + isNip57Invoice: false, + }), + ); return c.json({ error: 'Messages are unavailable' }, 503); } if (!invoiceLimiter.allow(account.id, deps.now())) { c.header('Retry-After', '10'); + await persistInvoiceAttempt( + deps.store, + invoiceAttemptBase({ + messageId: row.id, + payerAccountId: account.id, + authorAccountId: author.id, + amountSats: parsed.data.sats, + lightningAddress: author.lightningAddress, + zapRequest: null, + result: 'rate_limited', + httpStatus: 429, + pr: null, + paymentHash: null, + description: null, + descriptionHash: null, + isNip57Invoice: false, + }), + ); return c.json({ error: 'Too many payments' }, 429); } const relays = resolveZapRelays(process.env); @@ -243,18 +457,80 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { /* v8 ignore next 4 -- keygen or sign failure */ } catch { logEvent('nostr.sign.failed', { messageId: row.id }); + await persistInvoiceAttempt( + deps.store, + invoiceAttemptBase({ + messageId: row.id, + payerAccountId: account.id, + authorAccountId: author.id, + amountSats: parsed.data.sats, + lightningAddress: author.lightningAddress, + zapRequest: null, + result: 'sign_failed', + httpStatus: 503, + pr: null, + paymentHash: null, + description: null, + descriptionHash: null, + isNip57Invoice: false, + }), + ); return c.json({ error: 'Messages are unavailable' }, 503); } + const zapRequestJson = JSON.stringify(signed); + const zapRequest = + signed !== null && typeof signed === 'object' + ? (signed as unknown as Record) + : null; const zap = await requestZapInvoice({ address: author.lightningAddress, amountMsat, - zapRequestJson: JSON.stringify(signed), + zapRequestJson, fetchImpl, }); /* v8 ignore next 3 -- LNURL/zap collapsed failure */ if (!zap.ok) { + await persistInvoiceAttempt( + deps.store, + invoiceAttemptBase({ + messageId: row.id, + payerAccountId: account.id, + authorAccountId: author.id, + amountSats: parsed.data.sats, + lightningAddress: author.lightningAddress, + zapRequest, + result: zap.reason, + httpStatus: 400, + pr: null, + paymentHash: null, + description: null, + descriptionHash: null, + isNip57Invoice: false, + }), + ); return c.json({ error: 'Could not start the Bitcoin payment' }, 400); } + const inspected = inspectBolt11(zap.pr); + const description = inspected?.description ?? null; + const descriptionHash = inspected?.descriptionHash ?? null; + await persistInvoiceAttempt( + deps.store, + invoiceAttemptBase({ + messageId: row.id, + payerAccountId: account.id, + authorAccountId: author.id, + amountSats: parsed.data.sats, + lightningAddress: author.lightningAddress, + zapRequest, + result: 'ok', + httpStatus: 200, + pr: zap.pr, + paymentHash: inspected?.paymentHash ?? null, + description, + descriptionHash, + isNip57Invoice: isNip57Invoice(descriptionHash, zapRequestJson), + }), + ); return c.json({ pr: zap.pr, amountSats: zap.amountSats }, 200); }); } diff --git a/src/server.ts b/src/server.ts index 2f107a1b..cb154b09 100644 --- a/src/server.ts +++ b/src/server.ts @@ -17,6 +17,7 @@ import { invoiceRoutes } from '@/routes/invoices'; import { messagesRoutes } from '@/routes/messages'; import { contactRoutes } from '@/routes/contact'; import { debugContactsRoutes } from '@/routes/debug-contacts'; +import { debugPaymentsRoutes } from '@/routes/debug-payments'; import { InMemoryAuthStore } from '@/lib/auth/store'; import type { AuthStore } from '@/lib/auth/store'; import { InMemoryBtcUsdStore, type BtcUsdRateBook } from '@/lib/btc-usd-store'; @@ -68,8 +69,9 @@ export interface AppDeps { readBrand?: BrandReader; /** * Operator debug token (default: `process.env.DEBUG_TOKEN`). Unset or - * blank → `GET /debug/accounts`, `PATCH /debug/accounts/:id`, and - * `GET /debug/contacts` return 503. + * blank → `GET /debug/accounts`, `PATCH /debug/accounts/:id`, + * `GET /debug/contacts`, `GET /debug/invoices`, and + * `GET /debug/zap-ingests` return 503. */ debugToken?: string; /** @@ -193,6 +195,7 @@ export function createApp(deps: AppDeps = {}): Hono { ); app.route('/debug/accounts', debugRoutes({ store, debugToken })); app.route('/debug/contacts', debugContactsRoutes({ store: contactStore, debugToken })); + app.route('/debug', debugPaymentsRoutes({ store: messageStore, debugToken })); app.route('/gifts', giftsRoutes({ store: giftStore, rates: btcUsdRates, now })); app.route('/gifts/stats', giftsStatsRoutes({ store: giftStore, rates: btcUsdRates, now })); app.route( From 2fcb5487e208183f79de55e043bcc593937997bf Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Sun, 30 Aug 2026 15:17:57 +0200 Subject: [PATCH 03/12] 01a04a23 - Publish pending photo notes instead of resetting them (#71) Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- docs/handbook/functions.md | 6 +- src/__tests__/lib/message-store.test.ts | 19 ++++++ src/__tests__/lib/nostr/worker.test.ts | 84 ++++++++++++++++--------- src/lib/message-store.ts | 7 ++- src/lib/nostr/worker.ts | 28 ++++----- 5 files changed, 94 insertions(+), 50 deletions(-) diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index bcf057dc..2d10248a 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -149,7 +149,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; `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 signed rows with a photo whose kind:1 content lacks `/messages/:id/photo` (any publish state, `sats = 0`, `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; `getPhoto` loads bytes by id; `getById`; `getByEventId` (`WHERE event_id`); `claimUnsigned`/`claimUnpublished` lease rows; `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` (`sats = 0`, pending excluded so fan-out is not starved, `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. @@ -261,7 +261,7 @@ ## 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, `listPendingSigned` (pending, no `t=bitcoin`, oldest-first), `clearSignedEvent` (pending and `eventId` still matches `expectedEventId`, then nulls `eventId` / `nostrEvent` / `claimedUntil`), `listSignedMissingPhoto` (signed + photo, kind:1 content lacks `/messages/:id/photo`, oldest-first, any publish state, `sats === 0`), `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). +- **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, `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`, oldest-first, `sats === 0`, pending excluded), `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. - **Used by:** `createApp` default `messageStore`. @@ -933,7 +933,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. Signed photo posts missing the public photo URL are reset and re-signed only when `PUBLIC_BASE_URL` is set and `sats` is 0 (zapped rows keep `eventId` so receipts still resolve); an empty API base leaves them alone and still EVENT's the URL-less note. A publish snapshot whose photo note still lacks that URL is reset and skipped only when the API base is set and `sats` is 0. 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. Published photo posts missing the public photo URL are reset and re-signed only when `PUBLIC_BASE_URL` is set and `sats` is 0 (zapped rows keep `eventId` so receipts still resolve). Pending URL-less photo notes are EVENT'd as-is so a reset cannot renew the sign lease and starve fan-out. An empty API base leaves them alone. 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. - **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. diff --git a/src/__tests__/lib/message-store.test.ts b/src/__tests__/lib/message-store.test.ts index 103fbc91..5b3d5d1d 100644 --- a/src/__tests__/lib/message-store.test.ts +++ b/src/__tests__/lib/message-store.test.ts @@ -290,6 +290,10 @@ describe('InMemoryMessageStore', () => { }, jpeg, ); + 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.listSignedMissingPhoto(10)).map((row) => row.id)).toEqual([ 'n', 'a', @@ -297,6 +301,20 @@ describe('InMemoryMessageStore', () => { 'q', 'z', ]); + await store.create( + { + ...EARLY, + id: 'pending-photo', + createdAt: new Date('2026-06-01T00:00:00.000Z'), + hasPhoto: true, + eventId: '55'.repeat(32), + nostrEvent: { content: '' }, + }, + jpeg, + ); + expect((await store.listSignedMissingPhoto(10)).map((row) => row.id)).not.toContain( + 'pending-photo', + ); await store.addSats('z', 21); expect((await store.listSignedMissingPhoto(10)).map((row) => row.id)).toEqual([ 'n', @@ -793,6 +811,7 @@ describe('PostgresMessageStore', () => { const listSql = sql.queries.at(-1)?.text ?? ''; expect(listSql).toMatch(/photo IS NOT NULL/); expect(listSql).toMatch(/sats = 0/); + expect(listSql).toMatch(/nostr_publish_state = 'published'/); expect(listSql).toMatch(/\/messages\/' \|\| id::text \|\| '\/photo/); await store.resetSignedEvent('m1', 'ab'.repeat(32)); expect(sql.executes.at(-1)?.text).toMatch(/nostr_publish_state = 'pending'/); diff --git a/src/__tests__/lib/nostr/worker.test.ts b/src/__tests__/lib/nostr/worker.test.ts index 45914592..4a01b77d 100644 --- a/src/__tests__/lib/nostr/worker.test.ts +++ b/src/__tests__/lib/nostr/worker.test.ts @@ -581,7 +581,7 @@ describe('runNostrWorkerTick', () => { expect(row?.sats).toBe(21); }); - it('does not publish a claimed photo snapshot that lacks the photo URL', async () => { + it('publishes a pending photo snapshot even without the photo URL', async () => { const { auth, messages } = await seed(); const jpeg: { contentType: 'image/jpeg'; bytes: Uint8Array } = { contentType: 'image/jpeg', @@ -592,7 +592,7 @@ describe('runNostrWorkerTick', () => { id: 'm-stale', accountId: 'acc', name: 'Ada', - text: '', + text: 'hallo', createdAt: new Date('2026-08-28T00:05:00.000Z'), hasPhoto: true, ...unsignedNostrDefaults(), @@ -601,36 +601,60 @@ describe('runNostrWorkerTick', () => { ); await messages.updateSignedEvent('m-stale', 'ab'.repeat(32), { kind: 1, - content: '', + id: 'ab'.repeat(32), + content: 'hallo', tags: [ ['t', 'bitcoin'], ['t', '21gifts'], ['r', 'https://21.gifts'], ], }); + messages.listSignedMissingPhoto = async () => []; + const publisher = new RecordingPublisher(); + await runNostrWorkerTick( + deps({ + messages, + auth, + kek: KEK, + publisher, + now: () => 1_700_000_000_000, + env: { + NOSTR_PUBLISH: '1', + NOSTR_RELAY_SPACE: 'wss://relay.nostr.space', + PUBLIC_BASE_URL: 'http://127.0.0.1:3000', + }, + }), + ); + expect( + publisher.calls.some( + (call) => call.event['kind'] === 1 && call.event['id'] === 'ab'.repeat(32), + ), + ).toBe(true); + expect((await messages.getById('m-stale'))?.eventId).toBe('ab'.repeat(32)); + expect((await messages.getById('m-stale'))?.nostrPublishState).toBe('published'); + }); + + it('signs a photo note without a URL when getPhoto returns null', async () => { + const { auth, messages } = await seed(); await messages.create( { - id: 'm-bad-content', + id: 'm-missing-bytes', accountId: 'acc', name: 'Ada', - text: '', - createdAt: new Date('2026-08-28T00:06:00.000Z'), + text: 'hallo', + createdAt: new Date('2026-08-28T00:10:00.000Z'), hasPhoto: true, ...unsignedNostrDefaults(), }, - jpeg, + { contentType: 'image/jpeg', bytes: new Uint8Array([0xff, 0xd8, 0xff, 0xd9]) }, ); - await messages.updateSignedEvent('m-bad-content', 'cd'.repeat(32), { - kind: 1, - content: 1, - tags: [ - ['t', 'bitcoin'], - ['t', '21gifts'], - ['r', 'https://21.gifts'], - ], - }); - messages.listSignedMissingPhoto = async () => []; + messages.getPhoto = async () => null; const publisher = new RecordingPublisher(); + 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, @@ -638,19 +662,23 @@ describe('runNostrWorkerTick', () => { kek: KEK, publisher, now: () => 1_700_000_000_000, - env: { - NOSTR_PUBLISH: '1', - NOSTR_RELAY_SPACE: 'wss://relay.nostr.space', - PUBLIC_BASE_URL: 'http://127.0.0.1:3000', - }, + env, }), ); - const kind1 = publisher.calls.filter((call) => call.event['kind'] === 1); - expect(kind1.map((call) => call.event['id'])).not.toContain('ab'.repeat(32)); - expect(kind1.map((call) => call.event['id'])).not.toContain('cd'.repeat(32)); - expect((await messages.getById('m-stale'))?.eventId).toBeNull(); - expect((await messages.getById('m-bad-content'))?.eventId).toBeNull(); - expect((await messages.getById('m-stale'))?.nostrPublishState).toBe('pending'); + await runNostrWorkerTick( + deps({ + messages, + auth, + kek: KEK, + publisher, + now: () => 1_700_000_060_000, + env, + }), + ); + const row = await messages.getById('m-missing-bytes'); + expect(row?.eventId).toMatch(/^[0-9a-f]{64}$/); + expect(row?.nostrEvent?.['content']).toBe('hallo'); + expect(row?.nostrPublishState).toBe('published'); }); it('publishes a zapped URL-less photo snapshot instead of resetting it', async () => { diff --git a/src/lib/message-store.ts b/src/lib/message-store.ts index 02385ea4..971322db 100644 --- a/src/lib/message-store.ts +++ b/src/lib/message-store.ts @@ -103,8 +103,9 @@ export interface MessageStore { clearSignedEvent(id: string, expectedEventId: string | null): Promise; /** - * Signed rows with a photo whose kind:1 content lacks the public photo URL. - * Any publish state, `sats = 0` only (zapped rows keep their event id). + * Published rows with a photo whose kind:1 content lacks the public photo 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. @@ -454,6 +455,7 @@ export class InMemoryMessageStore implements MessageStore { row.eventId !== null && row.hasPhoto && row.sats === 0 && + row.nostrPublishState === 'published' && kind1MissingPhotoUrl(row.nostrEvent, row.id), ) .sort((left, right) => { @@ -806,6 +808,7 @@ export class PostgresMessageStore implements MessageStore { `SELECT ${MESSAGE_SELECT_COLUMNS} FROM message WHERE event_id IS NOT NULL AND photo IS NOT NULL AND sats = 0 + AND nostr_publish_state = 'published' AND ( nostr_event IS NULL OR COALESCE(nostr_event->>'content', '') NOT LIKE '%/messages/' || id::text || '/photo%' diff --git a/src/lib/nostr/worker.ts b/src/lib/nostr/worker.ts index 90a8999f..d8916b59 100644 --- a/src/lib/nostr/worker.ts +++ b/src/lib/nostr/worker.ts @@ -101,12 +101,14 @@ function reservedContent( * until a public ACK makes `published`/`public`. Pending kind:1 JSON without * `t=bitcoin` is dropped and re-signed before fan-out. Signed photo posts * whose kind:1 lacks the public photo URL are reset and re-signed when - * `PUBLIC_BASE_URL` is set and `sats === 0`; zapped rows keep their event - * id so receipts still resolve. An empty API base leaves them alone 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 + * they are already published, `PUBLIC_BASE_URL` is set, and `sats === 0`. + * Pending rows are EVENT'd even without the URL — resetting them first + * renews the sign lease and they never reach a relay. Zapped rows keep + * their event id so receipts still resolve. An empty API base leaves + * them alone 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 * `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 @@ -189,13 +191,15 @@ async function signBatch(deps: NostrWorkerDeps, nowMs: number): Promise { let stored = false; const apiBase = resolvePublicApiBase(deps.env); let photo: { url: string; mime: 'image/jpeg' | 'image/png' | 'image/webp' } | undefined; - if (row.hasPhoto && apiBase !== '') { + if (apiBase !== '') { const storedPhoto = await deps.messages.getPhoto(row.id); if (storedPhoto !== null) { photo = { url: forumPhotoUrl(apiBase, row.id), mime: storedPhoto.contentType, }; + } else if (row.hasPhoto) { + logEvent('nostr.sign.photo_url_missing', { messageId: row.id }); } } for (let attempt = 0; attempt < 2 && !stored; attempt += 1) { @@ -402,16 +406,6 @@ async function publishBatch( continue; } /* v8 ignore stop */ - const photoContent = row.nostrEvent['content']; - if ( - resolvePublicApiBase(deps.env) !== '' && - row.hasPhoto && - row.sats === 0 && - (typeof photoContent !== 'string' || !photoContent.includes(`/messages/${row.id}/photo`)) - ) { - await deps.messages.resetSignedEvent(row.id, row.eventId); - continue; - } try { const acks = await deps.publisher.publish(row.nostrEvent, urls, RELAY_TIMEOUT_MS); const space = spaceAcked(acks, writeSet.spaceUrl); From 0ecec01ab5c15a838a311a6d57d33a99d9deacc6 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Sun, 30 Aug 2026 15:42:06 +0200 Subject: [PATCH 04/12] 01a04e04 - Pre-register accounts and passkey claim by view key (#65) * Allow operator-provisioned accounts and passkey claim by view key POST /debug/accounts creates name plus Wallet of Satoshi rows without a passkey. Register begin accepts viewKey so the owner can bind a passkey to that same account. * Tighten provision uniqueness and cover debug POST in e2e Unique Lightning Address and one passkey per account. Playwright hits POST /debug/accounts and claim begin with an unknown view key. * Format the provision and claim files with Prettier CI lint failed on six files after the uniqueness follow-up. * Reject duplicate Lightning Addresses on create, update, and /me Memory adapter matches the unique index. Linking someone else's address returns 409. Claim-loser provision still updates the name. * Document Lightning Address uniqueness in handbook and TSDoc POST /me/lightning-address 409, unique indexes, and in-memory refuse now match the adapters. * Cover claim, provision race, and address 409 branches CI coverage missed claim Nostr/passkey races, POST provision 500/loser name update, and silent updateAccount refuse on /me. * Document POST /debug/accounts 500 and drop a reachable v8 ignore The provision 500 path is operator-visible. Claim Nostr keygen failure is covered by a unit test, so it must stay in coverage. * Align SPEC.md with provision and claim, cover nameless claim POST /debug/accounts, optional viewKey begin, and 409 on a taken Lightning Address now match the implemented HTTP surface. * Normalize provision rows and keep one passkey per account Validate every name and Lightning Address before writing on POST /debug/accounts. Adapters refuse a second credential for the same account. Docs and tests cover the 400 path and uniqueness. * Document that claim user.id is the provisioned account Mint still uses a pending UUID; a view-key claim encodes the existing row. * Format the DEBUG_TOKEN row after the develop rebase --------- Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- CONCEPT.md | 7 +- CONTRIBUTING.md | 4 +- FLOWS.md | 3 +- SPEC.md | 121 ++++-- docs/handbook/endpoints.md | 19 +- docs/handbook/functions.md | 35 +- e2e/functions.spec.ts | 19 + e2e/http.spec.ts | 21 ++ src/__tests__/lib/auth/passkey.test.ts | 244 ++++++++++++ src/__tests__/lib/auth/postgres-store.test.ts | 116 ++++++ src/__tests__/lib/auth/schema.test.ts | 6 + src/__tests__/lib/auth/store.test.ts | 144 +++++++ src/__tests__/routes/auth.test.ts | 87 +++++ src/__tests__/routes/debug.test.ts | 350 ++++++++++++++++++ src/__tests__/routes/me.test.ts | 57 +++ src/lib/auth/passkey.ts | 89 ++++- src/lib/auth/postgres-store.ts | 80 +++- src/lib/auth/schema.ts | 3 + src/lib/auth/store.ts | 83 ++++- src/routes/auth.ts | 20 + src/routes/debug.ts | 101 ++++- src/routes/me.ts | 14 +- src/server.ts | 6 +- 23 files changed, 1538 insertions(+), 91 deletions(-) diff --git a/CONCEPT.md b/CONCEPT.md index ca5f98fa..858e62ba 100644 --- a/CONCEPT.md +++ b/CONCEPT.md @@ -3,7 +3,7 @@ > Peer-to-peer donation platform. Direct human-to-human giving over Bitcoin > Lightning, with NOSTR as the invisible communication substrate. -**Status**: draft, in active iteration. Last revised 2026-08-29. +**Status**: draft, in active iteration. Last revised 2026-08-30. --- @@ -75,6 +75,11 @@ Founder. leftover `account.linking_key` values are historical and cannot log in. - WebAuthn RP ID is `WEBAUTHN_RP_ID` (`21.gifts` / `dev.21.gifts`). Missing RP ID → passkey routes 500; the process still boots. +- **Operator provision / viewKey claim (2026-08-30):** `POST /debug/accounts` + can create accounts with name + Lightning Address and no passkey. The + public `viewKey` URL is the invite. `POST /auth/passkey/register/begin` + with `{ "viewKey" }` binds a passkey to that row (name and address stay); + living-room rules agreement remains a later `/me` step. ### Donor upgrade (custodial, v1 only) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 44715060..e7ce59ee 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 /debug/accounts; PATCH /debug/accounts/:id (DEBUG_TOKEN) +│ │ ├── debug.ts # GET/POST /debug/accounts; PATCH /debug/accounts/:id (DEBUG_TOKEN) │ │ ├── debug-contacts.ts # GET /debug/contacts (operator DEBUG_TOKEN) │ │ ├── debug-payments.ts # GET /debug/invoices; GET /debug/zap-ingests (DEBUG_TOKEN) │ │ ├── stats.ts # GET /gifts/stats (public gift totals) @@ -355,7 +355,7 @@ Currently: | `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`, 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 and contact stores, empty gift stats, empty day lists, and a no-op gift recorder. | -| `DEBUG_TOKEN` | _(unset → debug off)_ | Operator bearer for `GET /debug/accounts`, `PATCH /debug/accounts/:id`, `GET /debug/contacts`, `GET /debug/invoices`, and `GET /debug/zap-ingests`. Unset or blank → `503`; the process still boots. | +| `DEBUG_TOKEN` | _(unset → debug off)_ | Operator bearer for `GET /debug/accounts`, `POST /debug/accounts`, `PATCH /debug/accounts/:id`, `GET /debug/contacts`, `GET /debug/invoices`, and `GET /debug/zap-ingests`. 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.`. | diff --git a/FLOWS.md b/FLOWS.md index 556c4032..e09992ed 100644 --- a/FLOWS.md +++ b/FLOWS.md @@ -35,7 +35,8 @@ clears the token; a transient failure does not. **Passkey (first login, HTTP shipped)** -1. App calls `POST /auth/passkey/register/begin` (new account) or +1. App calls `POST /auth/passkey/register/begin` (new account, or + `{ "viewKey" }` to claim a provisioned profile) or `POST /auth/passkey/authenticate/begin` (returning). 2. Browser runs `navigator.credentials.create` / `get` with the returned `options` (no WebAuthn library in the app). diff --git a/SPEC.md b/SPEC.md index c153bcc9..15e85e22 100644 --- a/SPEC.md +++ b/SPEC.md @@ -4,7 +4,7 @@ > Product decisions live in [`CONCEPT.md`](./CONCEPT.md); this file owns > request/response contracts for routes that exist in code today. -**Status**: living document. Last revised 2026-08-30 (`GET /debug/invoices` and `GET /debug/zap-ingests`; kind:0 `picture` + NIP-65 kind:10002; kind:1 NIP-92 `imeta` photo URLs; public `GET /messages/:id/photo`; forum `account.role` `basis`\|`verified`\|`moderator`\|`founder`; live `role` on `GET/POST /messages`; `PATCH /debug/accounts/:id`; private in-app `POST /contact` + `GET /debug/contacts`; `POST /me/lightning-address` live-resolves and requires zap metadata; invoice limiter after payable checks; public forum `GET/POST /messages` with `sats`/`payable`/`hasPhoto`; worker indexes kind:9735 zap receipts onto `sats`; `POST /messages/:id/invoice` NIP-57 zap; SQL boot requires `NOSTR_NSEC_KEK`; passkey-only login; gift stats BTC + historical USD via Coinbase daily close; `GET /gifts?day=`). +**Status**: living document. Last revised 2026-08-30 (`POST /debug/accounts` provision; `POST /auth/passkey/register/begin` optional `{ viewKey }` claim; `POST /me/lightning-address` `409` when the address is taken; `GET /debug/invoices` and `GET /debug/zap-ingests`; kind:0 `picture` + NIP-65 kind:10002; kind:1 NIP-92 `imeta` photo URLs; public `GET /messages/:id/photo`; forum `account.role` `basis`\|`verified`\|`moderator`\|`founder`; live `role` on `GET/POST /messages`; `PATCH /debug/accounts/:id`; private in-app `POST /contact` + `GET /debug/contacts`; `POST /me/lightning-address` live-resolves and requires zap metadata; invoice limiter after payable checks; public forum `GET/POST /messages` with `sats`/`payable`/`hasPhoto`; worker indexes kind:9735 zap receipts onto `sats`; `POST /messages/:id/invoice` NIP-57 zap; SQL boot requires `NOSTR_NSEC_KEK`; passkey-only login; gift stats BTC + historical USD via Coinbase daily close; `GET /gifts?day=`). --- @@ -55,41 +55,42 @@ Public base URLs used in examples: | PRD | `https://api.21.gifts` | `https://21.gifts` | | DEV | `https://dev-api.21.gifts` | `https://dev.21.gifts` | -| Method | Path | Auth | Purpose | -| ------ | -------------------------------------------- | ------------------------ | ----------------------------------------------- | -| GET | `/healthz` | none | Liveness | -| GET | `/info` | none | Service identity | -| GET | `/favicon.ico` | none | Brand mark (favicon) | -| GET | `/favicon.svg` | none | Brand mark (SVG favicon) | -| GET | `/apple-touch-icon.png` | none | Brand mark (Apple touch icon) | -| POST | `/auth/passkey/register/begin` | none | Issue WebAuthn creation options | -| 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 | `/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 | -| POST | `/me/rules-agreement` | Bearer | Record living-room rules agreement | -| POST | `/me/lightning-address` | Bearer | Link/replace after live LNURL resolve | -| DELETE | `/me/lightning-address` | Bearer | Unlink address | -| POST | `/me/lightning-address/verification` | Bearer | Start address proof-of-control payment | -| POST | `/me/lightning-address/verification/confirm` | Bearer | Confirm nonce from wallet history | -| GET | `/messages` | Bearer | List public forum thread | -| POST | `/messages` | Bearer | Post text and/or one photo to the forum | -| GET | `/messages/:id/photo` | none | Fetch forum message photo bytes | -| POST | `/messages/:id/invoice` | Bearer | NIP-57 zap / BOLT11 | -| POST | `/contact` | Bearer | Send private in-app contact `{ text }` | -| GET | `/lightning-address` | none | Resolve LUD-16 metadata (cached) | -| GET | `/debug/accounts` | `Authorization: Bearer` | Operator account listing (`DEBUG_TOKEN`) | -| PATCH | `/debug/accounts/:id` | `Authorization: Bearer` | Operator set `account.role` (`DEBUG_TOKEN`) | -| 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`) | -| GET | `/gifts` | none | Outbound gifts for one UTC day (`?day=`) | -| GET | `/gifts/stats` | none | Aggregated outbound gift statistics | -| POST | `/invoices` | Bearer `SPEND_API_TOKEN` | Fetch a recipient BOLT11 (LNURL-pay) | -| POST | `/invoices/proof` | Bearer `SPEND_API_TOKEN` | Accept payment preimage as proof | +| Method | Path | Auth | Purpose | +| ------ | -------------------------------------------- | ------------------------ | ----------------------------------------------------------- | +| GET | `/healthz` | none | Liveness | +| GET | `/info` | none | Service identity | +| GET | `/favicon.ico` | none | Brand mark (favicon) | +| GET | `/favicon.svg` | none | Brand mark (SVG favicon) | +| GET | `/apple-touch-icon.png` | none | Brand mark (Apple touch icon) | +| POST | `/auth/passkey/register/begin` | none | Issue WebAuthn creation options | +| 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 | `/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 | +| POST | `/me/rules-agreement` | Bearer | Record living-room rules agreement | +| POST | `/me/lightning-address` | Bearer | Link/replace after live LNURL resolve | +| DELETE | `/me/lightning-address` | Bearer | Unlink address | +| POST | `/me/lightning-address/verification` | Bearer | Start address proof-of-control payment | +| POST | `/me/lightning-address/verification/confirm` | Bearer | Confirm nonce from wallet history | +| GET | `/messages` | Bearer | List public forum thread | +| POST | `/messages` | Bearer | Post text and/or one photo to the forum | +| GET | `/messages/:id/photo` | none | Fetch forum message photo bytes | +| POST | `/messages/:id/invoice` | Bearer | NIP-57 zap / BOLT11 | +| POST | `/contact` | Bearer | Send private in-app contact `{ text }` | +| 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`) | +| 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`) | +| GET | `/gifts` | none | Outbound gifts for one UTC day (`?day=`) | +| GET | `/gifts/stats` | none | Aggregated outbound gift statistics | +| POST | `/invoices` | Bearer `SPEND_API_TOKEN` | Fetch a recipient BOLT11 (LNURL-pay) | +| POST | `/invoices/proof` | Bearer `SPEND_API_TOKEN` | Accept payment preimage as proof | ### `GET /healthz` @@ -154,8 +155,11 @@ Graph tags. ### `POST /auth/passkey/register/begin` -Starts a discoverable-credential registration. No body. Does not persist an -account until finish. +Starts a discoverable-credential registration. Empty body mints a new account +id (no row until finish). Optional JSON `{ "viewKey": "<64 lowercase hex>" }` +claims an existing provisioned account: `404` when the profile is missing, +`409` when it already has a passkey, `400` when `viewKey` is present but not a +string. When `WEBAUTHN_RP_ID` is unset, blank, not on the allowlist (`21.gifts` / `dev.21.gifts` / `localhost`), or no CORS origin matches that RP ID: @@ -177,7 +181,8 @@ Otherwise **Response** `200`: `options` is `PublicKeyCredentialCreationOptionsJSON` (`residentKey` and `userVerification` required, attestation `none`). `user.id` is the pending -account UUID encoded as UTF-8. The process still boots without +account UUID encoded as UTF-8 (the provisioned account when claiming by +`viewKey`). The process still boots without `WEBAUTHN_RP_ID` — only these routes fail closed. ### `POST /auth/passkey/register/finish` @@ -394,6 +399,13 @@ Well-known resolve fails, or metadata lacks zap support → **Response** `400` { "error": "Lightning Address could not be resolved" } ``` +Another account already owns the address (including a unique-index race) +→ **Response** `409`: + +```json +{ "error": "Lightning Address is already in use" } +``` + Success → **Response** `200` with the updated account (same shape as `GET /me`). `lightningAddressVerified` is always reset to `false`, and any pending verification for the account is cleared. There is no proof-of-control @@ -613,6 +625,37 @@ Environment: | `DATABASE_URL` | When set, auth state is stored in Postgres; when unset, in-memory only. | | `DEBUG_TOKEN` | Operator bearer for this route. Unset → 503; process still boots. | +### `POST /debug/accounts` + +Operator provision of accounts by display name and Lightning Address, with no +passkey and `rulesAgreedAt` null. Same `DEBUG_TOKEN` bearer as GET. + +**Request** JSON `{ "accounts": [ { "name": string, "lightningAddress": string } ] }` +(1–100 rows; name 1–80 after trim; address has exactly one `@` with both sides +non-empty). Invalid body, C0/DEL in a name, or an address that is not LUD-16 +→ **Response** `400` `{ "error": "Expected a JSON body with an \"accounts\" array" }` +(no row is written). Create that does not persist the +address → **Response** `500` `{ "error": "Could not save the account" }`. + +Success → **Response** `200`: + +```json +{ + "accounts": [ + { + "name": "Ada", + "lightningAddress": "guest@walletofsatoshi.com", + "viewKey": "<64 lowercase hex>", + "created": true + } + ] +} +``` + +Existing address (`lower(trim)`): updates `name`, keeps `viewKey`, `created` is +`false`. New address: fresh `viewKey`, `created` is `true`. GET still omits +`viewKey`. + ### `PATCH /debug/accounts/:id` Operator assignment of the account's forum display role. Authenticated with diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 5c7f0cb2..883022f6 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -21,6 +21,13 @@ - **Used by:** Operator `gifts-debug` CLI. - **Auth:** `Authorization: Bearer` with `DEBUG_TOKEN`. Not an end-user session. +## Endpoint: POST /debug/accounts + +- **Purpose:** Operator provision of accounts by display name + Lightning Address (no passkey, `rulesAgreedAt` null). Body `{ "accounts": [ { "name", "lightningAddress" } ] }` (1–100 rows). Creates a new `basis` row with a fresh `viewKey`, or updates `name` when the address already exists (`lower(trim)` match). Response `{ accounts: [ { name, lightningAddress, viewKey, created } ] }` includes `viewKey` for the invite link; `GET` still omits it. +- **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 an "accounts" array' }` for invalid/missing/non-JSON body, C0/DEL names, or non-LUD-16 addresses (no row is written); 500 `{ error: 'Could not save the account' }` when create does not persist the address. +- **Used by:** Operator provisioning before passkey claim. +- **Auth:** `Authorization: Bearer` with `DEBUG_TOKEN`. Not an end-user session. + ## 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. @@ -65,16 +72,16 @@ ## Endpoint: POST /auth/passkey/register/begin -- **Purpose:** Issues WebAuthn creation options. JSON: challengeId, options. Does not persist the account yet. -- **Errors:** HTTP 500 `{ error: 'Server auth is not configured' }` if `WEBAUTHN_RP_ID` is unset, blank, not on the allowlist, or no CORS origin matches it. -- **Used by:** App passkey account creation. +- **Purpose:** Issues WebAuthn creation options. JSON: challengeId, options. Empty body / no `viewKey` mints a pending new account id (row created only on finish). Optional body `{ "viewKey": "<64-hex>" }` claims an operator-provisioned account (same id/name/lightningAddress/viewKey). +- **Errors:** HTTP 500 `{ error: 'Server auth is not configured' }` if `WEBAUTHN_RP_ID` is unset, blank, not on the allowlist, or no CORS origin matches it; 400 `{ error: 'Expected a JSON body with an optional "viewKey" string' }` when `viewKey` is present but not a string; 404 `{ error: 'This profile could not be found.' }` for a malformed/unknown view key; 409 `{ error: 'This profile already has a passkey' }` when the provisioned account already has a credential. +- **Used by:** App passkey account creation and claim-by-viewKey. - **Auth:** Public. ## Endpoint: POST /auth/passkey/register/finish -- **Purpose:** Verifies the attestation, creates a `linkingKey: null` account, issues `{ token, account }`. Requires `Origin`. +- **Purpose:** Verifies the attestation, creates a `linkingKey: null` account (or binds a passkey to a provisioned account without recreating it), issues `{ token, account }`. Requires `Origin`. - **Errors:** 400 invalid body/origin/challenge/passkey; 500 if WebAuthn is unconfigured. -- **Used by:** App passkey account creation. +- **Used by:** App passkey account creation and claim-by-viewKey. - **Auth:** Public (proof is the attestation). ## Endpoint: GET /favicon.ico @@ -199,7 +206,7 @@ ## Endpoint: POST /me/lightning-address - **Purpose:** Body `{ address }`. Live-resolves LUD-16 well-known metadata, requires zap support (`allowsNostr` + non-empty `nostrPubkey`), then stores the address unverified on the account. -- **Errors:** 401 Unauthorized; 400 Expected a JSON body with an "address" string; 400 Not a valid Lightning Address (expected name@domain); 400 Lightning Address could not be resolved (unreachable or missing zap metadata; account unchanged). +- **Errors:** 401 Unauthorized; 400 Expected a JSON body with an "address" string; 400 Not a valid Lightning Address (expected name@domain); 400 Lightning Address could not be resolved (unreachable or missing zap metadata; account unchanged); 409 Lightning Address is already in use (another account owns it, including a unique-index race). - **Used by:** App `setLightningAddress`. - **Auth:** See Purpose — Bearer where stated, else public. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 2d10248a..f68e4990 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -170,15 +170,15 @@ ## Function: InMemoryAuthStore -- **Purpose:** Process-local AuthStore: passkey challenges/credentials, accounts, sessions, verifications, and custodial Nostr keys (`getNostrPublicKey` / `getNostrSecret` / `setNostrKeyIfAbsent` / `listAccountIdsWithoutNostrKey`). Evicts expired challenges/sessions on write. Indexes `linkingKey` only when non-null. Maintains an O(1) `viewKey` index; `getAccountByViewKey` looks it up. `createAccount` is a no-op when `viewKey` is already stored (or a non-null `linkingKey` already exists). `updateAccount` reindexes `viewKey` when it changes and refuses a `viewKey` owned by another id (same as `linkingKey`). `deleteAccount` drops the row and its linking-key and viewKey indexes. `listAccounts` returns every account oldest-first. -- **Inputs:** Constructor none. Methods take domain objects (`PasskeyChallenge`, `PasskeyCredential`, `Account`, `Session`, `AddressVerification`). `createAccount` is a no-op when a non-null `linkingKey` already exists or when `viewKey` is already stored. `updateAccount` refuses a `linkingKey` owned by another account and keeps the viewKey index consistent. `deleteAccount` drops the row and its linking-key and viewKey indexes. `createPasskeyCredential` returns false on duplicate id. `updatePasskeyCredential` returns false unless `(newCount === 0 && stored === 0)` or `newCount > stored`; missing id is false; does not rebind `accountId` / `publicKey`. `updatePasskeyChallenge` returns false when the row is missing or already consumed. +- **Purpose:** Process-local AuthStore: passkey challenges/credentials, accounts, sessions, verifications, and custodial Nostr keys (`getNostrPublicKey` / `getNostrSecret` / `setNostrKeyIfAbsent` / `listAccountIdsWithoutNostrKey`). Evicts expired challenges/sessions on write. Indexes `linkingKey` only when non-null. Maintains an O(1) `viewKey` index; `getAccountByViewKey` looks it up. `getAccountByLightningAddress` scans for a `lower(trim)` match and skips null addresses. `accountHasPasskey` is true when any credential maps to the account id. `createAccount` is a no-op when `viewKey` is already stored, a non-null `linkingKey` already exists, or `lightningAddress` (`lower(trim)`) belongs to another id. `updateAccount` reindexes `viewKey` when it changes and refuses a `viewKey`, non-null `linkingKey`, or `lightningAddress` owned by another id. `deleteAccount` drops the row and its linking-key and viewKey indexes. `listAccounts` returns every account oldest-first. +- **Inputs:** Constructor none. Methods take domain objects (`PasskeyChallenge`, `PasskeyCredential`, `Account`, `Session`, `AddressVerification`). `createAccount` is a no-op when a non-null `linkingKey` already exists, when `viewKey` is already stored, or when `lightningAddress` (`lower(trim)`) is taken. `updateAccount` refuses a `linkingKey` / `viewKey` / `lightningAddress` owned by another account and keeps the viewKey index consistent. `deleteAccount` drops the row and its linking-key and viewKey indexes. `createPasskeyCredential` returns false when this account already has a credential or the id is taken. `createFirstPasskeyCredential` returns false when this account already has a credential or the id is taken. `updatePasskeyCredential` returns false unless `(newCount === 0 && stored === 0)` or `newCount > stored`; missing id is false; does not rebind `accountId` / `publicKey`. `updatePasskeyChallenge` returns false when the row is missing or already consumed. - **Returns / side effects:** Lookups return the object or `undefined`. Writes resolve when persisted. `listAccounts` returns `Account[]`. - **Used by:** `createApp` default store; all auth/me/debug/view routes. ## Function: PostgresAuthStore -- **Purpose:** Durable AuthStore over Postgres (`SqlClient`). Same eviction-on-write semantics as the in-memory adapter, including passkey challenges, credentials, custodial Nostr key columns, and the `view_key` column. `getAccountByViewKey` is `WHERE view_key = $1`. `mapAccount` skips null `view_key` (`getAccount` / `getAccountByViewKey` return undefined; `listAccounts` omits those rows). Passkey `signCount` advances with an atomic `WHERE` (`0/0` or `new > stored`) `RETURNING`, not `GREATEST`; duplicate credential ids are `ON CONFLICT DO NOTHING`. `createAccount` INSERT unique_violation `23505` is a no-op. `updateAccount` refuses a `linkingKey` owned by another id (`UPDATE` matches no row; unique_violation `23505` is a no-op). `deleteAccount` is `DELETE FROM account WHERE id = $1`. -- **Inputs:** Constructor takes a `SqlClient`. Methods match `AuthStore` including `getAccountByViewKey`. +- **Purpose:** Durable AuthStore over Postgres (`SqlClient`). Same eviction-on-write semantics as the in-memory adapter, including passkey challenges, credentials, custodial Nostr key columns, and the `view_key` column. `getAccountByViewKey` is `WHERE view_key = $1`. `getAccountByLightningAddress` is `WHERE lower(trim(lightning_address)) = lower(trim($1))` (null addresses do not match). `accountHasPasskey` is `SELECT 1 FROM passkey_credential WHERE account_id = $1 LIMIT 1`. `mapAccount` skips null `view_key` (`getAccount` / `getAccountByViewKey` / `getAccountByLightningAddress` return undefined; `listAccounts` omits those rows). Passkey `signCount` advances with an atomic `WHERE` (`0/0` or `new > stored`) `RETURNING`, not `GREATEST`; duplicate credential ids are `ON CONFLICT DO NOTHING`. `createPasskeyCredential` also returns false on unique_violation `23505` for `passkey_credential_account_uidx` (one credential per account). `createFirstPasskeyCredential` inserts only when the account has no credential (`WHERE NOT EXISTS` plus unique `account_id`); unique_violation is false. `createAccount` INSERT unique_violation `23505` is a no-op. `updateAccount` refuses a `linkingKey` owned by another id (`UPDATE` matches no row; unique_violation `23505` is a no-op). `deleteAccount` is `DELETE FROM account WHERE id = $1`. Unique index on `lower(trim(lightning_address))` where the address is not null. +- **Inputs:** Constructor takes a `SqlClient`. Methods match `AuthStore` including `getAccountByViewKey`, `getAccountByLightningAddress`, and `accountHasPasskey`. - **Returns / side effects:** Parameter-bound SQL; maps snake_case rows to domain objects. - **Used by:** `openAuthStore` when `DATABASE_URL` is set. @@ -186,7 +186,7 @@ - **Purpose:** Applies `AUTH_SCHEMA_SQL` in order (`CREATE TABLE IF NOT EXISTS` plus `ALTER` backfills for existing databases). - **Inputs:** `SqlClient`. -- **Returns / side effects:** Void; creates `account`, `auth_session`, `address_verification`, `passkey_challenge`, `passkey_credential`; drops leftover `auth_challenge`; backfills `account.name` / nullable `linking_key`; adds `nostr_pubkey` / nsec ciphertext / kek id / custody plus unique index and CHECK; adds `view_key` ALTER, uuid-concat backfill, and unique index; adds nullable `rules_agreed_at`. +- **Returns / side effects:** Void; creates `account`, `auth_session`, `address_verification`, `passkey_challenge`, `passkey_credential`; drops leftover `auth_challenge`; backfills `account.name` / nullable `linking_key`; adds `nostr_pubkey` / nsec ciphertext / kek id / custody plus unique index and CHECK; adds `view_key` ALTER, uuid-concat backfill, and unique index; adds nullable `rules_agreed_at`; unique index `account_lightning_address_uidx` on `lower(trim(lightning_address))` where not null; unique index `passkey_credential_account_uidx` on `account_id`. - **Used by:** `openAuthStore`. ## Function: openAuthStore @@ -219,9 +219,9 @@ ## Function: debugRoutes -- **Purpose:** Operator listing of registered accounts and role assignment. +- **Purpose:** Operator listing, provisioning, and role assignment for registered accounts. - **Inputs:** `DebugRouteDeps`: store, optional debugToken. -- **Returns / side effects:** Hono app (`GET /`, `PATCH /:id`). Shared 503 if token unset; 401 if bearer mismatches. GET 200 `{ accounts }` logs `debug.accounts.listed` with count. 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; upserts by Lightning Address and 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. - **Used by:** `createApp` at `/debug/accounts`. ## Function: debugContactsRoutes @@ -380,9 +380,9 @@ ## Function: authRoutes -- **Purpose:** Hono sub-app for passkey register and authenticate. Passes optional `nostrKek` / `nostrKeygen` into finish so new logins get a custodial nsec. +- **Purpose:** Hono sub-app for passkey register and authenticate. Register begin accepts an optional `{ viewKey }` to claim a provisioned account; empty begin still mints a pending new account. Passes optional `nostrKek` / `nostrKeygen` into finish so new logins get a custodial nsec. - **Inputs:** `AuthRouteDeps`: store, now, allowedOrigins, webAuthnRpId, webAuthnRpName, passkeyCeremony, optional `nostrKek` and `nostrKeygen`. -- **Returns / side effects:** Hono app mounted at `/auth`. +- **Returns / side effects:** Hono app mounted at `/auth`. Begin with viewKey maps claim errors to 404/409; unwraps `{ challengeId, options }` on success. - **Used by:** `createApp`. ## Function: bearerToken @@ -443,7 +443,7 @@ ## Function: meRoutes -- **Purpose:** Authenticated account routes (name, forum-laws dismiss, living-room rules agreement, Lightning Address link with live LNURL resolve + zap metadata check, verification). +- **Purpose:** Authenticated account routes (name, forum-laws dismiss, living-room rules agreement, Lightning Address link with live LNURL resolve + zap metadata check, verification). `POST /lightning-address` returns 409 `{ error: 'Lightning Address is already in use' }` when another account owns the address. - **Inputs:** `MeRouteDeps` store, now, payer, fetchImpl. - **Returns / side effects:** Hono at `/me`. - **Used by:** `createApp`. @@ -625,9 +625,9 @@ ## Function: finishPasskeyRegistration -- **Purpose:** Verifies an attestation, creates a `linkingKey: null` account plus credential, issues a session. Optional `nostr` mints a custodial nsec (rollback on keygen failure). +- **Purpose:** Verifies an attestation and issues a session. When the challenge account id already exists (claim path), binds the credential to that provisioned row without `createAccount` and never `deleteAccount` on failure. When the account is new, creates a `linkingKey: null` account plus credential; optional `nostr` mints a custodial nsec (rollback on keygen failure) and a duplicate credential id rolls the new account back. - **Inputs:** store, ceremony, config, now, Origin, challengeId, credential, optional `nostr`. -- **Returns / side effects:** `{ ok: true, value: { token, account } }` or `{ ok: false, error }`. A duplicate credential id rolls the new account back. +- **Returns / side effects:** `{ ok: true, value: { token, account } }` or `{ ok: false, error }`. Claim-path credential race → `{ ok: false, error: 'Invalid passkey' }` with the provisioned account left intact. Nostr keygen failure on claim is best-effort (same as authenticate): session still issues. - **Used by:** `POST /auth/passkey/register/finish`. ## Function: issueSession @@ -667,10 +667,17 @@ ## Function: startPasskeyRegistration -- **Purpose:** Mints WebAuthn creation options and a pending account UUID (row created only on finish). +- **Purpose:** Mints WebAuthn creation options and a pending account UUID (row created only on finish). Display name is always `21.gifts`. - **Inputs:** store, ceremony, config, now. - **Returns / side effects:** `{ challengeId, options }`; persists a passkey challenge. -- **Used by:** `POST /auth/passkey/register/begin`. +- **Used by:** `POST /auth/passkey/register/begin` when the body has no string `viewKey`. + +## Function: startPasskeyClaim + +- **Purpose:** Mints WebAuthn creation options for an existing operator-provisioned account identified by `viewKey`. Uses the stored account id and `account.name` (or `21.gifts` when null) as the WebAuthn user entity. +- **Inputs:** store, ceremony, config, now, viewKey. +- **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: serializeAccount diff --git a/e2e/functions.spec.ts b/e2e/functions.spec.ts index a77b1df1..37d698c5 100644 --- a/e2e/functions.spec.ts +++ b/e2e/functions.spec.ts @@ -195,6 +195,16 @@ test('Function: migrateAuthSchema — default boot has no DATABASE_URL', async ( expect(res.status()).toBe(200); }); +test('Function: debugRoutes — POST /debug/accounts with the e2e token is 200', async ({ + request, +}) => { + const res = await request.post('/debug/accounts', { + headers: { authorization: 'Bearer e2e-debug-token' }, + data: { accounts: [{ name: 'Ada', lightningAddress: 'guest@walletofsatoshi.com' }] }, + }); + expect(res.status()).toBe(200); +}); + test('Function: debugRoutes — GET /debug/accounts with the e2e token is 200', async ({ request, }) => { @@ -511,6 +521,15 @@ test('Function: resolveCandlesUrl — default boot has no DATABASE_URL', async ( expect(res.status()).toBe(200); }); +test('Function: startPasskeyClaim — POST begin with an unknown viewKey is 404', async ({ + request, +}) => { + const res = await request.post('/auth/passkey/register/begin', { + data: { viewKey: 'a'.repeat(64) }, + }); + expect(res.status()).toBe(404); +}); + test('Function: startPasskeyRegistration — POST begin returns a challenge', async ({ request }) => { const res = await request.post('/auth/passkey/register/begin'); expect(res.status()).toBe(200); diff --git a/e2e/http.spec.ts b/e2e/http.spec.ts index 1df188ff..6f45d127 100644 --- a/e2e/http.spec.ts +++ b/e2e/http.spec.ts @@ -174,6 +174,27 @@ test('GET /debug/accounts without bearer is 401', async ({ request }) => { expect(res.status()).toBe(401); }); +test('POST /debug/accounts without bearer is 401', async ({ request }) => { + const res = await request.post('/debug/accounts', { + data: { accounts: [{ name: 'Ada', lightningAddress: 'guest@walletofsatoshi.com' }] }, + }); + expect(res.status()).toBe(401); +}); + +test('POST /debug/accounts with the e2e token provisions a guest', async ({ request }) => { + const res = await request.post('/debug/accounts', { + headers: { authorization: 'Bearer e2e-debug-token' }, + data: { accounts: [{ name: 'Ada', lightningAddress: 'guest@walletofsatoshi.com' }] }, + }); + expect(res.status()).toBe(200); + const body = (await res.json()) as { + accounts: Array<{ name: string; lightningAddress: string; viewKey: string; created: boolean }>; + }; + expect(body.accounts).toHaveLength(1); + expect(body.accounts[0]?.name).toBe('Ada'); + expect(body.accounts[0]?.viewKey).toMatch(/^[0-9a-f]{64}$/); +}); + test('GET /debug/accounts with the e2e token lists accounts', async ({ request }) => { const res = await request.get('/debug/accounts', { headers: { authorization: 'Bearer e2e-debug-token' }, diff --git a/src/__tests__/lib/auth/passkey.test.ts b/src/__tests__/lib/auth/passkey.test.ts index 3d674bdb..675c8b11 100644 --- a/src/__tests__/lib/auth/passkey.test.ts +++ b/src/__tests__/lib/auth/passkey.test.ts @@ -7,6 +7,7 @@ import { finishPasskeyAuthentication, finishPasskeyRegistration, startPasskeyAuthentication, + startPasskeyClaim, startPasskeyRegistration, } from '@/lib/auth/passkey'; import { FakePasskeyCeremony } from '@/__tests__/helpers/fake-passkey'; @@ -177,6 +178,249 @@ describe('passkey registration', () => { }); }); +describe('passkey claim', () => { + const VIEW_KEY = 'a'.repeat(64); + + async function provisionedStore(): Promise { + const store = new InMemoryAuthStore(); + await store.createAccount({ + id: 'provisioned', + linkingKey: null, + role: 'basis', + name: 'Ada', + lightningAddress: 'guest@walletofsatoshi.com', + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: VIEW_KEY, + createdAt: T0, + rulesAgreedAt: null, + }); + return store; + } + + it('returns not-found for a malformed viewKey', async () => { + const result = await startPasskeyClaim( + new InMemoryAuthStore(), + new FakePasskeyCeremony(), + CONFIG, + T0, + 'not-a-key', + ); + expect(result).toEqual({ ok: false, error: 'This profile could not be found.' }); + }); + + it('returns not-found for an unknown viewKey', async () => { + const result = await startPasskeyClaim( + new InMemoryAuthStore(), + new FakePasskeyCeremony(), + CONFIG, + T0, + 'b'.repeat(64), + ); + expect(result).toEqual({ ok: false, error: 'This profile could not be found.' }); + }); + + it('refuses claim begin when the account already has a passkey', async () => { + const store = await provisionedStore(); + await store.createPasskeyCredential({ + credentialId: 'cred-existing', + publicKey: new Uint8Array([1]), + signCount: 0, + accountId: 'provisioned', + createdAt: T0, + }); + const result = await startPasskeyClaim(store, new FakePasskeyCeremony(), CONFIG, T0, VIEW_KEY); + expect(result).toEqual({ ok: false, error: 'This profile already has a passkey' }); + }); + + it('uses a fallback display name when the provisioned account has no name', async () => { + const store = new InMemoryAuthStore(); + await store.createAccount({ + id: 'provisioned', + linkingKey: null, + role: 'basis', + name: null, + lightningAddress: 'guest@walletofsatoshi.com', + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: VIEW_KEY, + createdAt: T0, + rulesAgreedAt: null, + }); + const result = await startPasskeyClaim(store, new FakePasskeyCeremony(), CONFIG, T0, VIEW_KEY); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + const options = result.value.options as { user: { displayName: string } }; + expect(options.user.displayName).toBe('21.gifts'); + }); + + it('begins claim with the existing account id and display name', async () => { + const store = await provisionedStore(); + const result = await startPasskeyClaim(store, new FakePasskeyCeremony(), CONFIG, T0, VIEW_KEY); + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + const options = result.value.options as { + user: { id: string; name: string; displayName: string }; + }; + expect(options.user.id).toBe('provisioned'); + expect(options.user.name).toBe('provisioned'); + expect(options.user.displayName).toBe('Ada'); + expect((await store.getPasskeyChallenge(result.value.challengeId))?.accountId).toBe( + 'provisioned', + ); + }); + + it('finish after claim keeps name, lightningAddress, viewKey, and id', async () => { + const store = await provisionedStore(); + const begin = await startPasskeyClaim(store, new FakePasskeyCeremony(), CONFIG, T0, VIEW_KEY); + expect(begin.ok).toBe(true); + if (!begin.ok) { + return; + } + const finish = await finishPasskeyRegistration( + store, + new FakePasskeyCeremony(), + CONFIG, + T0, + ORIGIN, + begin.value.challengeId, + { test: 'ok' }, + ); + expect(finish.ok).toBe(true); + if (!finish.ok) { + return; + } + expect(finish.value.account).toMatchObject({ + id: 'provisioned', + name: 'Ada', + lightningAddress: 'guest@walletofsatoshi.com', + viewKey: VIEW_KEY, + }); + expect((await store.listAccounts()).map((row) => row.id)).toEqual(['provisioned']); + }); + + it('does not delete a provisioned account when credential insert races', async () => { + class DupStore extends InMemoryAuthStore { + override async createFirstPasskeyCredential(): Promise { + return false; + } + } + const store = new DupStore(); + await store.createAccount({ + id: 'provisioned', + linkingKey: null, + role: 'basis', + name: 'Ada', + lightningAddress: 'guest@walletofsatoshi.com', + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: VIEW_KEY, + createdAt: T0, + rulesAgreedAt: null, + }); + const begin = await startPasskeyClaim(store, new FakePasskeyCeremony(), CONFIG, T0, VIEW_KEY); + expect(begin.ok).toBe(true); + if (!begin.ok) { + return; + } + const finish = await finishPasskeyRegistration( + store, + new FakePasskeyCeremony(), + CONFIG, + T0, + ORIGIN, + begin.value.challengeId, + { test: 'ok' }, + ); + expect(finish).toEqual({ ok: false, error: 'Invalid passkey' }); + expect(await store.getAccount('provisioned')).toMatchObject({ + id: 'provisioned', + name: 'Ada', + viewKey: VIEW_KEY, + }); + }); + + it('rejects claim finish when a passkey appeared after begin', async () => { + const store = await provisionedStore(); + const begin = await startPasskeyClaim(store, new FakePasskeyCeremony(), CONFIG, T0, VIEW_KEY); + expect(begin.ok).toBe(true); + if (!begin.ok) { + return; + } + await store.createPasskeyCredential({ + credentialId: 'cred-existing', + publicKey: new Uint8Array([1]), + signCount: 0, + accountId: 'provisioned', + createdAt: T0, + }); + const finish = await finishPasskeyRegistration( + store, + new FakePasskeyCeremony(), + CONFIG, + T0, + ORIGIN, + begin.value.challengeId, + { test: 'ok' }, + ); + expect(finish).toEqual({ ok: false, error: 'Invalid passkey' }); + }); + + it('mints a Nostr key on claim when a KEK is provided', async () => { + const store = await provisionedStore(); + const begin = await startPasskeyClaim(store, new FakePasskeyCeremony(), CONFIG, T0, VIEW_KEY); + expect(begin.ok).toBe(true); + if (!begin.ok) { + return; + } + const kek = new Uint8Array(32).fill(3); + const finish = await finishPasskeyRegistration( + store, + new FakePasskeyCeremony(), + CONFIG, + T0, + ORIGIN, + begin.value.challengeId, + { test: 'ok' }, + { kek }, + ); + expect(finish.ok).toBe(true); + expect(await store.getNostrPublicKey('provisioned')).toMatch(/^[0-9a-f]{64}$/); + }); + + it('still issues a claim session when Nostr keygen fails', async () => { + const store = await provisionedStore(); + const begin = await startPasskeyClaim(store, new FakePasskeyCeremony(), CONFIG, T0, VIEW_KEY); + expect(begin.ok).toBe(true); + if (!begin.ok) { + return; + } + const finish = await finishPasskeyRegistration( + store, + new FakePasskeyCeremony(), + CONFIG, + T0, + ORIGIN, + begin.value.challengeId, + { test: 'ok' }, + { + kek: new Uint8Array(32).fill(3), + keygen: { + generateSecretKey: (): never => { + throw new Error('no entropy'); + }, + }, + }, + ); + expect(finish.ok).toBe(true); + expect(await store.getNostrPublicKey('provisioned')).toBeUndefined(); + }); +}); + describe('passkey authentication', () => { async function seed(): Promise<{ store: InMemoryAuthStore; diff --git a/src/__tests__/lib/auth/postgres-store.test.ts b/src/__tests__/lib/auth/postgres-store.test.ts index 35ae7326..e1985ed5 100644 --- a/src/__tests__/lib/auth/postgres-store.test.ts +++ b/src/__tests__/lib/auth/postgres-store.test.ts @@ -9,9 +9,13 @@ class MockSql implements SqlClient { queries: { text: string; params: readonly unknown[] }[] = []; nextRows: unknown[] = []; executeError: unknown | undefined; + queryError: unknown | undefined; async query(text: string, params: readonly unknown[] = []): Promise { this.queries.push({ text, params }); + if (this.queryError !== undefined) { + throw this.queryError; + } return this.nextRows as T[]; } @@ -181,6 +185,41 @@ describe('PostgresAuthStore', () => { ).toBeUndefined(); }); + it('looks up an account by lightning_address with lower(trim) SQL', async () => { + const sql = new MockSql(); + sql.nextRows = [ + { ...ACCOUNT_ROW, lightning_address: 'guest@walletofsatoshi.com', name: 'Ada' }, + ]; + const store = new PostgresAuthStore(sql); + const found = await store.getAccountByLightningAddress(' Guest@WalletOfSatoshi.com '); + expect(sql.queries[0]?.text).toMatch( + /WHERE lower\(trim\(lightning_address\)\) = lower\(trim\(\$1\)\)/, + ); + expect(sql.queries[0]?.params).toEqual([' Guest@WalletOfSatoshi.com ']); + expect(found?.id).toBe('acc'); + expect(found?.lightningAddress).toBe('guest@walletofsatoshi.com'); + }); + + it('returns undefined when lightning_address lookup has no rows', async () => { + expect( + await new PostgresAuthStore(new MockSql()).getAccountByLightningAddress( + 'missing@example.com', + ), + ).toBeUndefined(); + }); + + it('accountHasPasskey queries passkey_credential by account_id', async () => { + const sql = new MockSql(); + const store = new PostgresAuthStore(sql); + sql.nextRows = []; + expect(await store.accountHasPasskey('acc')).toBe(false); + expect(sql.queries[0]?.text).toMatch(/FROM passkey_credential/); + expect(sql.queries[0]?.text).toMatch(/account_id = \$1/); + expect(sql.queries[0]?.params).toEqual(['acc']); + sql.nextRows = [{ '?column?': 1 }]; + expect(await store.accountHasPasskey('acc')).toBe(true); + }); + it('skips rows with a null view_key', async () => { const sql = new MockSql(); sql.nextRows = [{ ...ACCOUNT_ROW, view_key: null }]; @@ -513,6 +552,27 @@ describe('PostgresAuthStore', () => { createdAt: 1, }), ).toBe(false); + sql.queryError = Object.assign(new Error('duplicate key'), { code: '23505' }); + expect( + await store.createPasskeyCredential({ + credentialId: 'cred-2', + publicKey: key, + signCount: 0, + accountId: 'acc', + createdAt: 1, + }), + ).toBe(false); + sql.queryError = new Error('disk full'); + await expect( + store.createPasskeyCredential({ + credentialId: 'cred-3', + publicKey: key, + signCount: 0, + accountId: 'acc', + createdAt: 1, + }), + ).rejects.toThrow(/disk full/); + sql.queryError = undefined; sql.nextRows = [ { credential_id: 'cred', @@ -557,6 +617,62 @@ describe('PostgresAuthStore', () => { ).toBe(false); }); + it('inserts a first passkey only when the account has none', async () => { + const sql = new MockSql(); + const store = new PostgresAuthStore(sql); + sql.nextRows = [{ credential_id: 'cred' }]; + expect( + await store.createFirstPasskeyCredential({ + credentialId: 'cred', + publicKey: new Uint8Array([1]), + signCount: 0, + accountId: 'acc', + createdAt: 1, + }), + ).toBe(true); + expect(sql.queries[0]?.text).toMatch(/WHERE NOT EXISTS/); + sql.nextRows = []; + expect( + await store.createFirstPasskeyCredential({ + credentialId: 'cred-2', + publicKey: new Uint8Array([2]), + signCount: 0, + accountId: 'acc', + createdAt: 1, + }), + ).toBe(false); + }); + + it('createFirstPasskeyCredential treats a unique_violation as false', async () => { + const sql = new MockSql(); + const store = new PostgresAuthStore(sql); + sql.queryError = Object.assign(new Error('duplicate key'), { code: '23505' }); + expect( + await store.createFirstPasskeyCredential({ + credentialId: 'cred', + publicKey: new Uint8Array([1]), + signCount: 0, + accountId: 'acc', + createdAt: 1, + }), + ).toBe(false); + }); + + it('createFirstPasskeyCredential rethrows errors that are not unique_violation', async () => { + const sql = new MockSql(); + const store = new PostgresAuthStore(sql); + sql.queryError = new Error('disk full'); + await expect( + store.createFirstPasskeyCredential({ + credentialId: 'cred', + publicKey: new Uint8Array([1]), + signCount: 0, + accountId: 'acc', + createdAt: 1, + }), + ).rejects.toThrow(/disk full/); + }); + it('returns undefined for a missing passkey credential', async () => { expect(await new PostgresAuthStore(new MockSql()).getPasskeyCredential('x')).toBeUndefined(); }); diff --git a/src/__tests__/lib/auth/schema.test.ts b/src/__tests__/lib/auth/schema.test.ts index 79f34015..1ebd1595 100644 --- a/src/__tests__/lib/auth/schema.test.ts +++ b/src/__tests__/lib/auth/schema.test.ts @@ -32,5 +32,11 @@ describe('AUTH_SCHEMA_SQL', () => { expect(AUTH_SCHEMA_SQL.join('\n')).toMatch( /ALTER TABLE account ADD COLUMN IF NOT EXISTS rules_agreed_at timestamptz/i, ); + expect(AUTH_SCHEMA_SQL.join('\n')).toMatch( + /CREATE UNIQUE INDEX IF NOT EXISTS account_lightning_address_uidx/i, + ); + expect(AUTH_SCHEMA_SQL.join('\n')).toMatch( + /CREATE UNIQUE INDEX IF NOT EXISTS passkey_credential_account_uidx ON passkey_credential \(account_id\)/i, + ); }); }); diff --git a/src/__tests__/lib/auth/store.test.ts b/src/__tests__/lib/auth/store.test.ts index 271238f5..abe9b45c 100644 --- a/src/__tests__/lib/auth/store.test.ts +++ b/src/__tests__/lib/auth/store.test.ts @@ -620,6 +620,150 @@ describe('InMemoryAuthStore', () => { expect(await store.getAccountByViewKey('0'.repeat(64))).toBeUndefined(); }); + it('finds an account by lightningAddress with mixed case and whitespace', async () => { + const store = new InMemoryAuthStore(); + await store.createAccount({ + id: 'acc', + linkingKey: null, + role: 'basis', + name: 'Ada', + lightningAddress: 'guest@walletofsatoshi.com', + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: 'a'.repeat(64), + createdAt: 1, + rulesAgreedAt: null, + }); + expect((await store.getAccountByLightningAddress(' Guest@WalletOfSatoshi.com '))?.id).toBe( + 'acc', + ); + expect(await store.getAccountByLightningAddress('missing@example.com')).toBeUndefined(); + }); + + it('refuses createAccount and updateAccount when the lightningAddress is taken', async () => { + const store = new InMemoryAuthStore(); + const base = { + linkingKey: null as string | null, + role: 'basis' as const, + name: 'Ada', + lightningAddressVerified: false, + forumLawsDismissed: false, + createdAt: 1, + rulesAgreedAt: null as number | null, + }; + await store.createAccount({ + ...base, + id: 'a', + lightningAddress: 'guest@walletofsatoshi.com', + viewKey: 'a'.repeat(64), + }); + await store.createAccount({ + ...base, + id: 'b', + name: 'Bob', + lightningAddress: ' Guest@WalletOfSatoshi.com ', + viewKey: 'b'.repeat(64), + }); + expect(await store.getAccount('b')).toBeUndefined(); + await store.createAccount({ + ...base, + id: 'c', + name: 'Cara', + lightningAddress: 'cara@walletofsatoshi.com', + viewKey: 'c'.repeat(64), + }); + await store.updateAccount({ + ...base, + id: 'c', + name: 'Cara', + lightningAddress: 'guest@walletofsatoshi.com', + viewKey: 'c'.repeat(64), + }); + expect((await store.getAccount('c'))?.lightningAddress).toBe('cara@walletofsatoshi.com'); + }); + + it('skips null lightningAddress rows when looking up by address', async () => { + const store = new InMemoryAuthStore(); + await store.createAccount({ + id: 'acc', + linkingKey: null, + role: 'basis', + name: null, + lightningAddress: null, + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: 'b'.repeat(64), + createdAt: 1, + rulesAgreedAt: null, + }); + expect(await store.getAccountByLightningAddress('null@example.com')).toBeUndefined(); + }); + + it('reports whether an account has a passkey credential', async () => { + const store = new InMemoryAuthStore(); + await store.createAccount({ + id: 'acc', + linkingKey: null, + role: 'basis', + name: 'Ada', + lightningAddress: 'guest@walletofsatoshi.com', + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: 'c'.repeat(64), + createdAt: 1, + rulesAgreedAt: null, + }); + expect(await store.accountHasPasskey('acc')).toBe(false); + expect( + await store.createPasskeyCredential({ + credentialId: 'cred', + publicKey: new Uint8Array([1]), + signCount: 0, + accountId: 'acc', + createdAt: 1, + }), + ).toBe(true); + expect(await store.accountHasPasskey('acc')).toBe(true); + }); + + it('refuses a second passkey credential for the same account', async () => { + const store = new InMemoryAuthStore(); + const first = { + credentialId: 'cred-a', + publicKey: new Uint8Array([1]), + signCount: 0, + accountId: 'acc', + createdAt: 1, + }; + expect(await store.createPasskeyCredential(first)).toBe(true); + expect( + await store.createPasskeyCredential({ + ...first, + credentialId: 'cred-b', + publicKey: new Uint8Array([2]), + }), + ).toBe(false); + }); + + it('refuses a second first-passkey for the same account', async () => { + const store = new InMemoryAuthStore(); + const first = { + credentialId: 'cred-a', + publicKey: new Uint8Array([1]), + signCount: 0, + accountId: 'acc', + createdAt: 1, + }; + expect(await store.createFirstPasskeyCredential(first)).toBe(true); + expect( + await store.createFirstPasskeyCredential({ + ...first, + credentialId: 'cred-b', + publicKey: new Uint8Array([2]), + }), + ).toBe(false); + }); + it('ignores a second createAccount with the same viewKey', async () => { const store = new InMemoryAuthStore(); const viewKey = 'e'.repeat(64); diff --git a/src/__tests__/routes/auth.test.ts b/src/__tests__/routes/auth.test.ts index 41261a3e..2e2b6206 100644 --- a/src/__tests__/routes/auth.test.ts +++ b/src/__tests__/routes/auth.test.ts @@ -98,6 +98,93 @@ describe('auth routes', () => { expect(body.options.challenge).toBe('test-challenge'); }); + it('issues claim options for a provisioned viewKey', async () => { + const store = new InMemoryAuthStore(); + const viewKey = 'a'.repeat(64); + await store.createAccount({ + id: 'provisioned', + linkingKey: null, + role: 'basis', + name: 'Ada', + lightningAddress: 'guest@walletofsatoshi.com', + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey, + createdAt: 1, + rulesAgreedAt: null, + }); + const res = await mount(store).request('/auth/passkey/register/begin', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ viewKey }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + challengeId: string; + options: { user: { displayName: string; name: string } }; + ok?: unknown; + value?: unknown; + }; + expect(body.challengeId).toMatch(/^[0-9a-f]{64}$/); + expect(body.options.user.displayName).toBe('Ada'); + expect(body.options.user.name).toBe('provisioned'); + expect(body).not.toHaveProperty('ok'); + expect(body).not.toHaveProperty('value'); + }); + + it('returns 404 when begin viewKey is unknown', async () => { + const res = await mount(new InMemoryAuthStore()).request('/auth/passkey/register/begin', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ viewKey: 'b'.repeat(64) }), + }); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'This profile could not be found.' }); + }); + + it('returns 409 when begin viewKey already has a passkey', async () => { + const store = new InMemoryAuthStore(); + const viewKey = 'c'.repeat(64); + await store.createAccount({ + id: 'provisioned', + linkingKey: null, + role: 'basis', + name: 'Ada', + lightningAddress: 'guest@walletofsatoshi.com', + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey, + createdAt: 1, + rulesAgreedAt: null, + }); + await store.createPasskeyCredential({ + credentialId: 'cred-1', + publicKey: new Uint8Array([1]), + signCount: 0, + accountId: 'provisioned', + createdAt: 1, + }); + const res = await mount(store).request('/auth/passkey/register/begin', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ viewKey }), + }); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ error: 'This profile already has a passkey' }); + }); + + it('returns 400 when begin viewKey is not a string', async () => { + const res = await mount(new InMemoryAuthStore()).request('/auth/passkey/register/begin', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ viewKey: 12 }), + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: 'Expected a JSON body with an optional "viewKey" string', + }); + }); + it('rejects a missing finish body', async () => { const res = await mount(new InMemoryAuthStore()).request('/auth/passkey/register/finish', { method: 'POST', diff --git a/src/__tests__/routes/debug.test.ts b/src/__tests__/routes/debug.test.ts index e5d24a06..b1d20102 100644 --- a/src/__tests__/routes/debug.test.ts +++ b/src/__tests__/routes/debug.test.ts @@ -195,4 +195,354 @@ describe('debugRoutes', () => { ), ).toBe(true); }); + + it('POST returns 503 when debug is not configured', async () => { + const app = new Hono().route( + '/debug/accounts', + debugRoutes({ store: new InMemoryAuthStore(), debugToken: undefined }), + ); + const res = await app.request('/debug/accounts', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + accounts: [{ name: 'Ada', lightningAddress: 'guest@walletofsatoshi.com' }], + }), + }); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ error: 'Debug is not configured' }); + }); + + it('POST returns 401 without a matching bearer', async () => { + const app = new Hono().route( + '/debug/accounts', + debugRoutes({ store: new InMemoryAuthStore(), debugToken: 'secret' }), + ); + const res = await app.request('/debug/accounts', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + accounts: [{ name: 'Ada', lightningAddress: 'guest@walletofsatoshi.com' }], + }), + }); + expect(res.status).toBe(401); + }); + + it('POST returns 400 for an invalid body', async () => { + const app = new Hono().route( + '/debug/accounts', + debugRoutes({ store: new InMemoryAuthStore(), debugToken: 'secret' }), + ); + const res = await app.request('/debug/accounts', { + method: 'POST', + headers: { authorization: 'Bearer secret', 'content-type': 'application/json' }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: 'Expected a JSON body with an "accounts" array', + }); + }); + + it('POST returns 400 when name or Lightning Address fail normalisation', async () => { + const app = new Hono().route( + '/debug/accounts', + debugRoutes({ store: new InMemoryAuthStore(), debugToken: 'secret' }), + ); + const badName = await app.request('/debug/accounts', { + method: 'POST', + headers: { authorization: 'Bearer secret', 'content-type': 'application/json' }, + body: JSON.stringify({ + accounts: [{ name: 'Ada\u0001', lightningAddress: 'guest@walletofsatoshi.com' }], + }), + }); + expect(badName.status).toBe(400); + expect(await badName.json()).toEqual({ + error: 'Expected a JSON body with an "accounts" array', + }); + const badAddr = await app.request('/debug/accounts', { + method: 'POST', + headers: { authorization: 'Bearer secret', 'content-type': 'application/json' }, + body: JSON.stringify({ + accounts: [{ name: 'Ada', lightningAddress: 'a@b' }], + }), + }); + expect(badAddr.status).toBe(400); + expect(await badAddr.json()).toEqual({ + error: 'Expected a JSON body with an "accounts" array', + }); + }); + + it('POST returns 400 without persisting earlier rows when one address fails normalisation', async () => { + const store = new InMemoryAuthStore(); + const app = new Hono().route('/debug/accounts', debugRoutes({ store, debugToken: 'secret' })); + const res = await app.request('/debug/accounts', { + method: 'POST', + headers: { authorization: 'Bearer secret', 'content-type': 'application/json' }, + body: JSON.stringify({ + accounts: [ + { name: 'Ada', lightningAddress: 'guest@walletofsatoshi.com' }, + { name: 'Bob', lightningAddress: 'a@b' }, + ], + }), + }); + expect(res.status).toBe(400); + expect(await store.getAccountByLightningAddress('guest@walletofsatoshi.com')).toBeUndefined(); + }); + + it('POST provisions a new account without a passkey', async () => { + const store = new InMemoryAuthStore(); + const app = new Hono().route('/debug/accounts', debugRoutes({ store, debugToken: 'secret' })); + const res = await app.request('/debug/accounts', { + method: 'POST', + headers: { authorization: 'Bearer secret', 'content-type': 'application/json' }, + body: JSON.stringify({ + accounts: [{ name: 'Ada', lightningAddress: 'guest@walletofsatoshi.com' }], + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + accounts: Array<{ + name: string; + lightningAddress: string; + viewKey: string; + created: boolean; + }>; + }; + expect(body.accounts).toHaveLength(1); + expect(body.accounts[0]?.created).toBe(true); + expect(body.accounts[0]?.name).toBe('Ada'); + expect(body.accounts[0]?.lightningAddress).toBe('guest@walletofsatoshi.com'); + expect(body.accounts[0]?.viewKey).toMatch(/^[0-9a-f]{64}$/); + const stored = await store.getAccountByLightningAddress('guest@walletofsatoshi.com'); + expect(stored).toMatchObject({ + linkingKey: null, + role: 'basis', + name: 'Ada', + lightningAddress: 'guest@walletofsatoshi.com', + rulesAgreedAt: null, + viewKey: body.accounts[0]?.viewKey, + }); + expect(await store.accountHasPasskey(stored!.id)).toBe(false); + expect( + parsedEvents(warn).some( + (e) => + e['event'] === 'debug.accounts.provisioned' && e['created'] === 1 && e['updated'] === 0, + ), + ).toBe(true); + }); + + it('POST updates name idempotently for the same address ignoring case', async () => { + const store = new InMemoryAuthStore(); + const app = new Hono().route('/debug/accounts', debugRoutes({ store, debugToken: 'secret' })); + const first = await app.request('/debug/accounts', { + method: 'POST', + headers: { authorization: 'Bearer secret', 'content-type': 'application/json' }, + body: JSON.stringify({ + accounts: [{ name: 'Ada', lightningAddress: 'guest@walletofsatoshi.com' }], + }), + }); + const firstBody = (await first.json()) as { + accounts: Array<{ viewKey: string; created: boolean }>; + }; + const second = await app.request('/debug/accounts', { + method: 'POST', + headers: { authorization: 'Bearer secret', 'content-type': 'application/json' }, + body: JSON.stringify({ + accounts: [{ name: 'Ada Lovelace', lightningAddress: 'Guest@WalletOfSatoshi.com' }], + }), + }); + expect(second.status).toBe(200); + const secondBody = (await second.json()) as { + accounts: Array<{ name: string; viewKey: string; created: boolean }>; + }; + expect(secondBody.accounts[0]?.created).toBe(false); + expect(secondBody.accounts[0]?.viewKey).toBe(firstBody.accounts[0]?.viewKey); + expect(secondBody.accounts[0]?.name).toBe('Ada Lovelace'); + expect((await store.getAccountByLightningAddress('guest@walletofsatoshi.com'))?.name).toBe( + 'Ada Lovelace', + ); + const listed = await app.request('/debug/accounts', { + headers: { authorization: 'Bearer secret' }, + }); + const listBody = (await listed.json()) as { + accounts: Array>; + }; + expect(listBody.accounts[0]).not.toHaveProperty('viewKey'); + expect( + parsedEvents(warn).some( + (e) => + e['event'] === 'debug.accounts.provisioned' && e['created'] === 0 && e['updated'] === 1, + ), + ).toBe(true); + }); + + it('POST returns 500 when create does not persist the address', async () => { + class HollowStore extends InMemoryAuthStore { + override async getAccountByLightningAddress(): Promise { + return undefined; + } + override async createAccount(): Promise { + return; + } + } + const app = new Hono().route( + '/debug/accounts', + debugRoutes({ store: new HollowStore(), debugToken: 'secret' }), + ); + const res = await app.request('/debug/accounts', { + method: 'POST', + headers: { authorization: 'Bearer secret', 'content-type': 'application/json' }, + body: JSON.stringify({ + accounts: [{ name: 'Ada', lightningAddress: 'guest@walletofsatoshi.com' }], + }), + }); + expect(res.status).toBe(500); + }); + + it('POST applies the name when create loses a race to an existing address', async () => { + class RaceStore extends InMemoryAuthStore { + #lookups = 0; + override async getAccountByLightningAddress(address: string) { + this.#lookups += 1; + if (this.#lookups === 1) { + return undefined; + } + return super.getAccountByLightningAddress(address); + } + } + const store = new RaceStore(); + await store.createAccount({ + id: 'existing', + linkingKey: null, + role: 'basis', + name: 'Old', + lightningAddress: 'guest@walletofsatoshi.com', + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: 'c'.repeat(64), + createdAt: 1, + rulesAgreedAt: null, + }); + const app = new Hono().route('/debug/accounts', debugRoutes({ store, debugToken: 'secret' })); + const res = await app.request('/debug/accounts', { + method: 'POST', + headers: { authorization: 'Bearer secret', 'content-type': 'application/json' }, + body: JSON.stringify({ + accounts: [{ name: 'Ada', lightningAddress: 'guest@walletofsatoshi.com' }], + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + accounts: Array<{ name: string; created: boolean; viewKey: string }>; + }; + expect(body.accounts[0]?.created).toBe(false); + expect(body.accounts[0]?.name).toBe('Ada'); + expect(body.accounts[0]?.viewKey).toBe('c'.repeat(64)); + expect((await store.getAccount('existing'))?.name).toBe('Ada'); + }); + + it('POST falls back to the request address when the stored row has none', async () => { + class NullAddressStore extends InMemoryAuthStore { + override async getAccountByLightningAddress() { + return { + id: 'existing', + linkingKey: null, + role: 'basis' as const, + name: 'Old', + lightningAddress: null, + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: 'c'.repeat(64), + createdAt: 1, + rulesAgreedAt: null, + }; + } + } + const app = new Hono().route( + '/debug/accounts', + debugRoutes({ store: new NullAddressStore(), debugToken: 'secret' }), + ); + const res = await app.request('/debug/accounts', { + method: 'POST', + headers: { authorization: 'Bearer secret', 'content-type': 'application/json' }, + body: JSON.stringify({ + accounts: [{ name: 'Ada', lightningAddress: 'guest@walletofsatoshi.com' }], + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + accounts: Array<{ lightningAddress: string; created: boolean }>; + }; + expect(body.accounts[0]?.created).toBe(false); + expect(body.accounts[0]?.lightningAddress).toBe('guest@walletofsatoshi.com'); + }); + + it('POST falls back to the request name and address when create returns null fields', async () => { + class NullCreatedStore extends InMemoryAuthStore { + override async getAccountByLightningAddress(address: string) { + const acc = await super.getAccountByLightningAddress(address); + if (acc === undefined) { + return undefined; + } + return { ...acc, name: null, lightningAddress: null }; + } + } + const store = new NullCreatedStore(); + const app = new Hono().route('/debug/accounts', debugRoutes({ store, debugToken: 'secret' })); + const res = await app.request('/debug/accounts', { + method: 'POST', + headers: { authorization: 'Bearer secret', 'content-type': 'application/json' }, + body: JSON.stringify({ + accounts: [{ name: 'Ada', lightningAddress: 'guest@walletofsatoshi.com' }], + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + accounts: Array<{ name: string; lightningAddress: string; created: boolean }>; + }; + expect(body.accounts[0]?.created).toBe(true); + expect(body.accounts[0]?.name).toBe('Ada'); + expect(body.accounts[0]?.lightningAddress).toBe('guest@walletofsatoshi.com'); + }); + + it('POST falls back to the request address when a create race returns a nameless row', async () => { + class RaceNullAddressStore extends InMemoryAuthStore { + #lookups = 0; + override async getAccountByLightningAddress(address: string) { + this.#lookups += 1; + if (this.#lookups === 1) { + return undefined; + } + const acc = await super.getAccountByLightningAddress(address); + return acc === undefined ? undefined : { ...acc, lightningAddress: null }; + } + } + const store = new RaceNullAddressStore(); + await store.createAccount({ + id: 'existing', + linkingKey: null, + role: 'basis', + name: 'Old', + lightningAddress: 'guest@walletofsatoshi.com', + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: 'c'.repeat(64), + createdAt: 1, + rulesAgreedAt: null, + }); + const app = new Hono().route('/debug/accounts', debugRoutes({ store, debugToken: 'secret' })); + const res = await app.request('/debug/accounts', { + method: 'POST', + headers: { authorization: 'Bearer secret', 'content-type': 'application/json' }, + body: JSON.stringify({ + accounts: [{ name: 'Ada', lightningAddress: 'guest@walletofsatoshi.com' }], + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + accounts: Array<{ lightningAddress: string; created: boolean }>; + }; + expect(body.accounts[0]?.created).toBe(false); + expect(body.accounts[0]?.lightningAddress).toBe('guest@walletofsatoshi.com'); + }); }); diff --git a/src/__tests__/routes/me.test.ts b/src/__tests__/routes/me.test.ts index f5e070d7..6e0614e4 100644 --- a/src/__tests__/routes/me.test.ts +++ b/src/__tests__/routes/me.test.ts @@ -451,6 +451,63 @@ describe('POST /me/lightning-address', () => { ).toBe(true); }); + it('returns 409 when the Lightning Address belongs to another account', async () => { + const store = await seededStore(); + await store.createAccount({ + id: 'other', + linkingKey: null, + role: 'basis', + name: 'Other', + lightningAddress: ADDRESS, + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: 'b'.repeat(64), + createdAt: 1_000_000, + rulesAgreedAt: null, + }); + const res = await mount(store, { fetchImpl: happyFetch() }).request('/me/lightning-address', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ address: ADDRESS }), + }); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ error: 'Lightning Address is already in use' }); + expect((await store.getAccount('acc'))?.lightningAddress).toBeNull(); + }); + + it('returns 409 when updateAccount silently refuses a taken address', async () => { + class SilentStore extends InMemoryAuthStore { + override async getAccountByLightningAddress(): Promise { + return undefined; + } + override async updateAccount(): Promise { + return; + } + } + const store = new SilentStore(); + await store.createAccount({ + id: 'acc', + linkingKey: LINKING_KEY, + role: 'basis', + name: null, + lightningAddress: null, + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: VIEW_KEY, + createdAt: 1_000_000, + rulesAgreedAt: null, + }); + await store.createSession({ token: 'tok', accountId: 'acc', createdAt: 1_000_000 }); + const res = await mount(store, { fetchImpl: happyFetch() }).request('/me/lightning-address', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ address: ADDRESS }), + }); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ error: 'Lightning Address is already in use' }); + expect((await store.getAccount('acc'))?.lightningAddress).toBeNull(); + }); + it('clears a pending verification when linking', async () => { const store = await seededStore({ lightningAddress: ADDRESS }); await store.putVerification({ diff --git a/src/lib/auth/passkey.ts b/src/lib/auth/passkey.ts index 68ffd71d..89c02b74 100644 --- a/src/lib/auth/passkey.ts +++ b/src/lib/auth/passkey.ts @@ -51,6 +51,12 @@ export function credentialIdFrom(credential: unknown): string | null { return typeof id === 'string' && id !== '' ? id : null; } +/** Stable 404 copy when a claim view key is missing or malformed. */ +const CLAIM_NOT_FOUND = 'This profile could not be found.'; + +/** Stable 409 copy when a provisioned profile already has a passkey. */ +const CLAIM_ALREADY_HAS_PASSKEY = 'This profile already has a passkey'; + /** * Start passkey registration: mint a pending account id and creation options. * @@ -89,18 +95,68 @@ export async function startPasskeyRegistration( return { challengeId, options: generated.options }; } +/** + * Start passkey claim: bind a passkey to an operator-provisioned account + * identified by its durable view key. Does not mint a new account id. + * + * @param store - Auth persistence port. + * @param ceremony - WebAuthn collaborator. + * @param config - RP ID, name, and allowed origins. + * @param now - Current time in epoch milliseconds. + * @param viewKey - Capability secret from the invite link (64 lowercase hex). + * @returns Creation options, or a stable 404/409 error string. + */ +export async function startPasskeyClaim( + store: AuthStore, + ceremony: PasskeyCeremony, + config: WebAuthnRuntimeConfig, + now: number, + viewKey: string, +): Promise<{ ok: true; value: PasskeyBeginResult } | { ok: false; error: string }> { + if (!/^[0-9a-f]{64}$/.test(viewKey)) { + return { ok: false, error: CLAIM_NOT_FOUND }; + } + const account = await store.getAccountByViewKey(viewKey); + if (account === undefined) { + return { ok: false, error: CLAIM_NOT_FOUND }; + } + if (await store.accountHasPasskey(account.id)) { + return { ok: false, error: CLAIM_ALREADY_HAS_PASSKEY }; + } + const generated = await ceremony.generateRegistrationOptions({ + rpName: config.rpName, + rpID: config.rpId, + userID: new TextEncoder().encode(account.id), + userName: account.id, + userDisplayName: account.name ?? '21.gifts', + }); + const challengeId = randomHex(32); + await store.createPasskeyChallenge({ + id: challengeId, + type: 'register', + challenge: generated.challenge, + accountId: account.id, + consumed: false, + createdAt: now, + }); + return { ok: true, value: { challengeId, options: generated.options } }; +} + /** * Complete passkey registration: verify attestation, persist the account and - * credential, issue a session. When `nostr` is set, generates a custodial - * nsec for the new account (rolls the account back if keygen fails). A - * duplicate credential id rolls the new account back via `deleteAccount`. + * credential, issue a session. When claiming a provisioned account, binds the + * credential without creating or deleting the row. When creating a new account, + * optional `nostr` mints a custodial nsec (rolls the account back if keygen + * fails) and a duplicate credential id rolls the new account back via + * `deleteAccount`. * * @param store - Auth persistence port. * @param ceremony - WebAuthn collaborator. * @param config - RP ID, name, and allowed origins. * @param now - Current time in epoch milliseconds. * @param origin - Request `Origin` header (must match `expectedOrigins`). - * @param challengeId - Id returned by {@link startPasskeyRegistration}. + * @param challengeId - Id returned by {@link startPasskeyRegistration} or + * {@link startPasskeyClaim}. * @param credential - Browser attestation JSON. * @param nostr - Optional KEK (and test-only keygen) to mint a custodial nsec. * @returns Session + account, or a 400 error string. @@ -143,6 +199,31 @@ export async function finishPasskeyRegistration( if ((await store.getPasskeyCredential(verified.credentialId)) !== undefined) { return { ok: false, error: 'Invalid passkey' }; } + const existing = await store.getAccount(accountId); + if (existing !== undefined) { + if (await store.accountHasPasskey(accountId)) { + return { ok: false, error: 'Invalid passkey' }; + } + const stored = await store.createFirstPasskeyCredential({ + credentialId: verified.credentialId, + publicKey: verified.publicKey, + signCount: verified.signCount, + accountId, + createdAt: now, + }); + if (!stored) { + return { ok: false, error: 'Invalid passkey' }; + } + if (nostr !== undefined) { + try { + await ensureAccountNostrKey(store, existing.id, nostr.kek, nostr.keygen); + } catch { + logEvent('nostr.keygen.backfill.failed', { accountId: existing.id }); + } + } + const issued = await issueSession(store, now, existing); + return { ok: true, value: issued }; + } const account: Account = { id: accountId, linkingKey: null, diff --git a/src/lib/auth/postgres-store.ts b/src/lib/auth/postgres-store.ts index 9610be14..bc21d44d 100644 --- a/src/lib/auth/postgres-store.ts +++ b/src/lib/auth/postgres-store.ts @@ -170,6 +170,24 @@ export class PostgresAuthStore implements AuthStore { return row === undefined ? undefined : mapAccount(row); } + async getAccountByLightningAddress(address: string): Promise { + const rows = await this.#sql.query( + `SELECT id, linking_key, role, name, lightning_address, lightning_address_verified, forum_laws_dismissed, view_key, created_at, rules_agreed_at + FROM account WHERE lower(trim(lightning_address)) = lower(trim($1))`, + [address], + ); + const row = rows[0]; + return row === undefined ? undefined : mapAccount(row); + } + + async accountHasPasskey(accountId: string): Promise { + const rows = await this.#sql.query>( + 'SELECT 1 FROM passkey_credential WHERE account_id = $1 LIMIT 1', + [accountId], + ); + return rows[0] !== undefined; + } + async deleteAccount(id: string): Promise { await this.#sql.execute('DELETE FROM account WHERE id = $1', [id]); } @@ -278,20 +296,54 @@ export class PostgresAuthStore implements AuthStore { } async createPasskeyCredential(credential: PasskeyCredential): Promise { - const rows = await this.#sql.query<{ credential_id: string }>( - `INSERT INTO passkey_credential (credential_id, public_key, sign_count, account_id, created_at) - VALUES ($1, $2, $3, $4, to_timestamp($5::double precision / 1000.0)) - ON CONFLICT (credential_id) DO NOTHING - RETURNING credential_id`, - [ - credential.credentialId, - credential.publicKey, - credential.signCount, - credential.accountId, - credential.createdAt, - ], - ); - return rows[0] !== undefined; + try { + const rows = await this.#sql.query<{ credential_id: string }>( + `INSERT INTO passkey_credential (credential_id, public_key, sign_count, account_id, created_at) + VALUES ($1, $2, $3, $4, to_timestamp($5::double precision / 1000.0)) + ON CONFLICT (credential_id) DO NOTHING + RETURNING credential_id`, + [ + credential.credentialId, + credential.publicKey, + credential.signCount, + credential.accountId, + credential.createdAt, + ], + ); + return rows[0] !== undefined; + } catch (error: unknown) { + if (isUniqueViolation(error)) { + return false; + } + throw error; + } + } + + async createFirstPasskeyCredential(credential: PasskeyCredential): Promise { + try { + const rows = await this.#sql.query<{ credential_id: string }>( + `INSERT INTO passkey_credential (credential_id, public_key, sign_count, account_id, created_at) + SELECT $1, $2, $3, $4, to_timestamp($5::double precision / 1000.0) + WHERE NOT EXISTS ( + SELECT 1 FROM passkey_credential WHERE account_id = $4 + ) + ON CONFLICT (credential_id) DO NOTHING + RETURNING credential_id`, + [ + credential.credentialId, + credential.publicKey, + credential.signCount, + credential.accountId, + credential.createdAt, + ], + ); + return rows[0] !== undefined; + } catch (error: unknown) { + if (isUniqueViolation(error)) { + return false; + } + throw error; + } } async getPasskeyCredential(credentialId: string): Promise { diff --git a/src/lib/auth/schema.ts b/src/lib/auth/schema.ts index 11b0ffb1..ba4cafa3 100644 --- a/src/lib/auth/schema.ts +++ b/src/lib/auth/schema.ts @@ -64,4 +64,7 @@ export const AUTH_SCHEMA_SQL: readonly string[] = [ `UPDATE account SET view_key = replace(gen_random_uuid()::text || gen_random_uuid()::text, '-', '') WHERE view_key IS NULL`, `CREATE UNIQUE INDEX IF NOT EXISTS account_view_key_uidx ON account (view_key) WHERE view_key IS NOT NULL`, `ALTER TABLE account ADD COLUMN IF NOT EXISTS rules_agreed_at timestamptz`, + `CREATE UNIQUE INDEX IF NOT EXISTS account_lightning_address_uidx + ON account (lower(trim(lightning_address))) WHERE lightning_address IS NOT NULL`, + `CREATE UNIQUE INDEX IF NOT EXISTS passkey_credential_account_uidx ON passkey_credential (account_id)`, ]; diff --git a/src/lib/auth/store.ts b/src/lib/auth/store.ts index 94842c1e..9850fa3d 100644 --- a/src/lib/auth/store.ts +++ b/src/lib/auth/store.ts @@ -122,9 +122,9 @@ export interface AuthStore { /** Persist a new account. */ createAccount(account: Account): Promise; /** - * Overwrite a stored account. A `viewKey` or non-null `linkingKey` owned - * by another id is refused (in-memory no-op; Postgres `linkingKey` via - * `UPDATE` matching no row, `viewKey` via swallowed `view_key` + * Overwrite a stored account. A `viewKey`, non-null `linkingKey`, or + * `lightningAddress` (`lower(trim)`) owned by another id is refused + * (in-memory no-op; Postgres via `UPDATE` matching no row or swallowed * unique_violation). */ updateAccount(account: Account): Promise; @@ -135,6 +135,17 @@ export interface AuthStore { * Used by the public capability URL; never mints a session. */ getAccountByViewKey(viewKey: string): Promise; + /** + * Look up an account by Lightning Address (`lower(trim)` match). Rows with a + * null `lightningAddress` are skipped. At most one row matches (unique index + * in Postgres; in-memory create/update refuse a taken address). + */ + getAccountByLightningAddress(address: string): Promise; + /** + * Whether the account already has at least one passkey credential. + * Used to refuse a second claim on a provisioned profile. + */ + accountHasPasskey(accountId: string): Promise; /** * Drop an account row. Used to roll back `finishPasskeyRegistration` when * the credential insert loses a duplicate-id race. @@ -166,9 +177,15 @@ export interface AuthStore { updatePasskeyChallenge(challenge: PasskeyChallenge): Promise; /** * Persist a verified passkey credential. Returns false when the id is - * already stored so two adapters reject duplicates the same way. + * already stored or this account already has a credential so two adapters + * reject duplicates the same way. */ createPasskeyCredential(credential: PasskeyCredential): Promise; + /** + * Persist the account's first passkey. Returns false when this account + * already has a credential or the credential id is taken. + */ + createFirstPasskeyCredential(credential: PasskeyCredential): Promise; /** Look up a passkey credential by id, or `undefined` if unknown. */ getPasskeyCredential(credentialId: string): Promise; /** @@ -228,6 +245,9 @@ export class InMemoryAuthStore implements AuthStore { if (account.linkingKey !== null && this.#accountsByLinkingKey.has(account.linkingKey)) { return; } + if (this.#lightningAddressTaken(account.lightningAddress, account.id)) { + return; + } this.#accounts.set(account.id, account); this.#accountsByViewKey.set(account.viewKey, account.id); if (account.linkingKey !== null) { @@ -246,6 +266,9 @@ export class InMemoryAuthStore implements AuthStore { if (viewKeyOwnerId !== undefined && viewKeyOwnerId !== account.id) { return; } + if (this.#lightningAddressTaken(account.lightningAddress, account.id)) { + return; + } const previous = this.#accounts.get(account.id); if ( previous !== undefined && @@ -286,6 +309,44 @@ export class InMemoryAuthStore implements AuthStore { return id === undefined ? undefined : this.#accounts.get(id); } + #lightningAddressTaken(address: string | null, accountId: string): boolean { + if (address === null) { + return false; + } + const needle = address.trim().toLowerCase(); + for (const other of this.#accounts.values()) { + if (other.id === accountId || other.lightningAddress === null) { + continue; + } + if (other.lightningAddress.trim().toLowerCase() === needle) { + return true; + } + } + return false; + } + + async getAccountByLightningAddress(address: string): Promise { + const needle = address.trim().toLowerCase(); + for (const account of this.#accounts.values()) { + if (account.lightningAddress === null) { + continue; + } + if (account.lightningAddress.trim().toLowerCase() === needle) { + return account; + } + } + return undefined; + } + + async accountHasPasskey(accountId: string): Promise { + for (const credential of this.#passkeyCredentials.values()) { + if (credential.accountId === accountId) { + return true; + } + } + return false; + } + async listAccounts(): Promise { return [...this.#accounts.values()].sort(compareAccountsForList); } @@ -333,10 +394,24 @@ export class InMemoryAuthStore implements AuthStore { if (this.#passkeyCredentials.has(credential.credentialId)) { return false; } + for (const stored of this.#passkeyCredentials.values()) { + if (stored.accountId === credential.accountId) { + return false; + } + } this.#passkeyCredentials.set(credential.credentialId, credential); return true; } + async createFirstPasskeyCredential(credential: PasskeyCredential): Promise { + for (const stored of this.#passkeyCredentials.values()) { + if (stored.accountId === credential.accountId) { + return false; + } + } + return this.createPasskeyCredential(credential); + } + async getPasskeyCredential(credentialId: string): Promise { return this.#passkeyCredentials.get(credentialId); } diff --git a/src/routes/auth.ts b/src/routes/auth.ts index a999073c..d3b6ea60 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -5,6 +5,7 @@ import { finishPasskeyAuthentication, finishPasskeyRegistration, startPasskeyAuthentication, + startPasskeyClaim, startPasskeyRegistration, } from '@/lib/auth/passkey'; import { serializeOwnerAccount } from '@/lib/auth/account-json'; @@ -56,6 +57,25 @@ export function authRoutes(deps: AuthRouteDeps): Hono { if (config === null) { return c.json({ error: 'Server auth is not configured' }, 500); } + const body = await c.req.json().catch(() => null); + if (body !== null && typeof body === 'object' && !Array.isArray(body) && 'viewKey' in body) { + const viewKey = (body as { viewKey: unknown }).viewKey; + if (typeof viewKey !== 'string') { + return c.json({ error: 'Expected a JSON body with an optional "viewKey" string' }, 400); + } + const claimed = await startPasskeyClaim( + deps.store, + deps.passkeyCeremony, + config, + deps.now(), + viewKey, + ); + if (!claimed.ok) { + const status = claimed.error === 'This profile already has a passkey' ? 409 : 404; + return c.json({ error: claimed.error }, status); + } + return c.json(claimed.value, 200); + } const started = await startPasskeyRegistration( deps.store, deps.passkeyCeremony, diff --git a/src/routes/debug.ts b/src/routes/debug.ts index 967fa7ac..33f75bea 100644 --- a/src/routes/debug.ts +++ b/src/routes/debug.ts @@ -2,14 +2,17 @@ import { Hono } from 'hono'; import type { MiddlewareHandler } from 'hono'; import { z } from 'zod'; import { serializeAccount } from '@/lib/auth/account-json'; +import { randomHex } from '@/lib/auth/hex'; import type { AuthStore } from '@/lib/auth/store'; import { bearerMatchesDebugToken } from '@/lib/debug-token'; +import { normalizeLightningAddress } from '@/lib/lightning-address'; import { logEvent } from '@/lib/log'; +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) and `PATCH /:id` (set role). + * Exposes `GET /` (list), `POST /` (provision), and `PATCH /:id` (set role). */ /** Collaborators the debug routes need. */ @@ -25,6 +28,23 @@ const roleBody = z.object({ role: z.enum(['basis', 'verified', 'moderator', 'founder']), }); +/** One row in the operator provision body. */ +const provisionAccountRow = z.object({ + name: z.string().trim().min(1).max(80), + lightningAddress: z + .string() + .trim() + .refine((value) => { + const at = value.indexOf('@'); + return at > 0 && at === value.lastIndexOf('@') && at < value.length - 1; + }), +}); + +/** Body schema for operator account provisioning. */ +const provisionBody = z.object({ + accounts: z.array(provisionAccountRow).min(1).max(100), +}); + /** Shared 503/401 gate for every `/debug/accounts` method. */ function requireDebugToken(deps: DebugRouteDeps): MiddlewareHandler { return async (c, next) => { @@ -43,7 +63,7 @@ function requireDebugToken(deps: DebugRouteDeps): MiddlewareHandler { * Build the `/debug/accounts` route group. * * @param deps - Shared store and optional debug token. - * @returns A Hono app exposing `GET /` and `PATCH /:id`. + * @returns A Hono app exposing `GET /`, `POST /`, and `PATCH /:id`. */ export function debugRoutes(deps: DebugRouteDeps): Hono { return new Hono() @@ -53,6 +73,83 @@ export function debugRoutes(deps: DebugRouteDeps): Hono { logEvent('debug.accounts.listed', { count: accounts.length }); return c.json({ accounts: accounts.map(serializeAccount) }, 200); }) + .post('/', async (c) => { + const parsed = provisionBody.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) { + return c.json({ error: 'Expected a JSON body with an "accounts" array' }, 400); + } + const accounts: Array<{ name: string; lightningAddress: string }> = []; + for (const raw of parsed.data.accounts) { + const name = normalizeDisplayName(raw.name); + const lightningAddress = normalizeLightningAddress(raw.lightningAddress); + if (name === null || lightningAddress === null) { + return c.json({ error: 'Expected a JSON body with an "accounts" array' }, 400); + } + accounts.push({ name, lightningAddress }); + } + let created = 0; + let updated = 0; + const results: Array<{ + name: string; + lightningAddress: string; + viewKey: string; + created: boolean; + }> = []; + for (const row of accounts) { + const found = await deps.store.getAccountByLightningAddress(row.lightningAddress); + if (found !== undefined) { + const next = { ...found, name: row.name }; + await deps.store.updateAccount(next); + updated += 1; + results.push({ + name: row.name, + lightningAddress: found.lightningAddress ?? row.lightningAddress, + viewKey: found.viewKey, + created: false, + }); + continue; + } + const viewKey = randomHex(32); + await deps.store.createAccount({ + id: crypto.randomUUID(), + linkingKey: null, + role: 'basis', + name: row.name, + lightningAddress: row.lightningAddress, + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey, + createdAt: Date.now(), + rulesAgreedAt: null, + }); + const stored = await deps.store.getAccountByLightningAddress(row.lightningAddress); + if (stored === undefined) { + return c.json({ error: 'Could not save the account' }, 500); + } + const didCreate = stored.viewKey === viewKey; + if (didCreate) { + created += 1; + results.push({ + name: stored.name ?? row.name, + lightningAddress: stored.lightningAddress ?? row.lightningAddress, + viewKey: stored.viewKey, + created: true, + }); + } else { + const next = { ...stored, name: row.name }; + await deps.store.updateAccount(next); + updated += 1; + results.push({ + name: row.name, + lightningAddress: stored.lightningAddress ?? row.lightningAddress, + viewKey: stored.viewKey, + created: false, + }); + } + } + logEvent('debug.accounts.provisioned', { created, updated }); + return c.json({ accounts: results }, 200); + }) .patch('/:id', async (c) => { const parsed = roleBody.safeParse(await c.req.json().catch(() => null)); if (!parsed.success) { diff --git a/src/routes/me.ts b/src/routes/me.ts index 0f83f317..d9faec4d 100644 --- a/src/routes/me.ts +++ b/src/routes/me.ts @@ -189,18 +189,30 @@ export function meRoutes(deps: MeRouteDeps): Hono { } // Linking a (new) address resets any prior verified state; proof of control // is a separate step. Any in-flight verification is dropped with the link. + const owner = await deps.store.getAccountByLightningAddress(address); + if (owner !== undefined && owner.id !== current.id) { + return c.json({ error: 'Lightning Address is already in use' }, 409); + } const updated: Account = { ...current, lightningAddress: address, lightningAddressVerified: false, }; await deps.store.updateAccount(updated); + const stored = await storedAccount(deps, current.id); + /* v8 ignore next 3 -- the account row cannot vanish mid-request after auth */ + if (stored === null) { + return c.json({ error: 'Unauthorized' }, 401); + } + if ((stored.lightningAddress ?? '').trim().toLowerCase() !== address.trim().toLowerCase()) { + return c.json({ error: 'Lightning Address is already in use' }, 409); + } await deps.store.deleteVerification(current.id); logEvent('account.lightning_address.linked', { accountId: account.id, address, }); - return c.json(serializeOwnerAccount(updated), 200); + return c.json(serializeOwnerAccount(stored), 200); }) .delete('/lightning-address', async (c) => { const account = await authedAccount(deps, c.req.header('authorization')); diff --git a/src/server.ts b/src/server.ts index cb154b09..1a696dcb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -69,9 +69,9 @@ export interface AppDeps { readBrand?: BrandReader; /** * Operator debug token (default: `process.env.DEBUG_TOKEN`). Unset or - * blank → `GET /debug/accounts`, `PATCH /debug/accounts/:id`, - * `GET /debug/contacts`, `GET /debug/invoices`, and - * `GET /debug/zap-ingests` return 503. + * blank → `GET /debug/accounts`, `POST /debug/accounts`, + * `PATCH /debug/accounts/:id`, `GET /debug/contacts`, `GET /debug/invoices`, + * and `GET /debug/zap-ingests` return 503. */ debugToken?: string; /** From 4790ce055e68ec2da610ce4f3ebb161df260377a Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Sun, 30 Aug 2026 15:49:02 +0200 Subject: [PATCH 05/12] 01a04a23 - Give Damus image URLs a .jpg/.png/.webp extension (#73) * 01a04a23 - Give Damus image URLs a .jpg/.png/.webp extension * 01a04a23 - Document photo Content-Disposition and .jpeg alias --------- Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- docs/handbook/endpoints.md | 30 ++++++++++- docs/handbook/functions.md | 8 +-- e2e/http.spec.ts | 16 ++++++ src/__tests__/lib/message-store.test.ts | 4 +- src/__tests__/lib/nostr/event.test.ts | 18 ++++--- src/__tests__/lib/nostr/worker.test.ts | 6 +-- src/__tests__/routes/messages.test.ts | 62 +++++++++++++++++++++ src/lib/message-store.ts | 4 +- src/lib/nostr/event.ts | 30 +++++++++-- src/lib/nostr/worker.ts | 2 +- src/routes/messages.ts | 71 ++++++++++++++++--------- 11 files changed, 205 insertions(+), 46 deletions(-) diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 883022f6..80de7d84 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -170,11 +170,39 @@ ## Endpoint: GET /messages/:id/photo -- **Purpose:** Public. Returns raw photo bytes for one message (`Content-Type` jpeg/png/webp, `Cache-Control: public, max-age=86400`) so Nostr clients can load NIP-92 `imeta` URLs. List JSON never embeds bytes — clients fetch here when `hasPhoto` is true. +- **Purpose:** Public. Returns raw photo bytes for one message (`Content-Type` jpeg/png/webp, `Cache-Control: public, max-age=86400`, `Access-Control-Allow-Origin: *`, `Content-Disposition: inline; filename="photo.jpg|png|webp"`) so Nostr clients can load NIP-92 `imeta` URLs. Same bytes at `/photo.jpg`, `/photo.jpeg`, `/photo.png`, and `/photo.webp` because Damus only embeds URLs that look like image files. List JSON never embeds bytes — clients fetch here when `hasPhoto` is true. - **Errors:** 404 `{ error: 'Photo not found' }` when the id is missing, not a UUID, or has no photo; 503 `{ error: 'Messages are unavailable' }` (`messages.photo.failed`). - **Used by:** App forum photo display; Damus/Primal via kind:1 photo URLs. - **Auth:** none. +## Endpoint: GET /messages/:id/photo.jpg + +- **Purpose:** Same public bytes as `GET /messages/:id/photo`. Kind:1 and `imeta` use this path so Damus embeds the image instead of a website card. +- **Errors:** Same 404 / 503 as `GET /messages/:id/photo`. +- **Used by:** Damus, Primal, njump via kind:1 photo URLs. +- **Auth:** none. + +## Endpoint: GET /messages/:id/photo.jpeg + +- **Purpose:** Alias of `GET /messages/:id/photo.jpg`. +- **Errors:** Same 404 / 503 as `GET /messages/:id/photo`. +- **Used by:** Clients that request `.jpeg`. +- **Auth:** none. + +## Endpoint: GET /messages/:id/photo.png + +- **Purpose:** Same handler as `GET /messages/:id/photo` when the stored type is PNG. Kind:1 URLs use `.png` for PNG posts. +- **Errors:** Same 404 / 503 as `GET /messages/:id/photo`. +- **Used by:** Damus/Primal for PNG forum photos. +- **Auth:** none. + +## Endpoint: GET /messages/:id/photo.webp + +- **Purpose:** Same handler as `GET /messages/:id/photo` when the stored type is WebP. Kind:1 URLs use `.webp` for WebP posts. +- **Errors:** Same 404 / 503 as `GET /messages/:id/photo`. +- **Used by:** Damus/Primal for WebP forum photos. +- **Auth:** none. + ## 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. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index f68e4990..e02ecd27 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -149,7 +149,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; `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` (`sats = 0`, pending excluded so fan-out is not starved, `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; `getPhoto` loads bytes by id; `getById`; `getByEventId` (`WHERE event_id`); `claimUnsigned`/`claimUnpublished` lease rows; `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`); `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. @@ -261,7 +261,7 @@ ## 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, `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`, oldest-first, `sats === 0`, pending excluded), `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). +- **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, `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), `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. - **Used by:** `createApp` default `messageStore`. @@ -772,8 +772,8 @@ ## Function: forumPhotoUrl -- **Purpose:** Absolute `GET /messages/:id/photo` URL for kind:1 content and `imeta`. -- **Inputs:** API origin, message id. +- **Purpose:** Absolute `GET /messages/:id/photo.jpg` (or `.png` / `.webp`) URL for kind:1 content and `imeta`. The extension matches the stored MIME so Damus treats the URL as an image, not a website. +- **Inputs:** API origin, message id, optional MIME (default JPEG). - **Returns / side effects:** URL string. - **Used by:** Worker sign path. diff --git a/e2e/http.spec.ts b/e2e/http.spec.ts index 6f45d127..688be0f0 100644 --- a/e2e/http.spec.ts +++ b/e2e/http.spec.ts @@ -125,6 +125,22 @@ test('GET /messages/:id/photo UUID path without bearer is 404', async ({ request const res = await request.get('/messages/00000000-0000-0000-0000-000000000000/photo'); expect(res.status()).toBe(404); }); +test('GET /messages/:id/photo.jpg UUID path without bearer is 404', async ({ request }) => { + const res = await request.get('/messages/:id/photo.jpg'); + expect(res.status()).toBe(404); +}); +test('GET /messages/:id/photo.jpeg without bearer is 404', async ({ request }) => { + const res = await request.get('/messages/:id/photo.jpeg'); + expect(res.status()).toBe(404); +}); +test('GET /messages/:id/photo.png without bearer is 404', async ({ request }) => { + const res = await request.get('/messages/:id/photo.png'); + expect(res.status()).toBe(404); +}); +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('POST /me/name without bearer is 401', async ({ request }) => { const res = await request.post('/me/name', { diff --git a/src/__tests__/lib/message-store.test.ts b/src/__tests__/lib/message-store.test.ts index 5b3d5d1d..2ac77ae9 100644 --- a/src/__tests__/lib/message-store.test.ts +++ b/src/__tests__/lib/message-store.test.ts @@ -328,7 +328,7 @@ describe('InMemoryMessageStore', () => { 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/photo', + content: 'http://127.0.0.1:3000/messages/a/photo.jpg', }); expect((await store.listSignedMissingPhoto(10)).map((row) => row.id)).toEqual(['n', 'p', 'q']); await store.resetSignedEvent('a', 'ff'.repeat(32)); @@ -812,7 +812,7 @@ describe('PostgresMessageStore', () => { expect(listSql).toMatch(/photo IS NOT NULL/); expect(listSql).toMatch(/sats = 0/); expect(listSql).toMatch(/nostr_publish_state = 'published'/); - expect(listSql).toMatch(/\/messages\/' \|\| id::text \|\| '\/photo/); + expect(listSql).toMatch(/\/messages\/' \|\| id::text \|\| '\/photo\./); await store.resetSignedEvent('m1', 'ab'.repeat(32)); expect(sql.executes.at(-1)?.text).toMatch(/nostr_publish_state = 'pending'/); expect(sql.executes.at(-1)?.text).toMatch(/event_id IS NOT DISTINCT FROM/); diff --git a/src/__tests__/lib/nostr/event.test.ts b/src/__tests__/lib/nostr/event.test.ts index 8275e33f..cccc0e22 100644 --- a/src/__tests__/lib/nostr/event.test.ts +++ b/src/__tests__/lib/nostr/event.test.ts @@ -24,23 +24,23 @@ describe('kind1', () => { it('appends the photo URL and imeta when a photo is set', () => { const event = buildKind1Event('hello', 1, { - url: 'http://127.0.0.1:3000/messages/m1/photo', + url: 'http://127.0.0.1:3000/messages/m1/photo.jpg', mime: 'image/jpeg', }); - expect(event.content).toBe('hello\nhttp://127.0.0.1:3000/messages/m1/photo'); + expect(event.content).toBe('hello\nhttp://127.0.0.1:3000/messages/m1/photo.jpg'); expect(event.tags.at(-1)).toEqual([ 'imeta', - 'url http://127.0.0.1:3000/messages/m1/photo', + 'url http://127.0.0.1:3000/messages/m1/photo.jpg', 'm image/jpeg', ]); }); it('uses the photo URL as content when text is empty', () => { const event = buildKind1Event('', 1, { - url: 'http://127.0.0.1:3000/messages/m1/photo', + url: 'http://127.0.0.1:3000/messages/m1/photo.png', mime: 'image/png', }); - expect(event.content).toBe('http://127.0.0.1:3000/messages/m1/photo'); + expect(event.content).toBe('http://127.0.0.1:3000/messages/m1/photo.png'); }); }); @@ -53,7 +53,13 @@ describe('kind0', () => { picture: KIND0_PICTURE_URL, }); expect(forumPhotoUrl('https://api.21.gifts/', 'm1')).toBe( - 'https://api.21.gifts/messages/m1/photo', + 'https://api.21.gifts/messages/m1/photo.jpg', + ); + expect(forumPhotoUrl('https://api.21.gifts', 'm1', 'image/png')).toBe( + 'https://api.21.gifts/messages/m1/photo.png', + ); + expect(forumPhotoUrl('https://api.21.gifts', 'm1', 'image/webp')).toBe( + 'https://api.21.gifts/messages/m1/photo.webp', ); expect(buildKind0Event('Ada', null, 1).tags).toEqual([]); }); diff --git a/src/__tests__/lib/nostr/worker.test.ts b/src/__tests__/lib/nostr/worker.test.ts index 4a01b77d..2ad06640 100644 --- a/src/__tests__/lib/nostr/worker.test.ts +++ b/src/__tests__/lib/nostr/worker.test.ts @@ -431,7 +431,7 @@ describe('runNostrWorkerTick', () => { const notes = publisher.calls .filter((call) => call.event['kind'] === 1) .map((call) => String(call.event['content'])); - expect(notes).toContain('pic\nhttps://dev-api.21.gifts/messages/m-pic/photo'); + expect(notes).toContain('pic\nhttps://dev-api.21.gifts/messages/m-pic/photo.jpg'); const note = publisher.calls.find( (call) => call.event['kind'] === 1 && String(call.event['content']).includes('m-pic/photo'), ); @@ -439,7 +439,7 @@ describe('runNostrWorkerTick', () => { ['t', 'bitcoin'], ['t', '21gifts'], ['r', 'https://21.gifts'], - ['imeta', 'url https://dev-api.21.gifts/messages/m-pic/photo', 'm image/jpeg'], + ['imeta', 'url https://dev-api.21.gifts/messages/m-pic/photo.jpg', 'm image/jpeg'], ]); }); @@ -494,7 +494,7 @@ describe('runNostrWorkerTick', () => { ); const row = await messages.getById('m-photo'); expect(row?.eventId).not.toBe('ab'.repeat(32)); - expect(String(row?.nostrEvent?.['content'])).toContain('/messages/m-photo/photo'); + expect(String(row?.nostrEvent?.['content'])).toContain('/messages/m-photo/photo.png'); }); it('does not reset published photo posts when PUBLIC_BASE_URL is unset', async () => { diff --git a/src/__tests__/routes/messages.test.ts b/src/__tests__/routes/messages.test.ts index fb8db95f..951210fa 100644 --- a/src/__tests__/routes/messages.test.ts +++ b/src/__tests__/routes/messages.test.ts @@ -1540,9 +1540,71 @@ describe('GET /messages/:id/photo', () => { expect(res.status).toBe(200); expect(res.headers.get('Content-Type')).toBe('image/jpeg'); expect(res.headers.get('Cache-Control')).toBe('public, max-age=86400'); + expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(res.headers.get('Content-Disposition')).toBe('inline; filename="photo.jpg"'); expect(new Uint8Array(await res.arrayBuffer())).toEqual(JPEG_BYTES); }); + it('serves the same bytes at /photo.jpg so Damus treats the URL as an image', async () => { + const store = new InMemoryMessageStore(); + await store.create( + { + id: '00000000-0000-4000-8000-000000000001', + accountId: 'acc', + name: 'Ada', + text: '', + createdAt: new Date(now()), + hasPhoto: true, + ...unsignedNostrDefaults(), + }, + { contentType: 'image/jpeg', bytes: JPEG_BYTES }, + ); + const res = await mount(await seededStore(), store).request( + '/messages/00000000-0000-4000-8000-000000000001/photo.jpg', + ); + const jpeg = await mount(await seededStore(), store).request( + '/messages/00000000-0000-4000-8000-000000000001/photo.jpeg', + ); + expect(res.status).toBe(200); + expect(jpeg.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe('image/jpeg'); + expect(new Uint8Array(await res.arrayBuffer())).toEqual(JPEG_BYTES); + }); + + it('names png and webp files from the stored type', async () => { + const store = new InMemoryMessageStore(); + const pngId = '00000000-0000-4000-8000-000000000002'; + const webpId = '00000000-0000-4000-8000-000000000003'; + await store.create( + { + id: pngId, + accountId: 'acc', + name: 'Ada', + text: '', + createdAt: new Date(now()), + hasPhoto: true, + ...unsignedNostrDefaults(), + }, + { contentType: 'image/png', bytes: new Uint8Array([0x89, 0x50, 0x4e, 0x47]) }, + ); + await store.create( + { + id: webpId, + accountId: 'acc', + name: 'Ada', + text: '', + createdAt: new Date(now()), + hasPhoto: true, + ...unsignedNostrDefaults(), + }, + { contentType: 'image/webp', bytes: new Uint8Array([0x52, 0x49, 0x46, 0x46]) }, + ); + const png = await mount(await seededStore(), store).request(`/messages/${pngId}/photo.png`); + const webp = await mount(await seededStore(), store).request(`/messages/${webpId}/photo.webp`); + expect(png.headers.get('Content-Disposition')).toBe('inline; filename="photo.png"'); + expect(webp.headers.get('Content-Disposition')).toBe('inline; filename="photo.webp"'); + }); + it('returns 404 when the photo is missing', async () => { const res = await mount(await seededStore()).request( '/messages/00000000-0000-0000-0000-000000000000/photo', diff --git a/src/lib/message-store.ts b/src/lib/message-store.ts index 971322db..03e3c377 100644 --- a/src/lib/message-store.ts +++ b/src/lib/message-store.ts @@ -21,7 +21,7 @@ function kind1MissingPhotoUrl(event: Record | null, messageId: return true; } const content = event['content']; - return typeof content !== 'string' || !content.includes(`/messages/${messageId}/photo`); + return typeof content !== 'string' || !content.includes(`/messages/${messageId}/photo.`); } function pendingKind1LacksBitcoinTag(event: Record | null): boolean { @@ -811,7 +811,7 @@ export class PostgresMessageStore implements MessageStore { AND nostr_publish_state = 'published' AND ( nostr_event IS NULL - OR COALESCE(nostr_event->>'content', '') NOT LIKE '%/messages/' || id::text || '/photo%' + OR COALESCE(nostr_event->>'content', '') NOT LIKE '%/messages/' || id::text || '/photo.%' ) ORDER BY created_at ASC, id ASC LIMIT $1`, diff --git a/src/lib/nostr/event.ts b/src/lib/nostr/event.ts index cd83f142..a63edf00 100644 --- a/src/lib/nostr/event.ts +++ b/src/lib/nostr/event.ts @@ -27,15 +27,39 @@ export interface Kind1Photo { mime: 'image/jpeg' | 'image/png' | 'image/webp'; } +/** + * Filename extension Damus treats as an inline image. + * + * @param mime - Stored JPEG, PNG, or WebP type. + * @returns `jpg`, `png`, or `webp`. + */ +function forumPhotoExt(mime: Kind1Photo['mime']): 'jpg' | 'png' | 'webp' { + if (mime === 'image/png') { + return 'png'; + } + if (mime === 'image/webp') { + return 'webp'; + } + return 'jpg'; +} + /** * Absolute photo URL for a forum message. * + * Damus only embeds URLs that look like image files, so the path ends in + * `.jpg` / `.png` / `.webp` rather than a bare `/photo`. + * * @param apiBase - Public API origin (no trailing slash). * @param messageId - Message id. - * @returns `GET /messages/:id/photo` URL. + * @param mime - Stored type (defaults to JPEG). + * @returns `GET /messages/:id/photo.jpg` (or `.png` / `.webp`) URL. */ -export function forumPhotoUrl(apiBase: string, messageId: string): string { - return `${apiBase.replace(/\/$/, '')}/messages/${messageId}/photo`; +export function forumPhotoUrl( + apiBase: string, + messageId: string, + mime: Kind1Photo['mime'] = 'image/jpeg', +): string { + return `${apiBase.replace(/\/$/, '')}/messages/${messageId}/photo.${forumPhotoExt(mime)}`; } /** Mutable tag arrays for `finalizeEvent` (copy of {@link KIND1_TAGS}). */ diff --git a/src/lib/nostr/worker.ts b/src/lib/nostr/worker.ts index d8916b59..e3d0deab 100644 --- a/src/lib/nostr/worker.ts +++ b/src/lib/nostr/worker.ts @@ -195,7 +195,7 @@ async function signBatch(deps: NostrWorkerDeps, nowMs: number): Promise { const storedPhoto = await deps.messages.getPhoto(row.id); if (storedPhoto !== null) { photo = { - url: forumPhotoUrl(apiBase, row.id), + url: forumPhotoUrl(apiBase, row.id, storedPhoto.contentType), mime: storedPhoto.contentType, }; } else if (row.hasPhoto) { diff --git a/src/routes/messages.ts b/src/routes/messages.ts index dbd0ebd8..a7cfb28c 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -130,6 +130,44 @@ async function authedAccount( /** Hex UUID as stored on `message.id` (rejects values Postgres would error on). */ const MESSAGE_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +/** + * Public photo bytes for Nostr clients. Same handler for `/photo` and + * `/photo.jpg` (Damus only embeds URLs with an image extension). + * + * @param deps - Message store. + * @param id - Path id. + * @returns 200 bytes, 404, or 503. + */ +async function serveForumPhoto(deps: MessagesRouteDeps, id: string): Promise { + if (!MESSAGE_ID_RE.test(id)) { + return Response.json({ error: 'Photo not found' }, { status: 404 }); + } + try { + const photo = await deps.store.getPhoto(id); + if (photo === null) { + return Response.json({ error: 'Photo not found' }, { status: 404 }); + } + const ext = + photo.contentType === 'image/png' + ? 'png' + : photo.contentType === 'image/webp' + ? 'webp' + : 'jpg'; + return new Response(photo.bytes, { + status: 200, + headers: { + 'Content-Type': photo.contentType, + 'Cache-Control': 'public, max-age=86400', + 'Access-Control-Allow-Origin': '*', + 'Content-Disposition': `inline; filename="photo.${ext}"`, + }, + }); + } catch { + logEvent('messages.photo.failed'); + return Response.json({ error: 'Messages are unavailable' }, { status: 503 }); + } +} + /** Body schema for posting a forum message (text and/or photo). */ const postBody = z .object({ @@ -150,10 +188,12 @@ 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 `POST /messages/:id/invoice`. + * `POST /messages`, `GET /messages/:id/photo` (and `.jpg` / `.jpeg` / `.png` / + * `.webp`), and `POST /messages/:id/invoice`. * * @param deps - Message store, auth store, and clock. - * @returns A Hono app with `GET /`, `POST /`, `GET /:id/photo`, and `POST /:id/invoice`. + * @returns A Hono app with `GET /`, `POST /`, `GET /:id/photo` plus `.jpg` / + * `.jpeg` / `.png` / `.webp`, and `POST /:id/invoice`. */ export function messagesRoutes(deps: MessagesRouteDeps): Hono { const postLimiter = deps.postLimiter ?? defaultPostLimiter; @@ -233,28 +273,11 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { return c.json({ error: 'Messages are unavailable' }, 503); } }) - .get('/:id/photo', async (c) => { - const id = c.req.param('id'); - if (!MESSAGE_ID_RE.test(id)) { - return c.json({ error: 'Photo not found' }, 404); - } - try { - const photo = await deps.store.getPhoto(id); - if (photo === null) { - return c.json({ error: 'Photo not found' }, 404); - } - return new Response(photo.bytes, { - status: 200, - headers: { - 'Content-Type': photo.contentType, - 'Cache-Control': 'public, max-age=86400', - }, - }); - } catch { - logEvent('messages.photo.failed'); - return c.json({ error: 'Messages are unavailable' }, 503); - } - }) + .get('/:id/photo.jpg', (c) => serveForumPhoto(deps, c.req.param('id'))) + .get('/:id/photo.jpeg', (c) => serveForumPhoto(deps, c.req.param('id'))) + .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'))) .post('/:id/invoice', async (c) => { const account = await authedAccount(deps, c.req.header('authorization')); if (account === null) { From 06b3adb97e9e320804ffc9ac69df5b822ff076f6 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Sun, 30 Aug 2026 15:56:03 +0200 Subject: [PATCH 06/12] Enforce the 10 million sat invoice cap and 503 debug listings. (#74) Over-cap POST /messages/:id/invoice now persists bad_body instead of hiding behind a coverage ignore. Debug invoice and zap-ingest lists return 503 when the store throws, matching the other debug routes. Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- SPEC.md | 16 +++++++++++-- docs/handbook/endpoints.md | 6 ++--- docs/handbook/functions.md | 2 +- src/__tests__/routes/debug-payments.test.ts | 25 ++++++++++++++++++++ src/__tests__/routes/messages.test.ts | 26 +++++++++++++++++++++ src/routes/debug-payments.ts | 22 ++++++++++++----- src/routes/messages.ts | 6 ++--- 7 files changed, 87 insertions(+), 16 deletions(-) diff --git a/SPEC.md b/SPEC.md index 15e85e22..ec0da848 100644 --- a/SPEC.md +++ b/SPEC.md @@ -770,6 +770,12 @@ Missing or non-matching bearer → **Response** `401`: { "error": "Unauthorized" } ``` +Store failure → **Response** `503`: + +```json +{ "error": "Messages are unavailable" } +``` + Success → **Response** `200`: ```json @@ -828,6 +834,12 @@ Missing or non-matching bearer → **Response** `401`: { "error": "Unauthorized" } ``` +Store failure → **Response** `503`: + +```json +{ "error": "Messages are unavailable" } +``` + Success → **Response** `200`: ```json @@ -1241,7 +1253,7 @@ Success → **Response** `200`: ### `POST /messages/:id/invoice` Signed-in pay-on-note. Bearer session required. `:id` is a UUID (`MESSAGE_ID_RE`). -Body `{ "sats": }`. The api signs a NIP-57 zap request with the +Body `{ "sats": }`. The api signs a NIP-57 zap request with the **payer** key and returns a BOLT11 invoice for the **author** Lightning Address. It does **not** increment `sats` (that happens when a validated kind:9735 receipt is indexed). After auth, every attempt with a valid UUID is persisted @@ -1257,7 +1269,7 @@ Success → **Response** `200`: ``` Missing Bearer → **401** `{ "error": "Unauthorized" }`. -Malformed body → **400** `{ "error": "Expected a JSON body with a positive \"sats\" integer" }`. +Malformed body or `sats` above 10 million → **400** `{ "error": "Expected a JSON body with a positive \"sats\" integer" }`. Unknown id → **404** `{ "error": "Not found" }`. Unsigned note, author without a Lightning Address, or missing recipient pubkey → **400** `{ "error": "This message cannot be paid yet" }`. Missing KEK → **503** `{ "error": "Messages are unavailable" }` (before the limiter). diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 80de7d84..038cb75c 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -45,14 +45,14 @@ ## Endpoint: GET /debug/invoices - **Purpose:** Operator listing of forum `POST /messages/:id/invoice` attempts newest-first (cap 200): result, HTTP status, BOLT11 `pr`, payment hash, description / description_hash, and `isNip57Invoice`. ISO `createdAt`. Never includes nsec. -- **Errors:** 503 `{ error: 'Debug is not configured' }` when `DEBUG_TOKEN` is unset or blank; 401 `{ error: 'Unauthorized' }` when the Bearer token does not match. +- **Errors:** 503 `{ error: 'Debug is not configured' }` when `DEBUG_TOKEN` is unset or blank; 401 `{ error: 'Unauthorized' }` when the Bearer token does not match; 503 `{ error: 'Messages are unavailable' }` when listing throws (`debug.invoices.list_failed`). - **Used by:** Operators debugging zap invoice issuance (including non-NIP-57 fallbacks). - **Auth:** `Authorization: Bearer` with `DEBUG_TOKEN`. Not an end-user session. ## Endpoint: GET /debug/zap-ingests - **Purpose:** Operator listing of kind:9735 ingest decisions newest-first (cap 200): `outcome` (`indexed` \| `rejected`), `reason`, receipt id, note/message ids, amount, and the receipt event frame. ISO `createdAt`. Never includes nsec. -- **Errors:** 503 `{ error: 'Debug is not configured' }` when `DEBUG_TOKEN` is unset or blank; 401 `{ error: 'Unauthorized' }` when the Bearer token does not match. +- **Errors:** 503 `{ error: 'Debug is not configured' }` when `DEBUG_TOKEN` is unset or blank; 401 `{ error: 'Unauthorized' }` when the Bearer token does not match; 503 `{ error: 'Messages are unavailable' }` when listing throws (`debug.zap_ingests.list_failed`). - **Used by:** Operators debugging zap receipt indexing. - **Auth:** `Authorization: Bearer` with `DEBUG_TOKEN`. Not an end-user session. @@ -212,7 +212,7 @@ ## Endpoint: POST /messages/:id/invoice -- **Purpose:** Bearer required. `:id` is a UUID. Body `{ sats }` (positive integer). Builds a NIP-57 kind:9734 zap request for the note, signs it with the payer's custodial key (ensuring one exists when KEK is present), and returns a BOLT11 `{ pr, amountSats }` via the author's LNURL-pay. After auth, valid-UUID attempts are persisted best-effort (`message_invoice`); persist failures do not change the HTTP response. The invoice rate limit is applied only after auth, amount, payable, and KEK checks. +- **Purpose:** Bearer required. `:id` is a UUID. Body `{ sats }` (integer 1..10_000_000). Builds a NIP-57 kind:9734 zap request for the note, signs it with the payer's custodial key (ensuring one exists when KEK is present), and returns a BOLT11 `{ pr, amountSats }` via the author's LNURL-pay. After auth, valid-UUID attempts are persisted best-effort (`message_invoice`); persist failures do not change the HTTP response. The invoice rate limit is applied only after auth, amount, payable, and KEK checks. - **Errors:** 401 Unauthorized; 400 bad body / note not yet payable; 404 Not found (unknown id or non-UUID `:id`, the latter without a persist row); 429 Too many payments (`Retry-After: 10`, after payable checks); 503 Messages are unavailable (missing KEK before limiter, or keygen/sign failure after). - **Used by:** App pay sheet for forum notes. - **Auth:** `Authorization: Bearer` session. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index e02ecd27..8b027232 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -235,7 +235,7 @@ - **Purpose:** Operator listing of forum invoice attempts (`message_invoice`) and kind:9735 ingest decisions (`nostr_zap_ingest`). - **Inputs:** `DebugPaymentsRouteDeps`: message store, optional debugToken. -- **Returns / side effects:** Hono app. 503 if token unset; 401 if bearer mismatches; 200 `{ invoices }` on `GET /invoices` and `{ ingests }` on `GET /zap-ingests`, newest-first (cap 200). Logs `debug.invoices.listed` / `debug.zap_ingests.listed` with count, never the token or nsec. +- **Returns / side effects:** Hono app. 503 if token unset; 401 if bearer mismatches; 200 `{ invoices }` on `GET /invoices` and `{ ingests }` on `GET /zap-ingests`, newest-first (cap 200). Store throws → 503 `{ error: 'Messages are unavailable' }` and `debug.invoices.list_failed` / `debug.zap_ingests.list_failed`. Logs `debug.invoices.listed` / `debug.zap_ingests.listed` with count, never the token or nsec. - **Used by:** `createApp` at `/debug`. ## Function: inspectBolt11 diff --git a/src/__tests__/routes/debug-payments.test.ts b/src/__tests__/routes/debug-payments.test.ts index 49358b45..0e0f44a2 100644 --- a/src/__tests__/routes/debug-payments.test.ts +++ b/src/__tests__/routes/debug-payments.test.ts @@ -153,4 +153,29 @@ describe('debugPaymentsRoutes', () => { expect(JSON.stringify(body)).not.toMatch(/nsec/i); expect(parsedEvents(warn).some((e) => e['event'] === 'debug.zap_ingests.listed')).toBe(true); }); + + it('returns 503 when listing invoices or ingests throws', async () => { + const boom = async (): Promise => { + throw new Error('list boom'); + }; + const store = { + listInvoiceAttempts: boom, + listZapIngests: boom, + } as unknown as InMemoryMessageStore; + const app = mount(store, 'secret'); + const invoices = await app.request('/debug/invoices', { + headers: { authorization: 'Bearer secret' }, + }); + expect(invoices.status).toBe(503); + expect(await invoices.json()).toEqual({ error: 'Messages are unavailable' }); + const ingests = await app.request('/debug/zap-ingests', { + headers: { authorization: 'Bearer secret' }, + }); + expect(ingests.status).toBe(503); + expect(await ingests.json()).toEqual({ error: 'Messages are unavailable' }); + expect(parsedEvents(warn).some((e) => e['event'] === 'debug.invoices.list_failed')).toBe(true); + expect(parsedEvents(warn).some((e) => e['event'] === 'debug.zap_ingests.list_failed')).toBe( + true, + ); + }); }); diff --git a/src/__tests__/routes/messages.test.ts b/src/__tests__/routes/messages.test.ts index 951210fa..d5333c72 100644 --- a/src/__tests__/routes/messages.test.ts +++ b/src/__tests__/routes/messages.test.ts @@ -753,6 +753,32 @@ describe('POST /messages/:id/invoice', () => { expect(res.status).toBe(400); }); + it('returns 400 and persists bad_body when sats exceed 10 million', async () => { + const messageStore = new InMemoryMessageStore(); + const app = new Hono().route( + '/messages', + messagesRoutes({ + store: messageStore, + authStore: await namedStore('Ada'), + now, + nostrKek: new Uint8Array(32).fill(1), + postLimiter: new PostRateLimiter(), + invoiceLimiter: new InvoiceRateLimiter(), + }), + ); + const res = await app.request('/messages/11111111-1111-4111-8111-111111111111/invoice', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ sats: 10_000_001 }), + }); + expect(res.status).toBe(400); + const attempts = await messageStore.listInvoiceAttempts(10); + expect(attempts).toHaveLength(1); + expect(attempts[0]?.result).toBe('bad_body'); + expect(attempts[0]?.httpStatus).toBe(400); + expect(attempts[0]?.pr).toBeNull(); + }); + it('returns 401 without a session', async () => { const res = await mount(new InMemoryAuthStore()).request('/messages/m1/invoice', { method: 'POST', diff --git a/src/routes/debug-payments.ts b/src/routes/debug-payments.ts index 0e2bcc74..d8433338 100644 --- a/src/routes/debug-payments.ts +++ b/src/routes/debug-payments.ts @@ -82,17 +82,27 @@ export function debugPaymentsRoutes(deps: DebugPaymentsRouteDeps): Hono { if (!gate.ok) { return c.json(gate.body, gate.status); } - const invoices = await deps.store.listInvoiceAttempts(DEBUG_LIST_LIMIT); - logEvent('debug.invoices.listed', { count: invoices.length }); - return c.json({ invoices: invoices.map(serializeInvoice) }, 200); + try { + const invoices = await deps.store.listInvoiceAttempts(DEBUG_LIST_LIMIT); + logEvent('debug.invoices.listed', { count: invoices.length }); + return c.json({ invoices: invoices.map(serializeInvoice) }, 200); + } catch { + logEvent('debug.invoices.list_failed'); + return c.json({ error: 'Messages are unavailable' }, 503); + } }) .get('/zap-ingests', async (c) => { const gate = gateDebugToken(deps.debugToken, c.req.header('authorization')); if (!gate.ok) { return c.json(gate.body, gate.status); } - const ingests = await deps.store.listZapIngests(DEBUG_LIST_LIMIT); - logEvent('debug.zap_ingests.listed', { count: ingests.length }); - return c.json({ ingests: ingests.map(serializeIngest) }, 200); + try { + const ingests = await deps.store.listZapIngests(DEBUG_LIST_LIMIT); + logEvent('debug.zap_ingests.listed', { count: ingests.length }); + return c.json({ ingests: ingests.map(serializeIngest) }, 200); + } catch { + logEvent('debug.zap_ingests.list_failed'); + return c.json({ error: 'Messages are unavailable' }, 503); + } }); } diff --git a/src/routes/messages.ts b/src/routes/messages.ts index a7cfb28c..0f844023 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { resolveSession } from '@/lib/auth/service'; import type { Account, AuthStore } from '@/lib/auth/store'; import { inspectBolt11, isNip57Invoice } from '@/lib/bolt11'; -import { GIFT_INVOICE_MAX_MSAT, GIFT_INVOICE_MIN_MSAT } from '@/lib/config'; +import { GIFT_INVOICE_MAX_MSAT } from '@/lib/config'; import { logEvent } from '@/lib/log'; import type { FetchFn } from '@/lib/lnurlp'; import { requestZapInvoice } from '@/lib/lnurl-pay'; @@ -310,8 +310,7 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { return c.json({ error: 'Expected a JSON body with a positive "sats" integer' }, 400); } const amountMsat = parsed.data.sats * 1000; - /* v8 ignore start -- zod already requires positive int; cap is extra */ - if (amountMsat < GIFT_INVOICE_MIN_MSAT || amountMsat > GIFT_INVOICE_MAX_MSAT) { + if (amountMsat > GIFT_INVOICE_MAX_MSAT) { await persistInvoiceAttempt( deps.store, invoiceAttemptBase({ @@ -332,7 +331,6 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { ); return c.json({ error: 'Expected a JSON body with a positive "sats" integer' }, 400); } - /* v8 ignore stop */ const row = await deps.store.getById(messageIdParam); if (row === undefined) { await persistInvoiceAttempt( From caf1dd28b6b4bfab4c21ef200bdb5b876de85cab Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Sun, 30 Aug 2026 15:59:09 +0200 Subject: [PATCH 07/12] 01a052b6 - Put #bitcoin and #21gifts in kind:1 content (#72) * 01a052b6 - Put #bitcoin and #21gifts in kind:1 content Damus renders hashtags from note text, not from hidden t tags. Append the same discovery tags to kind:1 content and re-sign unpaid notes that still lack them. Forum message text is unchanged. * 01a052b6 - Keep hashtags in worker tests that CI runs Photo notes without bytes still get #bitcoin #21gifts in content. Zap ingest fixtures must already include those tags so re-sign does not replace the event id the receipt points at. * 01a052b6 - Format kind:1 photo URL tests after rebase --------- Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- CONCEPT.md | 14 +-- FLOWS.md | 3 +- SPEC.md | 5 +- docs/handbook/functions.md | 22 ++++- e2e/functions.spec.ts | 8 ++ src/__tests__/lib/message-store.test.ts | 110 ++++++++++++++++++++++ src/__tests__/lib/nostr/event.test.ts | 28 +++++- src/__tests__/lib/nostr/sign.test.ts | 2 +- src/__tests__/lib/nostr/worker.test.ts | 85 +++++++++++++++-- src/__tests__/lib/nostr/zap-index.test.ts | 1 + src/__tests__/routes/messages.test.ts | 2 + src/lib/message-store.ts | 50 ++++++++++ src/lib/nostr/event.ts | 45 ++++++++- src/lib/nostr/worker.ts | 18 +++- 14 files changed, 361 insertions(+), 32 deletions(-) diff --git a/CONCEPT.md b/CONCEPT.md index 858e62ba..df9142f7 100644 --- a/CONCEPT.md +++ b/CONCEPT.md @@ -241,13 +241,13 @@ below applies to v1 for the surfaces v1 ships — profile metadata, campaign post, public comment; the DM and Zap-receipt rows stay deferred, see MVP scope. The client-side-signing flow beneath it is target state.) -| UI surface | NOSTR primitive | -| ------------------------------------- | -------------------------------------------------------------------------------------------- | -| Profile metadata (name, photo, story) | `kind:0` (NIP-01 metadata) | -| Receiver profile / campaign post | `kind:1` (text note), tagged with campaign metadata | -| Public comment / encouragement | top-level `kind:1` (frozen `t=bitcoin` / `t=21gifts` / `r=https://21.gifts`; no `e`/`p`/`q`) | -| Private message donor ↔ receiver | `kind:14` (NIP-17 sealed DM, modern) or `kind:4` (legacy) | -| Donation acknowledgement | `kind:9735` Zap receipt (when NIP-57 enabled) | +| UI surface | NOSTR primitive | +| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| Profile metadata (name, photo, story) | `kind:0` (NIP-01 metadata) | +| Receiver profile / campaign post | `kind:1` (text note), tagged with campaign metadata | +| Public comment / encouragement | top-level `kind:1` (frozen `t=bitcoin` / `t=21gifts` / `r=https://21.gifts`; no `e`/`p`/`q`; Damus-visible `#bitcoin #21gifts` in kind:1 **content**) | +| Private message donor ↔ receiver | `kind:14` (NIP-17 sealed DM, modern) or `kind:4` (legacy) | +| Donation acknowledgement | `kind:9735` Zap receipt (when NIP-57 enabled) | **Flow** — the app does not talk to NOSTR relays directly. It talks to the backend API, which acts as the user's edge to the network: diff --git a/FLOWS.md b/FLOWS.md index e09992ed..b1877a53 100644 --- a/FLOWS.md +++ b/FLOWS.md @@ -155,7 +155,8 @@ snapshotted at post, `sats`, `payable`, `hasPhoto`, and live author `role` is a messenger-group thread: oldest notes at the top, newest at the bottom, composer under the newest note. The welcome-forum living-room laws hint is dismissed via `POST /me/forum-laws-dismissed`. Posts are standalone kind:1 -notes; the worker fans out when `NOSTR_PUBLISH=1`. Pay-on-note is +notes (Damus-visible `#bitcoin` / `#21gifts` in content; forum `text` unchanged); +the worker fans out when `NOSTR_PUBLISH=1`. Pay-on-note is `POST /messages/:id/invoice`. Do not invent `/events` or `/comments` paths. Private donor↔receiver DMs (NIP-17) are **out of v1** (CONCEPT deferred). Do diff --git a/SPEC.md b/SPEC.md index ec0da848..12f1a422 100644 --- a/SPEC.md +++ b/SPEC.md @@ -4,7 +4,7 @@ > Product decisions live in [`CONCEPT.md`](./CONCEPT.md); this file owns > request/response contracts for routes that exist in code today. -**Status**: living document. Last revised 2026-08-30 (`POST /debug/accounts` provision; `POST /auth/passkey/register/begin` optional `{ viewKey }` claim; `POST /me/lightning-address` `409` when the address is taken; `GET /debug/invoices` and `GET /debug/zap-ingests`; kind:0 `picture` + NIP-65 kind:10002; kind:1 NIP-92 `imeta` photo URLs; public `GET /messages/:id/photo`; forum `account.role` `basis`\|`verified`\|`moderator`\|`founder`; live `role` on `GET/POST /messages`; `PATCH /debug/accounts/:id`; private in-app `POST /contact` + `GET /debug/contacts`; `POST /me/lightning-address` live-resolves and requires zap metadata; invoice limiter after payable checks; public forum `GET/POST /messages` with `sats`/`payable`/`hasPhoto`; worker indexes kind:9735 zap receipts onto `sats`; `POST /messages/:id/invoice` NIP-57 zap; SQL boot requires `NOSTR_NSEC_KEK`; passkey-only login; gift stats BTC + historical USD via Coinbase daily close; `GET /gifts?day=`). +**Status**: living document. Last revised 2026-08-30 (kind:1 content includes Damus-visible `#bitcoin` / `#21gifts`; `POST /debug/accounts` provision; `POST /auth/passkey/register/begin` optional `{ viewKey }` claim; `POST /me/lightning-address` `409` when the address is taken; `GET /debug/invoices` and `GET /debug/zap-ingests`; kind:0 `picture` + NIP-65 kind:10002; kind:1 NIP-92 `imeta` photo URLs; public `GET /messages/:id/photo`; forum `account.role` `basis`\|`verified`\|`moderator`\|`founder`; live `role` on `GET/POST /messages`; `PATCH /debug/accounts/:id`; private in-app `POST /contact` + `GET /debug/contacts`; `POST /me/lightning-address` live-resolves and requires zap metadata; invoice limiter after payable checks; public forum `GET/POST /messages` with `sats`/`payable`/`hasPhoto`; worker indexes kind:9735 zap receipts onto `sats`; `POST /messages/:id/invoice` NIP-57 zap; SQL boot requires `NOSTR_NSEC_KEK`; passkey-only login; gift stats BTC + historical USD via Coinbase daily close; `GET /gifts?day=`). --- @@ -1188,7 +1188,8 @@ trim, or with disallowed C0/DEL controls, is rejected. Newlines (`\n`, `payable` is false until the worker signs the note. `role` is the posting session account's live `account.role`. Over-limit posters get **429** `{ "error": "Too many messages" }` with `Retry-After: 10` (1/10s, 6/h, -20/UTC-day). The worker signs a top-level kind:1 and fans out when +20/UTC-day). The worker signs a top-level kind:1 (content includes Damus-visible +`#bitcoin` and `#21gifts`; forum `text` stays the member's words) and fans out when `NOSTR_PUBLISH=1`. Missing/invalid/expired bearer → **Response** `401`: diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 8b027232..5601c793 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -149,7 +149,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; `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`); `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; `getPhoto` loads bytes by id; `getById`; `getByEventId` (`WHERE event_id`); `claimUnsigned`/`claimUnpublished` lease rows; `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 signed unpaid rows whose kind:1 content lacks `#bitcoin` or `#21gifts` (any publish state, `sats = 0`, 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. @@ -261,7 +261,7 @@ ## 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, `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), `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). +- **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, `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` (signed unpaid, kind:1 content lacks `#bitcoin` or `#21gifts`, oldest-first, any publish state, `sats === 0`, includes null / non-string content), `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`; `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. - **Used by:** `createApp` default `messageStore`. @@ -770,6 +770,20 @@ - **Returns / side effects:** `[["t","bitcoin"],["t","21gifts"],["r","https://21.gifts"]]`. - **Used by:** `buildKind1Event`. +## Function: kind1HasHashtag + +- **Purpose:** Case-insensitive check that kind:1 content already contains `#name` as a hashtag token (the `#` prefix distinguishes `#21gifts` from `https://21.gifts`). +- **Inputs:** content string, hashtag name without `#`. +- **Returns / side effects:** boolean. +- **Used by:** `kind1ContentWithHashtags`. + +## Function: kind1ContentWithHashtags + +- **Purpose:** Append any missing Damus-visible `#bitcoin` / `#21gifts` to Nostr kind:1 content (forum DB `text` stays unchanged). Empty → `"#bitcoin #21gifts"`; non-empty strips trailing newlines then appends `\n\n` + missing tags in fixed order; already-present tags (any case) are not duplicated. +- **Inputs:** content string. +- **Returns / side effects:** content with missing hashtags appended. +- **Used by:** `buildKind1Event`; `listSignedMissingHashtags` (in-memory helper). + ## Function: forumPhotoUrl - **Purpose:** Absolute `GET /messages/:id/photo.jpg` (or `.png` / `.webp`) URL for kind:1 content and `imeta`. The extension matches the stored MIME so Damus treats the URL as an image, not a website. @@ -779,7 +793,7 @@ ## 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. +- **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 appends Damus-visible `#bitcoin` / `#21gifts` via `kind1ContentWithHashtags` (forum row `text` is not modified). - **Inputs:** content, unix created_at, optional `{ url, mime }`. - **Returns / side effects:** Unsigned fields. - **Used by:** Worker sign path. @@ -940,7 +954,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. Published photo posts missing the public photo URL are reset and re-signed only when `PUBLIC_BASE_URL` is set and `sats` is 0 (zapped rows keep `eventId` so receipts still resolve). Pending URL-less photo notes are EVENT'd as-is so a reset cannot renew the sign lease and starve fan-out. An empty API base leaves them alone. 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. Published photo posts missing the public photo URL are reset and re-signed only when `PUBLIC_BASE_URL` is set and `sats` is 0 (zapped rows keep `eventId` so receipts still resolve). Pending URL-less photo notes are EVENT'd as-is so a reset cannot renew the sign lease and starve fan-out. An empty API base leaves them alone. Sign looks up photo bytes even when `hasPhoto` is stale. Signed unpaid notes whose kind:1 content lacks `#bitcoin` or `#21gifts` are reset and re-signed; zapped rows keep `eventId`. 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. - **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. diff --git a/e2e/functions.spec.ts b/e2e/functions.spec.ts index 37d698c5..b2e80490 100644 --- a/e2e/functions.spec.ts +++ b/e2e/functions.spec.ts @@ -784,6 +784,14 @@ test('Function: generateNostrKeyRecord — default boot has no DATABASE_URL', as test('Function: kind1Tags — default boot has no DATABASE_URL', async ({ request }) => { expect((await request.get('/healthz')).status()).toBe(200); }); +test('Function: kind1HasHashtag — default boot has no DATABASE_URL', async ({ request }) => { + expect((await request.get('/healthz')).status()).toBe(200); +}); +test('Function: kind1ContentWithHashtags — default boot has no DATABASE_URL', async ({ + request, +}) => { + expect((await request.get('/healthz')).status()).toBe(200); +}); test('Function: buildKind1Event — default boot has no DATABASE_URL', async ({ request }) => { expect((await request.get('/healthz')).status()).toBe(200); }); diff --git a/src/__tests__/lib/message-store.test.ts b/src/__tests__/lib/message-store.test.ts index 2ac77ae9..474351b4 100644 --- a/src/__tests__/lib/message-store.test.ts +++ b/src/__tests__/lib/message-store.test.ts @@ -335,6 +335,90 @@ describe('InMemoryMessageStore', () => { expect((await store.getById('a'))?.eventId).toBe('cd'.repeat(32)); }); + it('listSignedMissingHashtags finds unpaid notes missing Damus hashtags', async () => { + const store = new InMemoryMessageStore(); + const jpeg: ForumPhoto = { + contentType: 'image/jpeg', + bytes: new Uint8Array([0xff, 0xd8, 0xff, 0xd9]), + }; + await store.create({ ...EARLY, text: 'ohne foto funktioniert es' }); + await store.updateSignedEvent('a', 'ab'.repeat(32), { + content: 'ohne foto funktioniert es', + }); + await store.updatePublishState('a', 'published', 'space'); + await store.create({ + ...EARLY, + id: 'n', + createdAt: new Date('2026-07-01T00:00:00.000Z'), + eventId: '11'.repeat(32), + nostrEvent: null, + }); + await store.create({ + ...EARLY, + id: 'z', + createdAt: new Date('2026-09-01T00:00:00.000Z'), + eventId: '22'.repeat(32), + nostrEvent: { content: 1 }, + }); + const tiedAt = new Date('2026-08-15T00:00:00.000Z'); + await store.create({ + ...EARLY, + id: 'q', + text: 'only bitcoin', + createdAt: tiedAt, + eventId: '33'.repeat(32), + nostrEvent: { content: 'only bitcoin\n\n#bitcoin' }, + }); + await store.create({ + ...EARLY, + id: 'p', + text: 'only 21gifts', + createdAt: tiedAt, + eventId: '44'.repeat(32), + nostrEvent: { content: 'only 21gifts\n\n#21gifts' }, + }); + await store.create( + { + ...EARLY, + id: 'c', + text: 'complete', + createdAt: new Date('2026-08-20T00:00:00.000Z'), + hasPhoto: true, + eventId: '55'.repeat(32), + nostrEvent: { + content: 'complete\nhttp://127.0.0.1:3000/messages/c/photo\n\n#bitcoin #21gifts', + }, + }, + jpeg, + ); + expect((await store.listSignedMissingHashtags(10)).map((row) => row.id)).toEqual([ + 'n', + 'a', + 'p', + 'q', + 'z', + ]); + expect((await store.listSignedMissingHashtags(2)).map((row) => row.id)).toEqual(['n', 'a']); + await store.addSats('z', 21); + expect((await store.listSignedMissingHashtags(10)).map((row) => row.id)).toEqual([ + 'n', + 'a', + 'p', + 'q', + ]); + 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: 'ohne foto funktioniert es\n\n#bitcoin #21gifts', + }); + expect((await store.listSignedMissingHashtags(10)).map((row) => row.id)).toEqual([ + 'n', + 'p', + 'q', + ]); + }); + it('listPendingSigned skips pending rows that already have t=bitcoin', async () => { const store = new InMemoryMessageStore(); await store.create(LATE); @@ -819,6 +903,32 @@ describe('PostgresMessageStore', () => { expect(sql.executes.at(-1)?.text).toMatch(/sats = 0/); }); + it('listSignedMissingHashtags hits Postgres', async () => { + const sql = new MockSql(); + sql.nextRows = [ + { + id: 'm1', + account_id: 'acc', + name: 'Ada', + text: 'ohne foto funktioniert es', + created_at: new Date(0), + has_photo: false, + event_id: 'ab'.repeat(32), + nostr_publish_state: 'published', + sats: 0, + }, + ]; + const store = new PostgresMessageStore(sql); + const missing = await store.listSignedMissingHashtags(4); + expect(missing[0]?.id).toBe('m1'); + const listSql = sql.queries.at(-1)?.text ?? ''; + expect(listSql).toMatch(/sats = 0/); + expect(listSql).toMatch(/jsonb_typeof\(nostr_event->'content'\) IS DISTINCT FROM 'string'/); + expect(listSql).toMatch(/NOT LIKE '%#21gifts%'/); + expect(listSql).toMatch(/NOT LIKE '%#bitcoin%'/); + expect(listSql).toMatch(/ORDER BY created_at ASC,\s*id ASC/); + }); + it('propagates getPhoto query errors', async () => { const sql = new MockSql(); sql.queryError = new Error('photo boom'); diff --git a/src/__tests__/lib/nostr/event.test.ts b/src/__tests__/lib/nostr/event.test.ts index cccc0e22..ce74c58d 100644 --- a/src/__tests__/lib/nostr/event.test.ts +++ b/src/__tests__/lib/nostr/event.test.ts @@ -6,6 +6,8 @@ import { buildKind1Event, buildKind10002Event, forumPhotoUrl, + kind1ContentWithHashtags, + kind1HasHashtag, kind1Tags, } from '@/lib/nostr/event'; @@ -13,7 +15,7 @@ describe('kind1', () => { it('uses frozen tags and no name prefix', () => { const event = buildKind1Event('hello', 1_700_000_000); expect(event.kind).toBe(1); - expect(event.content).toBe('hello'); + expect(event.content).toBe('hello\n\n#bitcoin #21gifts'); expect(event.tags).toEqual([ ['t', 'bitcoin'], ['t', '21gifts'], @@ -27,7 +29,9 @@ describe('kind1', () => { url: 'http://127.0.0.1:3000/messages/m1/photo.jpg', mime: 'image/jpeg', }); - expect(event.content).toBe('hello\nhttp://127.0.0.1:3000/messages/m1/photo.jpg'); + expect(event.content).toBe( + 'hello\nhttp://127.0.0.1:3000/messages/m1/photo.jpg\n\n#bitcoin #21gifts', + ); expect(event.tags.at(-1)).toEqual([ 'imeta', 'url http://127.0.0.1:3000/messages/m1/photo.jpg', @@ -40,7 +44,25 @@ describe('kind1', () => { url: 'http://127.0.0.1:3000/messages/m1/photo.png', mime: 'image/png', }); - expect(event.content).toBe('http://127.0.0.1:3000/messages/m1/photo.png'); + expect(event.content).toBe('http://127.0.0.1:3000/messages/m1/photo.png\n\n#bitcoin #21gifts'); + }); + + it('does not treat https://21.gifts as #21gifts', () => { + expect(kind1HasHashtag('see https://21.gifts', '21gifts')).toBe(false); + expect(kind1ContentWithHashtags('see https://21.gifts')).toBe( + 'see https://21.gifts\n\n#bitcoin #21gifts', + ); + expect(buildKind1Event('see https://21.gifts', 1).content).toBe( + 'see https://21.gifts\n\n#bitcoin #21gifts', + ); + }); + + it('appends only missing hashtags and leaves complete content alone', () => { + expect(kind1ContentWithHashtags('')).toBe('#bitcoin #21gifts'); + expect(kind1ContentWithHashtags('hello #21gifts')).toBe('hello #21gifts\n\n#bitcoin'); + expect(kind1ContentWithHashtags('x\n\n#bitcoin #21gifts')).toBe('x\n\n#bitcoin #21gifts'); + expect(kind1ContentWithHashtags('hello #21Gifts')).toBe('hello #21Gifts\n\n#bitcoin'); + expect(kind1HasHashtag('note #Bitcoin here', 'bitcoin')).toBe(true); }); }); diff --git a/src/__tests__/lib/nostr/sign.test.ts b/src/__tests__/lib/nostr/sign.test.ts index c51c7c0e..52fb953b 100644 --- a/src/__tests__/lib/nostr/sign.test.ts +++ b/src/__tests__/lib/nostr/sign.test.ts @@ -31,7 +31,7 @@ describe('signEventForAccount', () => { ); expect(signed.id).toMatch(/^[0-9a-f]{64}$/); expect(signed.kind).toBe(1); - expect(signed.content).toBe('hi'); + expect(signed.content).toBe('hi\n\n#bitcoin #21gifts'); }); it('throws when the account has no secret', async () => { diff --git a/src/__tests__/lib/nostr/worker.test.ts b/src/__tests__/lib/nostr/worker.test.ts index 2ad06640..459790ec 100644 --- a/src/__tests__/lib/nostr/worker.test.ts +++ b/src/__tests__/lib/nostr/worker.test.ts @@ -149,7 +149,7 @@ describe('runNostrWorkerTick', () => { }); await messages.updateSignedEvent(id, `${i.toString(16).padStart(2, '0')}`.repeat(32), { kind: 1, - content: `n${i}`, + content: `n${i}\n\n#bitcoin #21gifts`, tags: modern, created_at: 1, }); @@ -170,11 +170,14 @@ describe('runNostrWorkerTick', () => { env: {}, }), ); - expect((await messages.getById('m1'))?.nostrEvent?.['tags']).toEqual([ + const m1 = await messages.getById('m1'); + expect(m1?.nostrEvent?.['tags']).toEqual([ ['t', 'bitcoin'], ['t', '21gifts'], ['r', 'https://21.gifts'], ]); + expect(String(m1?.nostrEvent?.['content'])).toContain('#bitcoin'); + expect(String(m1?.nostrEvent?.['content'])).toContain('#21gifts'); }); it('leaves pending kind:1 events that already have t=bitcoin', async () => { @@ -187,7 +190,7 @@ describe('runNostrWorkerTick', () => { const eventId = 'cd'.repeat(32); await messages.updateSignedEvent('m1', eventId, { kind: 1, - content: 'hello', + content: 'hello\n\n#bitcoin #21gifts', tags, created_at: 1, }); @@ -204,6 +207,67 @@ describe('runNostrWorkerTick', () => { expect((await messages.getById('m1'))?.eventId).toBe(eventId); }); + it('re-signs published unpaid notes whose content lacks Damus hashtags', async () => { + const { auth, messages } = await seed(); + const tags = [ + ['t', 'bitcoin'], + ['t', '21gifts'], + ['r', 'https://21.gifts'], + ]; + await messages.create({ + id: 'm-hashtag', + accountId: 'acc', + name: 'Ada', + text: 'ohne foto funktioniert es', + createdAt: new Date('2026-08-28T00:10:00.000Z'), + hasPhoto: false, + ...unsignedNostrDefaults(), + }); + await messages.updateSignedEvent('m-hashtag', 'ab'.repeat(32), { + kind: 1, + content: 'ohne foto funktioniert es', + tags, + created_at: 1, + }); + await messages.updatePublishState('m-hashtag', 'published', 'space'); + await messages.create({ + id: 'm-hashtag-zapped', + accountId: 'acc', + name: 'Ada', + text: 'ohne foto funktioniert es', + createdAt: new Date('2026-08-28T00:11:00.000Z'), + hasPhoto: false, + ...unsignedNostrDefaults(), + }); + await messages.updateSignedEvent('m-hashtag-zapped', 'cd'.repeat(32), { + kind: 1, + content: 'ohne foto funktioniert es', + tags, + created_at: 1, + }); + await messages.updatePublishState('m-hashtag-zapped', 'published', 'space'); + await messages.addSats('m-hashtag-zapped', 21); + await runNostrWorkerTick( + deps({ + messages, + auth, + kek: KEK, + publisher: new RecordingPublisher(), + now: () => 1_700_000_000_000, + env: {}, + }), + ); + const unpaid = await messages.getById('m-hashtag'); + expect(unpaid?.eventId).not.toBe('ab'.repeat(32)); + expect(String(unpaid?.nostrEvent?.['content'])).toContain('#bitcoin'); + expect(String(unpaid?.nostrEvent?.['content'])).toContain('#21gifts'); + expect(String(unpaid?.nostrEvent?.['content'])).toContain('ohne foto funktioniert es'); + const zapped = await messages.getById('m-hashtag-zapped'); + expect(zapped?.eventId).toBe('cd'.repeat(32)); + expect(zapped?.nostrEvent?.['content']).toBe('ohne foto funktioniert es'); + expect(zapped?.sats).toBe(21); + }); + it('re-signs pending rows whose stored event has no tag array', async () => { const { auth, messages } = await seed(); await messages.updateSignedEvent('m1', 'ef'.repeat(32), { kind: 1, content: 'hello' }); @@ -431,7 +495,9 @@ describe('runNostrWorkerTick', () => { const notes = publisher.calls .filter((call) => call.event['kind'] === 1) .map((call) => String(call.event['content'])); - expect(notes).toContain('pic\nhttps://dev-api.21.gifts/messages/m-pic/photo.jpg'); + expect(notes).toContain( + 'pic\nhttps://dev-api.21.gifts/messages/m-pic/photo.jpg\n\n#bitcoin #21gifts', + ); const note = publisher.calls.find( (call) => call.event['kind'] === 1 && String(call.event['content']).includes('m-pic/photo'), ); @@ -513,7 +579,7 @@ describe('runNostrWorkerTick', () => { ); await messages.updateSignedEvent('m-nophoto-url', 'ab'.repeat(32), { kind: 1, - content: '', + content: '#bitcoin #21gifts', tags: [ ['t', 'bitcoin'], ['t', '21gifts'], @@ -610,6 +676,7 @@ describe('runNostrWorkerTick', () => { ], }); messages.listSignedMissingPhoto = async () => []; + messages.listSignedMissingHashtags = async () => []; const publisher = new RecordingPublisher(); await runNostrWorkerTick( deps({ @@ -677,7 +744,7 @@ describe('runNostrWorkerTick', () => { ); const row = await messages.getById('m-missing-bytes'); expect(row?.eventId).toMatch(/^[0-9a-f]{64}$/); - expect(row?.nostrEvent?.['content']).toBe('hallo'); + expect(row?.nostrEvent?.['content']).toBe('hallo\n\n#bitcoin #21gifts'); expect(row?.nostrPublishState).toBe('published'); }); @@ -707,6 +774,7 @@ describe('runNostrWorkerTick', () => { }); await messages.addSats('m-zap-pending', 7); messages.listSignedMissingPhoto = async () => []; + messages.listSignedMissingHashtags = async () => []; const publisher = new RecordingPublisher(); await runNostrWorkerTick( deps({ @@ -768,13 +836,13 @@ describe('runNostrWorkerTick', () => { ); const row = await messages.getById('m-plain-photo'); expect(row?.eventId).toMatch(/^[0-9a-f]{64}$/); - expect(row?.nostrEvent?.['content']).toBe(''); + expect(row?.nostrEvent?.['content']).toBe('#bitcoin #21gifts'); expect(row?.nostrPublishState).toBe('published'); expect( publisher.calls.some( (call) => call.event['kind'] === 1 && - call.event['content'] === '' && + call.event['content'] === '#bitcoin #21gifts' && call.event['id'] === row?.eventId, ), ).toBe(true); @@ -1865,6 +1933,7 @@ describe('runNostrWorkerTick', () => { nostrEvent: { id: eventId, kind: 1, + content: 'hello\n\n#bitcoin #21gifts', tags: [ ['t', 'bitcoin'], ['t', '21gifts'], diff --git a/src/__tests__/lib/nostr/zap-index.test.ts b/src/__tests__/lib/nostr/zap-index.test.ts index 4b530525..57c81783 100644 --- a/src/__tests__/lib/nostr/zap-index.test.ts +++ b/src/__tests__/lib/nostr/zap-index.test.ts @@ -1065,6 +1065,7 @@ describe('indexOpenZapReceipts', () => { base.claimUnpublished(...args), listPendingSigned: (limit: number) => base.listPendingSigned(limit), listSignedMissingPhoto: (limit: number) => base.listSignedMissingPhoto(limit), + listSignedMissingHashtags: (limit: number) => base.listSignedMissingHashtags(limit), clearSignedEvent: (...args: Parameters) => base.clearSignedEvent(...args), resetSignedEvent: (...args: Parameters) => diff --git a/src/__tests__/routes/messages.test.ts b/src/__tests__/routes/messages.test.ts index d5333c72..26fc40aa 100644 --- a/src/__tests__/routes/messages.test.ts +++ b/src/__tests__/routes/messages.test.ts @@ -90,6 +90,7 @@ function throwingStore(overrides: Partial = {}): MessageStore { claimUnpublished: boom, listPendingSigned: boom, listSignedMissingPhoto: boom, + listSignedMissingHashtags: boom, clearSignedEvent: boom, resetSignedEvent: boom, updateSignedEvent: boom, @@ -1352,6 +1353,7 @@ describe('POST /messages/:id/invoice', () => { claimUnpublished: (...args) => base.claimUnpublished(...args), listPendingSigned: (limit) => base.listPendingSigned(limit), listSignedMissingPhoto: (limit) => base.listSignedMissingPhoto(limit), + listSignedMissingHashtags: (limit) => base.listSignedMissingHashtags(limit), clearSignedEvent: (...args) => base.clearSignedEvent(...args), resetSignedEvent: (...args) => base.resetSignedEvent(...args), updateSignedEvent: (...args) => base.updateSignedEvent(...args), diff --git a/src/lib/message-store.ts b/src/lib/message-store.ts index 03e3c377..3c447b30 100644 --- a/src/lib/message-store.ts +++ b/src/lib/message-store.ts @@ -14,6 +14,7 @@ import { type MessageRow, type NostrPublishState, } from '@/lib/message'; +import { kind1ContentWithHashtags } from '@/lib/nostr/event'; import { normalizeSignedEvent } from '@/lib/nostr/publish'; function kind1MissingPhotoUrl(event: Record | null, messageId: string): boolean { @@ -24,6 +25,14 @@ function kind1MissingPhotoUrl(event: Record | null, messageId: return typeof content !== 'string' || !content.includes(`/messages/${messageId}/photo.`); } +function kind1MissingHashtags(event: Record | null): boolean { + if (event === null) { + return true; + } + const content = event['content']; + return typeof content !== 'string' || kind1ContentWithHashtags(content) !== content; +} + function pendingKind1LacksBitcoinTag(event: Record | null): boolean { if (event === null) { return true; @@ -112,6 +121,15 @@ export interface MessageStore { */ listSignedMissingPhoto(limit: number): Promise; + /** + * Signed rows whose kind:1 content lacks `#21gifts` or `#bitcoin` (case-insensitive). + * Any publish state, `sats = 0` only. Oldest `createdAt` then `id` first. + * Includes `nostrEvent === null` and non-string content. + * + * @param limit - Max rows. + */ + listSignedMissingHashtags(limit: number): Promise; + /** * Clear the signed event and park the row `pending` so it is signed again. * No-op unless `eventId` still matches `expectedEventId` and `sats` is 0. @@ -467,6 +485,20 @@ export class InMemoryMessageStore implements MessageStore { return Promise.resolve(rows); } + listSignedMissingHashtags(limit: number): Promise { + const rows = this.#rows + .filter( + (row) => row.eventId !== null && row.sats === 0 && kind1MissingHashtags(row.nostrEvent), + ) + .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); + } + resetSignedEvent(id: string, expectedEventId: string | null): Promise { const row = this.#rows.find((item) => item.id === id); if (row !== undefined && row.eventId === expectedEventId && row.sats === 0) { @@ -820,6 +852,24 @@ export class PostgresMessageStore implements MessageStore { return rows.map((row) => mapMessageRow(row)); } + async listSignedMissingHashtags(limit: number): Promise { + const rows = await this.#sql.query( + `SELECT ${MESSAGE_SELECT_COLUMNS} + FROM message + WHERE event_id IS NOT NULL AND sats = 0 + AND ( + nostr_event IS NULL + OR jsonb_typeof(nostr_event->'content') IS DISTINCT FROM 'string' + OR LOWER(COALESCE(nostr_event->>'content', '')) NOT LIKE '%#21gifts%' + OR LOWER(COALESCE(nostr_event->>'content', '')) NOT LIKE '%#bitcoin%' + ) + ORDER BY created_at ASC, id ASC + LIMIT $1`, + [limit], + ); + return rows.map((row) => mapMessageRow(row)); + } + async resetSignedEvent(id: string, expectedEventId: string | null): Promise { await this.#sql.execute( `UPDATE message SET event_id = NULL, nostr_event = NULL, claimed_until = NULL, diff --git a/src/lib/nostr/event.ts b/src/lib/nostr/event.ts index a63edf00..2cdc4b85 100644 --- a/src/lib/nostr/event.ts +++ b/src/lib/nostr/event.ts @@ -16,6 +16,12 @@ export const KIND1_TAGS: readonly [ ['r', 'https://21.gifts'], ] as const; +/** Damus-visible hashtags appended to kind:1 content (order fixed). */ +export const KIND1_CONTENT_HASHTAGS: readonly ['#bitcoin', '#21gifts'] = [ + '#bitcoin', + '#21gifts', +] as const; + /** 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'; @@ -67,11 +73,44 @@ export function kind1Tags(): string[][] { return KIND1_TAGS.map((tag) => [...tag]); } +/** + * True when `content` already contains `#name` as a hashtag (case-insensitive). + * + * @param content - Kind:1 content body. + * @param name - Hashtag name without `#` (e.g. `bitcoin`). + */ +export function kind1HasHashtag(content: string, name: string): boolean { + return content.toLowerCase().includes(`#${name.toLowerCase()}`); +} + +/** + * Append any missing `#bitcoin` / `#21gifts` so Damus renders them. + * Forum text is unchanged by the caller; this only shapes Nostr content. + * + * Empty content → `"#bitcoin #21gifts"` (no leading blank line). + * Non-empty → trailing newlines stripped, then `\n\n` + missing tags joined by a single space. + * Already-present tags (any case, e.g. `#21Gifts`) are not duplicated; only missing ones are appended, still in KIND1_CONTENT_HASHTAGS order. + * + * @param content - Forum text and optional photo URL already composed. + * @returns Content with any missing hashtags appended. + */ +export function kind1ContentWithHashtags(content: string): string { + const missing = KIND1_CONTENT_HASHTAGS.filter((tag) => !kind1HasHashtag(content, tag.slice(1))); + if (missing.length === 0) { + return content; + } + const suffix = missing.join(' '); + if (content === '') { + return suffix; + } + return `${content.replace(/\n+$/, '')}\n\n${suffix}`; +} + /** Unsigned kind:1 fields before `finalizeEvent`. */ export interface UnsignedKind1 { /** Kind 1. */ kind: 1; - /** Exact `normalizeForumText` output. */ + /** Forum text (plus optional photo URL) with Damus-visible `#bitcoin` / `#21gifts`. */ content: string; /** Frozen tags. */ tags: string[][]; @@ -82,7 +121,8 @@ export interface UnsignedKind1 { /** * Build an unsigned top-level kind:1 for a forum message. * - * Content is plaintext (no name prefix). Tags are frozen — no `e`/`p`/`q`. + * Content is plaintext (no name prefix) plus Damus-visible `#bitcoin` / + * `#21gifts`. Tags are frozen — no `e`/`p`/`q`. * * @param content - Already-normalised forum text (may be empty when `photo` is set). * @param createdAtUnix - Unix seconds for the event. @@ -100,6 +140,7 @@ export function buildKind1Event( body = content === '' ? photo.url : `${content}\n${photo.url}`; tags.push(['imeta', `url ${photo.url}`, `m ${photo.mime}`]); } + body = kind1ContentWithHashtags(body); return { kind: 1, content: body, diff --git a/src/lib/nostr/worker.ts b/src/lib/nostr/worker.ts index e3d0deab..70cd3b76 100644 --- a/src/lib/nostr/worker.ts +++ b/src/lib/nostr/worker.ts @@ -105,10 +105,12 @@ function reservedContent( * Pending rows are EVENT'd even without the URL — resetting them first * renews the sign lease and they never reach a relay. Zapped rows keep * their event id so receipts still resolve. An empty API base leaves - * them alone 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 + * them alone so it cannot un-publish and loop. Signed unpaid notes whose + * kind:1 content lacks `#bitcoin` or `#21gifts` are reset and re-signed; + * zapped rows keep `eventId`. 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 * `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 @@ -122,6 +124,7 @@ export async function runNostrWorkerTick(deps: NostrWorkerDeps): Promise { const nowMs = deps.now(); await resignLegacyKind1Tags(deps); await resignPhotoKind1(deps); + await resignHashtagKind1(deps); await signBatch(deps, nowMs); if (writeSet.publishEnabled) { await publishProfiles(deps, writeSet); @@ -163,6 +166,13 @@ async function resignPhotoKind1(deps: NostrWorkerDeps): Promise { } } +async function resignHashtagKind1(deps: NostrWorkerDeps): Promise { + const rows = await deps.messages.listSignedMissingHashtags(WORKER_BATCH); + for (const row of rows) { + await deps.messages.resetSignedEvent(row.id, row.eventId); + } +} + function kind1HasBitcoinTag(event: Record | null): boolean { if (event === null) { return false; From 9c063fb102b3a89438add96faee9bc4ae27e81d8 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Sun, 30 Aug 2026 16:37:06 +0200 Subject: [PATCH 08/12] 01a051f7 - Add self-hosted Web Push with VAPID and an outbox worker (#67) * Add self-hosted Web Push with VAPID and an outbox worker Store browser subscriptions on the account, enqueue forum and zap notifications, and send them when VAPID is configured. Missing keys keep the process booting; push HTTP returns 503. * Reject malformed VAPID keys without failing boot Missing keys already returned 503. Garbage keys used to throw inside web-push during sender construction and would take down the process. Validate decoded lengths and subject, and catch constructor throws. * Fix unconfigured push-route tests that used a default VAPID key Passing undefined into the helper still applied the configured default, so POST returned 200 and DELETE 404 instead of 503. * Pin VAPID off in Playwright and assert debug ping 401 without bearer Default e2e boot already sets DEBUG_TOKEN, so an unauthenticated debug-ping is 401. Blank VAPID env so host keys cannot leak into the booted process. * Keep HTTP push off when VAPID construct fails and return stored createdAt Boot now passes an explicit empty public key so createApp cannot pick VAPID back up from the environment after a sender throw. Subscription upsert returns the persisted createdAt, including rebinds. * Cover push enqueue failures and drop a dead worker branch Forum and zap ingest now have tests for enqueue throws. The worker tick marks sent or failed without an unreachable else. Document upsert return values. * Document forum push enqueue and format CONTRIBUTING after rebase messagesRoutes handbook and TSDoc now mention optional pushStore. Prettier the env table after merging develop. * Cover remaining VAPID branches and skip overlapping worker ticks Empty/invalid base64, non-numeric push status codes, and Date leases are tested. The interval will not start a second tick until the first has settled. * Retrigger pull_request CI on the current head * Format docs after rebasing onto develop * Split Date vs null claimed_until mapping tests The null-lease case must stay null. Map a Date lease in its own test so v8 covers the instanceof Date branch. * Cover Date vs string timestamps and env VAPID on createApp Postgres mappers now see both Date and ISO string created_at. createApp without vapidPublicKey still reads a valid env pair. * Use a current session timestamp in the env VAPID createApp test createdAt of 1 was expired and evicted, so the request returned 401. --------- Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- CONCEPT.md | 1 + CONTRIBUTING.md | 68 ++- FLOWS.md | 29 +- SPEC.md | 82 +++- bun.lock | 34 ++ docs/handbook/endpoints.md | 28 ++ docs/handbook/functions.md | 144 +++++- docs/schema/db_change.sql | 7 +- docs/schema/push.sql | 25 + e2e/functions.spec.ts | 69 +++ e2e/http.spec.ts | 28 ++ package.json | 2 + playwright.config.ts | 3 + scripts/check-handbook.mjs | 2 + src/__tests__/lib/boot-stores.test.ts | 64 ++- src/__tests__/lib/db-change.test.ts | 3 + src/__tests__/lib/nostr/worker.test.ts | 2 + src/__tests__/lib/nostr/zap-index.test.ts | 124 +++++ src/__tests__/lib/push-config.test.ts | 123 +++++ src/__tests__/lib/push-sender.test.ts | 100 ++++ src/__tests__/lib/push-store.test.ts | 337 +++++++++++++ src/__tests__/lib/push-worker.test.ts | 205 ++++++++ src/__tests__/lib/push.test.ts | 95 ++++ src/__tests__/routes/debug-push.test.ts | 161 +++++++ src/__tests__/routes/messages.test.ts | 74 +++ src/__tests__/routes/push.test.ts | 181 +++++++ src/__tests__/server.test.ts | 55 +++ src/index.ts | 25 +- src/lib/boot-stores.ts | 39 +- src/lib/db-change.ts | 2 +- src/lib/nostr/worker.ts | 4 + src/lib/nostr/zap-index.ts | 14 +- src/lib/push-config.ts | 80 ++++ src/lib/push-sender.ts | 143 ++++++ src/lib/push-store.ts | 548 ++++++++++++++++++++++ src/lib/push-worker.ts | 203 ++++++++ src/lib/push.ts | 104 ++++ src/routes/debug-push.ts | 69 +++ src/routes/messages.ts | 13 +- src/routes/push.ts | 106 +++++ src/server.ts | 36 +- 41 files changed, 3353 insertions(+), 79 deletions(-) create mode 100644 docs/schema/push.sql create mode 100644 src/__tests__/lib/push-config.test.ts create mode 100644 src/__tests__/lib/push-sender.test.ts create mode 100644 src/__tests__/lib/push-store.test.ts create mode 100644 src/__tests__/lib/push-worker.test.ts create mode 100644 src/__tests__/lib/push.test.ts create mode 100644 src/__tests__/routes/debug-push.test.ts create mode 100644 src/__tests__/routes/push.test.ts create mode 100644 src/lib/push-config.ts create mode 100644 src/lib/push-sender.ts create mode 100644 src/lib/push-store.ts create mode 100644 src/lib/push-worker.ts create mode 100644 src/lib/push.ts create mode 100644 src/routes/debug-push.ts create mode 100644 src/routes/push.ts diff --git a/CONCEPT.md b/CONCEPT.md index df9142f7..fd1ee5af 100644 --- a/CONCEPT.md +++ b/CONCEPT.md @@ -685,6 +685,7 @@ repository — they're intentionally not part of this project's scope. | 2026-08-29 | Public member forum UX is a messenger-group thread (oldest top, newest bottom above the composer). `GET /messages` remains the latest-200 window newest-first; clients reverse for display. | | 2026-08-29 | Zap ingest and invoice `relays` always include the public list (space plus Damus / Primal / nos.lol); kind:1 public write stays gated on `NOSTR_PUBLISH_PUBLIC`. | | 2026-08-29 | Zap-receipt sats UPDATE qualifies `message.sats` so Postgres can apply it. | +| 2026-08-30 | Web Push is self-hosted VAPID in this api (no third-party push SDK). Missing `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` → process still boots; push HTTP 503. Subscriptions bind to `account.id`. Outbox worker sends. Events: forum posts notify every other subscribed account (collapse tag `forum`); a newly indexed zap notifies the note author. iOS v1 is Home Screen (A2HS). Payloads are English `{ type, title, body, url, tag }`. | --- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e7ce59ee..ad706435 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,6 +41,8 @@ api/ │ │ ├── debug.ts # GET/POST /debug/accounts; PATCH /debug/accounts/:id (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) +│ │ ├── push.ts # GET /push/vapid-public; POST/DELETE /me/push-subscriptions │ │ ├── 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) @@ -54,6 +56,11 @@ api/ │ │ ├── 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 +│ │ ├── push-config.ts # resolveVapidConfig (VAPID env; missing → null) +│ │ ├── push.ts # parsePushSubscription + English forum/zap payloads +│ │ ├── push-store.ts # PushStore port, memory + Postgres, PUSH_SCHEMA_SQL +│ │ ├── push-sender.ts # PushSender port, UnconfiguredPushSender, WebPushSender +│ │ ├── push-worker.ts # enqueue + outbox tick │ │ ├── lightning-address.ts # LUD-16 shape check │ │ ├── invoice-payer.ts # InvoicePayer port + UnconfiguredInvoicePayer │ │ ├── lnurlp.ts # LUD-16 well-known metadata resolve (shared) @@ -68,7 +75,7 @@ api/ │ │ ├── gift-recorder.ts # Persist proven spend gifts into `gift` (no-op or SQL) │ │ ├── verification.ts # Address proof-of-control start/confirm domain logic │ │ ├── debug-token.ts # Constant-time DEBUG_TOKEN Bearer compare -│ │ ├── boot-stores.ts # DATABASE_URL → auth, optional QueryGiftStore + SqlGiftRecorder, message, contact, BTC-USD rates, KEK, db_change +│ │ ├── boot-stores.ts # DATABASE_URL → auth, optional QueryGiftStore + SqlGiftRecorder, message, contact, push, BTC-USD rates, KEK, db_change │ │ ├── money.ts # Sats/BTC strings and historical USD cents │ │ ├── btc-usd-candles.ts # Coinbase Exchange BTC-USD daily closes │ │ ├── btc-usd-store.ts # btc_usd_daily migrate + rate book @@ -123,6 +130,11 @@ api/ │ │ ├── nostr/ # kek, keys, publish, worker, relays, zap, event, sign, rate-limit │ │ ├── contact.test.ts │ │ ├── contact-store.test.ts +│ │ ├── push.test.ts +│ │ ├── push-config.test.ts +│ │ ├── push-store.test.ts +│ │ ├── push-sender.test.ts +│ │ ├── push-worker.test.ts │ │ └── auth/ │ │ ├── account-json.test.ts │ │ ├── hex.test.ts @@ -149,6 +161,8 @@ api/ │ ├── contact.test.ts │ ├── debug-contacts.test.ts │ ├── debug-payments.test.ts +│ ├── push.test.ts +│ ├── debug-push.test.ts │ └── view.test.ts ├── docs/handbook/ # Mandatory: every function + HTTP endpoint │ ├── README.md @@ -159,6 +173,7 @@ api/ │ ├── btc_usd_daily.sql # UTC daily BTC-USD closes for historical USD stats │ ├── message.sql # forum `message` plus `message_invoice` and `nostr_zap_ingest` │ ├── contact.sql # private contact mailbox table for POST /contact +│ ├── push.sql # push_subscription + push_outbox │ └── db_change.sql # append-only row-change log ├── scripts/ │ ├── check-handbook.mjs # CI gate: missing heading → exit 1 @@ -258,7 +273,8 @@ the default boot surface (today: `requestPayInvoice`, which needs a configured `InvoicePayer`; `PostgresAuthStore`, `migrateAuthSchema`, `QueryGiftStore`, `mapGiftQueryRow`, `PostgresBtcUsdStore`, `migrateBtcUsdSchema`, `PostgresMessageStore`, `migrateMessageSchema`, -`PostgresContactStore`, `migrateContactSchema`, `migrateDbChangeSchema`, +`PostgresContactStore`, `migrateContactSchema`, +`PostgresPushStore`, `migratePushSchema`, `migrateDbChangeSchema`, `DB_CHANGE_SCHEMA_SQL`, `fillRatesForGiftRange`, `fetchDailyCloses`, `parseCoinbaseCandles`, `resolveCandlesUrl`, and `SqlGiftRecorder`, which need `DATABASE_URL`; @@ -271,8 +287,9 @@ that test still exists and asserts the default-boot outcome that proves it is not invoked (verification `503`, spend invoices unconfigured `503`, or a healthy process with `DATABASE_URL` blank). Playwright `webServer.env` pins `DATABASE_URL`, `SPEND_API_TOKEN`, `NOSTR_NSEC_KEK`, `NOSTR_PUBLISH`, -`NOSTR_PUBLISH_PUBLIC`, `NOSTR_RELAY_URL`, `NOSTR_RELAY_SPACE`, and -`NOSTR_RELAY_PUBLIC` to blank +`NOSTR_PUBLISH_PUBLIC`, `NOSTR_RELAY_URL`, `NOSTR_RELAY_SPACE`, +`NOSTR_RELAY_PUBLIC`, `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, and +`VAPID_SUBJECT` to blank so those outcomes do not depend on the host environment. `bun run e2e:check` **fails the PR** if an endpoint has no matching `request.get/post/delete` or a function has no matching @@ -294,7 +311,7 @@ gap. Reviewers enforce this; `migrateDbChangeSchema` in `src/lib/db-change.ts` / - Logging is done by Postgres AFTER INSERT OR UPDATE OR DELETE **row** triggers named `trg_db_change` on every `public` table except `db_change` itself — **not** by application store methods. New public tables are covered on the next SQL boot - (`migrateDbChangeSchema` after `migrateContactSchema`) once the table exists. A + (`migrateDbChangeSchema` after `migratePushSchema`) once the table exists. A missing table **fails** the write; it does not skip the log. - `db_change` is append-only at runtime. UPDATE, DELETE, and TRUNCATE on it **must** fail (exception `db_change is append-only`). `migrateDbChangeSchema` @@ -302,7 +319,7 @@ gap. Reviewers enforce this; `migrateDbChangeSchema` in `src/lib/db-change.ts` / still match a live `account.view_key`, then recreates it. Rows whose key no longer matches a live account are left unchanged. - In the stored JSON, secret columns `token`, `challenge`, `nostr_nsec_ciphertext`, - `nonce`, and `view_key` are SHA-256 hex of the column text. All other columns, including + `nonce`, `view_key`, `endpoint`, `p256dh`, and `auth` are SHA-256 hex of the column text. All other columns, including `name`, stay plaintext. Do not omit those secret keys from the JSON (rotation **must** still be visible as a hash change). - Compare OLD vs NEW **before** redaction @@ -350,24 +367,27 @@ 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`, 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 and contact 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`, and `GET /debug/zap-ingests`. 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`. | +| 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. | 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/FLOWS.md b/FLOWS.md index b1877a53..9a95d732 100644 --- a/FLOWS.md +++ b/FLOWS.md @@ -1,13 +1,13 @@ # 21.gifts — Core UI Flows > Screen-by-screen sketch of the five journeys named in CONCEPT next-step 7, -> plus the shipped in-app contact mailbox (journey 6). +> plus the shipped in-app contact mailbox (journey 6) and Web Push (journey 7). > Product decisions live in [`CONCEPT.md`](./CONCEPT.md). Implemented HTTP > contracts live in [`SPEC.md`](./SPEC.md). This file **does not invent HTTP > paths, JSON fields, or status codes**. When a journey has no route in > `SPEC.md`, say so and stop. -**Status**: living document. Last revised 2026-08-29. +**Status**: living document. Last revised 2026-08-30. --- @@ -175,7 +175,30 @@ DMs, no Nostr fan-out. Do not invent `/events`. --- -## Out of these six journeys +## 7. Notifications — **Shipped** + +Transactional Web Push for signed-in members. The app is installable +(Web App Manifest + service worker). After login the profile card has an +icon-only bell: enable asks the OS permission, then `POST /me/push-subscriptions`. +Disable `DELETE`s the endpoint. `GET /push/vapid-public` is Bearer. + +On iPhone Safari the site must be on the Home Screen before the OS will +deliver pushes; the app shows that hint. Android and desktop Chrome do +not need the icon. + +The api enqueues (does not send inline): + +- a **forum** payload when someone else posts (`tag: forum`) +- a **zap** payload when a zap receipt is newly indexed onto the author's note + +The worker sends when VAPID is configured. Open focused tabs skip a second +banner (service worker). Do not invent preference HTTP in v1. + +HTTP cited: `/push/vapid-public`, `/me/push-subscriptions`, `/debug/push-ping`. + +--- + +## Out of these seven journeys Explicitly not journeys in this file: diff --git a/SPEC.md b/SPEC.md index 12f1a422..011abbe3 100644 --- a/SPEC.md +++ b/SPEC.md @@ -4,7 +4,7 @@ > Product decisions live in [`CONCEPT.md`](./CONCEPT.md); this file owns > request/response contracts for routes that exist in code today. -**Status**: living document. Last revised 2026-08-30 (kind:1 content includes Damus-visible `#bitcoin` / `#21gifts`; `POST /debug/accounts` provision; `POST /auth/passkey/register/begin` optional `{ viewKey }` claim; `POST /me/lightning-address` `409` when the address is taken; `GET /debug/invoices` and `GET /debug/zap-ingests`; kind:0 `picture` + NIP-65 kind:10002; kind:1 NIP-92 `imeta` photo URLs; public `GET /messages/:id/photo`; forum `account.role` `basis`\|`verified`\|`moderator`\|`founder`; live `role` on `GET/POST /messages`; `PATCH /debug/accounts/:id`; private in-app `POST /contact` + `GET /debug/contacts`; `POST /me/lightning-address` live-resolves and requires zap metadata; invoice limiter after payable checks; public forum `GET/POST /messages` with `sats`/`payable`/`hasPhoto`; worker indexes kind:9735 zap receipts onto `sats`; `POST /messages/:id/invoice` NIP-57 zap; SQL boot requires `NOSTR_NSEC_KEK`; passkey-only login; gift stats BTC + historical USD via Coinbase daily close; `GET /gifts?day=`). +**Status**: living document. Last revised 2026-08-30 (Web Push VAPID: `GET /push/vapid-public`, `POST`/`DELETE /me/push-subscriptions`, `POST /debug/push-ping`; kind:1 content includes Damus-visible `#bitcoin` / `#21gifts`; `POST /debug/accounts` provision; `POST /auth/passkey/register/begin` optional `{ viewKey }` claim; `POST /me/lightning-address` `409` when the address is taken; `GET /debug/invoices` and `GET /debug/zap-ingests`; kind:0 `picture` + NIP-65 kind:10002; kind:1 NIP-92 `imeta` photo URLs; public `GET /messages/:id/photo`; forum `account.role` `basis`\|`verified`\|`moderator`\|`founder`; live `role` on `GET/POST /messages`; `PATCH /debug/accounts/:id`; private in-app `POST /contact` + `GET /debug/contacts`; `POST /me/lightning-address` live-resolves and requires zap metadata; invoice limiter after payable checks; public forum `GET/POST /messages` with `sats`/`payable`/`hasPhoto`; worker indexes kind:9735 zap receipts onto `sats`; `POST /messages/:id/invoice` NIP-57 zap; SQL boot requires `NOSTR_NSEC_KEK`; passkey-only login; gift stats BTC + historical USD via Coinbase daily close; `GET /gifts?day=`). --- @@ -87,6 +87,10 @@ Public base URLs used in examples: | 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`) | +| GET | `/push/vapid-public` | Bearer | VAPID public key for Web Push subscribe | +| POST | `/me/push-subscriptions` | Bearer | Upsert a browser PushSubscription | +| DELETE | `/me/push-subscriptions` | Bearer | Remove a browser PushSubscription | +| POST | `/debug/push-ping` | Bearer `DEBUG_TOKEN` | Enqueue a test push for one account | | GET | `/gifts` | none | Outbound gifts for one UTC day (`?day=`) | | GET | `/gifts/stats` | none | Aggregated outbound gift statistics | | POST | `/invoices` | Bearer `SPEND_API_TOKEN` | Fetch a recipient BOLT11 (LNURL-pay) | @@ -871,6 +875,82 @@ Environment: | `DATABASE_URL` | When set, ingest rows are stored in Postgres `nostr_zap_ingest`. | | `DEBUG_TOKEN` | Operator bearer for this route. Unset → 503; process still boots. | +### `GET /push/vapid-public` + +Bearer session. Returns the VAPID **public** key the browser needs for +`pushManager.subscribe`. Missing VAPID env → **503** after session check +(the process still boots). No cookies. + +No/invalid session → **Response** `401`: + +```json +{ "error": "Unauthorized" } +``` + +VAPID not configured → **Response** `503`: + +```json +{ "error": "Push is not configured" } +``` + +Success → **Response** `200`: + +```json +{ "publicKey": "" } +``` + +### `POST /me/push-subscriptions` + +Bearer session. Upserts a browser PushSubscription for the account +(`endpoint` unique; rebinds if another account held it). + +No/invalid session → **401** `{ "error": "Unauthorized" }`. +VAPID not configured → **503** `{ "error": "Push is not configured" }`. +Invalid body (`endpoint` not an https URL, or missing `keys.p256dh` / +`keys.auth`) → **400** `{ "error": "Invalid subscription" }`. + +Success → **Response** `200`: + +```json +{ "endpoint": "https://push.example/device", "createdAt": "2026-08-30T12:00:00.000Z" } +``` + +### `DELETE /me/push-subscriptions` + +Bearer session. Body `{ "endpoint": "https://…" }`. Removes that device +for this account only. + +No/invalid session → **401**. VAPID not configured → **503**. Missing or +blank `endpoint` → **400** `{ "error": "Invalid subscription" }`. Unknown +endpoint for this account → **404** `{ "error": "Not found" }`. + +Success → **Response** `200`: + +```json +{ "ok": true } +``` + +### `POST /debug/push-ping` + +Operator enqueue of a test notification. Authenticated with +`Authorization: Bearer` matching `DEBUG_TOKEN` (not an end-user session). +JSON body `{ "accountId": "" }`. Enqueues at most one outbox row +when the account has a stored subscription. + +`DEBUG_TOKEN` unset or blank → **503** `{ "error": "Debug is not configured" }`. +Missing or non-matching bearer → **401** `{ "error": "Unauthorized" }`. +VAPID not configured → **503** `{ "error": "Push is not configured" }`. +Missing `accountId` → **400** `{ "error": "Expected a JSON body with an \"accountId\" string" }`. +Unknown account → **404** `{ "error": "Not found" }`. + +Success → **Response** `200`: + +```json +{ "enqueued": 1 } +``` + +`enqueued` is `0` when the account has no subscription. + ### `GET /gifts` Public list of outbound gifts for one UTC calendar day. Query `day=YYYY-MM-DD`. diff --git a/bun.lock b/bun.lock index d1ef11aa..c913f77b 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "hono": "^4.6.14", "light-bolt11-decoder": "^3.2.0", "nostr-tools": "^2.15.0", + "web-push": "^3.6.7", "zod": "^3.23.8", }, "devDependencies": { @@ -16,6 +17,7 @@ "@playwright/test": "^1.62.1", "@types/bun": "^1.1.14", "@types/node": "^22.10.2", + "@types/web-push": "^3.6.4", "@vitest/coverage-v8": "^2.1.8", "eslint": "^9.17.0", "eslint-plugin-tsdoc": "^0.4.0", @@ -235,6 +237,8 @@ "@types/node": ["@types/node@22.20.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g=="], + "@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.62.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/type-utils": "8.62.1", "@typescript-eslint/utils": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.62.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.62.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/types": "8.62.1", "@typescript-eslint/typescript-estree": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA=="], @@ -275,6 +279,8 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], @@ -283,14 +289,20 @@ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="], + "asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "bn.js": ["bn.js@4.12.5", "", {}, "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ=="], + "brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], @@ -319,6 +331,8 @@ "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -387,12 +401,18 @@ "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + "http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], @@ -423,6 +443,10 @@ "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], @@ -443,8 +467,12 @@ "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], + "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -509,6 +537,10 @@ "rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -575,6 +607,8 @@ "vitest": ["vitest@2.1.9", "", { "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", "@vitest/pretty-format": "^2.1.9", "@vitest/runner": "2.1.9", "@vitest/snapshot": "2.1.9", "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "2.1.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "2.1.9", "@vitest/ui": "2.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q=="], + "web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 038cb75c..069c7b46 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -56,6 +56,34 @@ - **Used by:** Operators debugging zap receipt indexing. - **Auth:** `Authorization: Bearer` with `DEBUG_TOKEN`. Not an end-user session. +## Endpoint: GET /push/vapid-public + +- **Purpose:** Bearer session. Returns `{ publicKey }` (URL-safe base64 VAPID public) so the app can subscribe. +- **Errors:** 401 `{ error: 'Unauthorized' }` without a session; 503 `{ error: 'Push is not configured' }` when VAPID keys are missing. +- **Used by:** App `fetchVapidPublicKey` / enable-notifications. +- **Auth:** `Authorization: Bearer` member session. + +## Endpoint: POST /me/push-subscriptions + +- **Purpose:** Bearer session. Upserts `{ endpoint, keys: { p256dh, auth } }` for the account. Rebinds the endpoint if another account owned it. +- **Errors:** 401 Unauthorized; 503 Push is not configured; 400 `{ error: 'Invalid subscription' }`. +- **Used by:** App `postPushSubscription`. +- **Auth:** `Authorization: Bearer` member session. + +## Endpoint: DELETE /me/push-subscriptions + +- **Purpose:** Bearer session. Body `{ endpoint }` removes that device for the account. +- **Errors:** 401 Unauthorized; 503 Push is not configured; 400 Invalid subscription; 404 `{ error: 'Not found' }`. +- **Used by:** App `deletePushSubscription`. +- **Auth:** `Authorization: Bearer` member session. + +## Endpoint: POST /debug/push-ping + +- **Purpose:** Operator enqueue of a test notification for `{ accountId }`. Returns `{ enqueued }` (`0` or `1`). +- **Errors:** 503 Debug is not configured; 401 Unauthorized; 503 Push is not configured; 400 expected accountId; 404 Not found. +- **Used by:** Operators verifying Web Push delivery. +- **Auth:** `Authorization: Bearer` with `DEBUG_TOKEN`. Not an end-user session. + ## Endpoint: POST /auth/passkey/authenticate/begin - **Purpose:** Issues WebAuthn request options for a discoverable credential. JSON: challengeId, options. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 5601c793..059837e9 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -119,18 +119,25 @@ - **Returns / side effects:** Void; idempotent DDL execute matching `docs/schema/contact.sql`. - **Used by:** `openBootStores` when SQL opens. +## Function: migratePushSchema + +- **Purpose:** Applies `PUSH_SCHEMA_SQL` in order (`CREATE TABLE IF NOT EXISTS` for `push_subscription` and `push_outbox` plus supporting indexes). +- **Inputs:** `SqlClient` already opened by boot. +- **Returns / side effects:** Void; idempotent DDL matching `docs/schema/push.sql`. Does not attach `db_change` triggers (that runs later via `migrateDbChangeSchema`). +- **Used by:** `openBootStores` when SQL opens, after `migrateContactSchema` and before `migrateDbChangeSchema`. + ## Function: migrateDbChangeSchema - **Purpose:** Applies `DB_CHANGE_SCHEMA_SQL` in order so durable Postgres row changes are append-logged in `db_change` via AFTER INSERT/UPDATE/DELETE triggers (not from application store methods). - **Inputs:** `SqlClient`. - **Returns / side effects:** Void; idempotent SQL matching `docs/schema/db_change.sql` (pgcrypto, table, redact/log/immutable functions, triggers, attach loop). The immutability-guard `DO` drops the append-only trigger once, hashes `view_key` values that still match a live `account.view_key`, leaves non-matches unchanged, then recreates the trigger. -- **Used by:** `openBootStores` when SQL opens, immediately after `migrateContactSchema`. +- **Used by:** `openBootStores` when SQL opens, immediately after `migratePushSchema`. ## Function: DB_CHANGE_SCHEMA_SQL - **Purpose:** Ordered idempotent SQL that creates the append-only `db_change` log, secret-redacting helpers, immutability guard (including a one-time live `view_key` rewrite in that same `DO`), and per-table `trg_db_change` triggers on every public table except `db_change`. - **Inputs:** None (readonly string array constant). -- **Returns / side effects:** Statement texts only; executed by `migrateDbChangeSchema`. Secrets `token`, `challenge`, `nostr_nsec_ciphertext`, `nonce`, and `view_key` become SHA-256 hex in logged JSON; other columns including `name` stay plaintext. The guard `DO` hashes JSON `view_key` that still equals a live `account.view_key` and leaves other rows unchanged. +- **Returns / side effects:** Statement texts only; executed by `migrateDbChangeSchema`. Secrets `token`, `challenge`, `nostr_nsec_ciphertext`, `nonce`, `view_key`, `endpoint`, `p256dh`, and `auth` become SHA-256 hex in logged JSON; other columns including `name` stay plaintext. The guard `DO` hashes JSON `view_key` that still equals a live `account.view_key` and leaves other rows unchanged. - **Used by:** `migrateDbChangeSchema`; documented mirror in `docs/schema/db_change.sql`. ## Function: InMemoryBtcUsdStore @@ -198,9 +205,9 @@ ## Function: openBootStores -- **Purpose:** Shared `DATABASE_URL` wiring: one `SqlClient` for durable auth, FX table, `QueryGiftStore`, `SqlGiftRecorder`, `PostgresBtcUsdStore`, `migrateMessageSchema`, `PostgresMessageStore`, `migrateContactSchema`, `PostgresContactStore`, `migrateDbChangeSchema`, and parsed `NOSTR_NSEC_KEK`; or in-memory auth, `giftStore`/`giftRecorder`/`messageStore`/`contactStore` undefined, `nostrKek` undefined, and empty `InMemoryBtcUsdStore` when unset. +- **Purpose:** Shared `DATABASE_URL` wiring: one `SqlClient` for durable auth, FX table, `QueryGiftStore`, `SqlGiftRecorder`, `PostgresBtcUsdStore`, `migrateMessageSchema`, `PostgresMessageStore`, `migrateContactSchema`, `PostgresContactStore`, `migratePushSchema`, `PostgresPushStore`, `migrateDbChangeSchema`, and parsed `NOSTR_NSEC_KEK`; or in-memory auth, `giftStore`/`giftRecorder`/`messageStore`/`contactStore`/`pushStore` undefined, `nostrKek` undefined, and empty `InMemoryBtcUsdStore` when unset. - **Inputs:** `databaseUrl`; optional `createClient` (required when URL set); optional `fx: { fetchImpl, candlesUrl, now }` so tests avoid the network (`candlesUrl` defaults via `resolveCandlesUrl(process.env)`). SQL path reads `process.env.NOSTR_NSEC_KEK`. -- **Returns / side effects:** `{ authStore, giftStore, giftRecorder, btcUsdRates, messageStore, contactStore, nostrKek }`. Migrates `btc_usd_daily`, `message`, `contact`, then `db_change` after auth migrate; best-effort `fillRatesForGiftRange` logs `gifts.fx.boot_fill.failed` and does not throw. Throws if the URL is set without a factory, or if the SQL path has a missing/malformed KEK. SQL path returns `SqlGiftRecorder`, `PostgresMessageStore`, and `PostgresContactStore`; memory path returns `giftRecorder`/`messageStore`/`contactStore`/`nostrKek` undefined and skips migrates including `migrateDbChangeSchema`. +- **Returns / side effects:** `{ authStore, giftStore, giftRecorder, btcUsdRates, messageStore, contactStore, pushStore, nostrKek }`. Migrates `btc_usd_daily`, `message`, `contact`, `push_subscription`/`push_outbox` (via `migratePushSchema`), then `db_change` after auth migrate; best-effort `fillRatesForGiftRange` logs `gifts.fx.boot_fill.failed` and does not throw. Throws if the URL is set without a factory, or if the SQL path has a missing/malformed KEK. SQL path returns `SqlGiftRecorder`, `PostgresMessageStore`, `PostgresContactStore`, and `PostgresPushStore`; memory path returns `giftRecorder`/`messageStore`/`contactStore`/`pushStore`/`nostrKek` undefined and skips migrates including `migratePushSchema` / `migrateDbChangeSchema`. - **Used by:** `src/index.ts` boot. ## Function: bearerMatchesDebugToken @@ -208,7 +215,7 @@ - **Purpose:** Constant-time compare of `DEBUG_TOKEN` against `Authorization: Bearer`. - **Inputs:** Configured token (non-empty) and raw header or `undefined`. - **Returns / side effects:** `true` only on an exact Bearer match (trim on the presented token). -- **Used by:** `debugRoutes`, `debugContactsRoutes`, `debugPaymentsRoutes`. +- **Used by:** `debugRoutes`, `debugContactsRoutes`, `debugPaymentsRoutes`, `debugPushRoutes`. ## Function: compareAccountsForList @@ -252,12 +259,110 @@ - **Returns / side effects:** boolean. - **Used by:** `POST /messages/:id/invoice` when persisting an ok attempt. -## Function: InMemoryGiftStore +## Function: resolveVapidConfig -- **Purpose:** Process-local GiftStore seeded at construction. Default empty so the process boots without a database. -- **Inputs:** Optional `GiftRow[]`. `listOutbound()` copies and sorts by `paidAt`. -- **Returns / side effects:** Promise of rows. Does not mutate the seed array. -- **Used by:** `createApp` default `giftStore`. +- **Purpose:** Resolve self-hosted Web Push VAPID credentials from an environment slice without failing boot when keys are missing or unusable. +- **Inputs:** `env` record (`VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, optional `VAPID_SUBJECT`). +- **Returns / side effects:** `{ publicKey, privateKey, subject }` when both keys decode (URL-safe base64) to 65-byte uncompressed P-256 public and 32-byte private, and `subject` is `https:` or `mailto:` (default `https://21.gifts`). Otherwise `null`. Never logs the private key. `src/index.ts` still try/catches `WebPushSender` construction so a library throw cannot kill listen. +- **Used by:** `createApp` (public key for HTTP), `src/index.ts` (sender + worker gate). + +## Function: UnconfiguredPushSender + +- **Purpose:** No-op `PushSender` used when VAPID env is missing so the process still boots and HTTP can return 503 without attempting delivery. +- **Inputs:** Constructor none. `send(sub, payload)` ignores arguments. +- **Returns / side effects:** `isConfigured()` is always `false`; `send` resolves `{ ok: false, reason: 'not_configured' }` and never calls `web-push`. +- **Used by:** `src/index.ts` when `resolveVapidConfig` returns `null`. + +## Function: WebPushSender + +- **Purpose:** VAPID Web Push delivery via the `web-push` package to one browser subscription endpoint. +- **Inputs:** Constructor takes resolved `VapidConfig`. `send(sub, payload)` takes a `PushSubscriptionRecord` and a JSON string body. +- **Returns / side effects:** `isConfigured()` is `true`. Maps HTTP 404/410 to `gone`, other errors to `fail`, success to `{ ok: true }`. Optional ASCII `topic` from payload `tag` (max 32). TTL 86400. +- **Used by:** `src/index.ts` when VAPID resolves; drained by `runPushWorkerTick`. + +## Function: InMemoryPushStore + +- **Purpose:** Process-local `PushStore` for Web Push subscriptions and the outbox. Default empty so the process boots without a database. +- **Inputs:** Constructor none. Methods match `PushStore` (`upsertSubscription` keeps original `createdAt` on endpoint conflict; `claimPending` leases oldest pending; `markFailed` fails at 8 attempts). +- **Returns / side effects:** Caller-owned copies; mutating results does not change the store. No I/O. +- **Used by:** `createApp` default `pushStore`; memory `src/index.ts` when boot omits SQL push. + +## Function: PostgresPushStore + +- **Purpose:** Durable `PushStore` over Postgres (`push_subscription`, `push_outbox`). Same port semantics as the in-memory adapter, including claim leases and attempt counting. +- **Inputs:** Constructor takes a shared boot `SqlClient` (already migrated via `migratePushSchema`). +- **Returns / side effects:** Parameter-bound SQL; maps snake_case rows to domain objects. Errors propagate to callers. +- **Used by:** `openBootStores` when `DATABASE_URL` is set. + +## Function: enqueueForumPushes + +- **Purpose:** Enqueue one forum notification per account that has at least one subscription, never for the message author. +- **Inputs:** `PushStore`, `authorId`, `messageId`, `nowMs`. Payload from `buildForumPushPayload`. +- **Returns / side effects:** One pending `type: 'forum'` outbox row per other subscriber account. Does not send HTTP push itself. +- **Used by:** `messagesRoutes` after a successful `POST /messages` create. + +## Function: enqueueZapPush + +- **Purpose:** Enqueue one zap notification for the note author when they have at least one push subscription. +- **Inputs:** `PushStore`, `authorId`, `messageId`, `nowMs`. Payload from `buildZapPushPayload(messageId)`. +- **Returns / side effects:** Zero or one pending `type: 'zap'` outbox row. No-op when the author has no subscriptions. +- **Used by:** Zap ingest in `indexOpenZapReceipts` when `indexZapReceipt` newly indexed a receipt. + +## Function: enqueueDebugPush + +- **Purpose:** Enqueue a single operator test notification for one account when it has a subscription. +- **Inputs:** `PushStore`, `accountId`, `nowMs`. Uses a fixed zap-typed debug payload (`tag: 'debug'`). +- **Returns / side effects:** `0` or `1` (rows enqueued). Does not deliver; the push worker drains the outbox. +- **Used by:** `debugPushRoutes` (`POST /debug/push-ping`). + +## Function: runPushWorkerTick + +- **Purpose:** Claim a batch of pending outbox rows and deliver each payload to every subscription for the recipient account. +- **Inputs:** `PushWorkerDeps` (`store`, `sender`, `now`). Batch size and lease from module constants. +- **Returns / side effects:** No-op when `sender.isConfigured()` is false. Deletes gone subscriptions; `markFailed` on fail; `markSent` when all gone / any ok / no subs. +- **Used by:** `startPushWorker` interval; unit tests. + +## Function: startPushWorker + +- **Purpose:** Start a periodic `setInterval` that runs `runPushWorkerTick` until stopped. +- **Inputs:** `PushWorkerDeps` and optional `intervalMs` (default `PUSH_WORKER_INTERVAL_MS` = 2s). +- **Returns / side effects:** `{ stop }` clears the interval. Does not throw on tick failures inside the timer callback. +- **Used by:** `src/index.ts` when VAPID resolves. + +## Function: parsePushSubscription + +- **Purpose:** Validate a browser PushSubscription JSON body into stored endpoint/key fields. +- **Inputs:** Unknown request body expecting `{ endpoint, keys: { p256dh, auth } }`. +- **Returns / side effects:** Parsed fields, or `null` when invalid (blank endpoint, bad url-safe base64 keys, non-https endpoint except localhost http). +- **Used by:** `pushRoutes` `POST /me/push-subscriptions`. + +## Function: buildForumPushPayload + +- **Purpose:** Shared English forum notification payload (`type: 'forum'`, collapse tag `forum`, url `/welcome`). +- **Inputs:** None. +- **Returns / side effects:** `PushPayload` object; callers `JSON.stringify` before enqueue/send. +- **Used by:** `enqueueForumPushes`. + +## Function: buildZapPushPayload + +- **Purpose:** English zap notification payload for a note author (`type: 'zap'`, tag `zap:`, url `/welcome`). +- **Inputs:** `messageId` string used only in `tag`. +- **Returns / side effects:** `PushPayload` object; callers `JSON.stringify` before enqueue/send. +- **Used by:** `enqueueZapPush`. + +## Function: pushRoutes + +- **Purpose:** Member Web Push HTTP: public VAPID key plus subscription upsert/delete for the signed-in account. +- **Inputs:** `PushRouteDeps` (`authStore`, `pushStore`, `now`, optional `vapidPublicKey`). +- **Returns / side effects:** Hono app with full path literals `/push/vapid-public` and `/me/push-subscriptions`. Session 401 before unconfigured 503. +- **Used by:** `createApp` mounted at `/`. + +## Function: debugPushRoutes + +- **Purpose:** Operator debug ping that enqueues a test Web Push for one account via `DEBUG_TOKEN` (not an end-user session). Body `{ accountId }`. Returns `{ enqueued }` (`0` or `1`). +- **Inputs:** `DebugPushRouteDeps` (`authStore`, `pushStore`, `now`, `debugToken`, `vapidPublicKey`). +- **Returns / side effects:** Hono app `POST /` mounted at `/debug/push-ping`. Debug 503/401 before JSON; then unconfigured 503; unknown account 404. Calls `enqueueDebugPush`. +- **Used by:** `createApp`. ## Function: InMemoryMessageStore @@ -280,6 +385,13 @@ - **Returns / side effects:** `get` returns `CachedLnAddress` or `null`. - **Used by:** `lightningAddressRoutes`. +## Function: InMemoryGiftStore + +- **Purpose:** Process-local GiftStore seeded at construction. Default empty so the process boots without a database. +- **Inputs:** Optional `GiftRow[]`. `listOutbound()` copies and sorts by `paidAt`. +- **Returns / side effects:** Promise of rows. Does not mutate the seed array. +- **Used by:** `createApp` default `giftStore`. + ## Function: mapGiftQueryRow - **Purpose:** Maps a SQL `gift` row (`paid_at`, `amount_sats`, `recipient_wos_user`) onto a `GiftRow`. @@ -408,8 +520,8 @@ ## Function: createApp -- **Purpose:** Wires CORS, requestLog, brand, health, info, auth, me, `/view`, lightning-address, `/debug/accounts`, `/debug/contacts`, `/debug/invoices`, `/debug/zap-ingests`, `/gifts`, `/gifts/stats`, `/messages` (incl. invoice), `/contact`, and invoices. -- **Inputs:** Optional `AppDeps` (store, clock, payer, fetch, cache, readBrand, origins, `debugToken`, giftStore, `giftRecorder`, `btcUsdRates`, `messageStore`, `contactStore`, `nostrKek`, spendApiToken, invoiceStore, `webAuthnRpId`, `webAuthnRpName`, `passkeyCeremony`). Omitted `giftRecorder` → `invoiceRoutes` uses `NoopGiftRecorder`; omitted `messageStore` → `InMemoryMessageStore`; omitted `contactStore` → `InMemoryContactStore`; omitted `nostrKek` → unsigned forum + invoice 503; SQL boot injects `SqlGiftRecorder`, `PostgresMessageStore`, `PostgresContactStore`, and parsed KEK. +- **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. +- **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. @@ -457,8 +569,8 @@ ## 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` issues a NIP-57 zap BOLT11 (invoice limiter after payable/KEK checks; post limiter on create). 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`. +- **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` issues a NIP-57 zap BOLT11 (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; 404 photo missing; 429 on post or invoice rate limits (invoice only after payable checks); 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). - **Used by:** `createApp`. @@ -984,8 +1096,8 @@ ## Function: indexOpenZapReceipts -- **Purpose:** Each worker tick, query zap relays for kind:9735 on recent notes (chunks of 20 event ids), verify the Nostr signature, validate provider pubkey via LNURL (module TTL cache, lowercased), bolt11 amount, e-tag, and index via `indexZapReceipt`. Persists every ingest decision (`indexed` / `rejected` with reason). One throwing receipt does not skip the rest of the tick. -- **Inputs:** store, auth, querier, urls, timeoutMs, now, fetchImpl; optional `verifyReceipt` (default: nostr-tools `verifyEvent`). +- **Purpose:** Each worker tick, query zap relays for kind:9735 on recent notes (chunks of 20 event ids), verify the Nostr signature, validate provider pubkey via LNURL (module TTL cache, lowercased), bolt11 amount, e-tag, and index via `indexZapReceipt`. Persists every ingest decision (`indexed` / `rejected` with reason). One throwing receipt does not skip the rest of the tick. A newly indexed receipt enqueues a zap push when `pushStore` is set (`push.enqueue.failed` on throw, ingest continues). +- **Inputs:** store, auth, querier, urls, timeoutMs, now, fetchImpl; optional `verifyReceipt` (default: nostr-tools `verifyEvent`); optional `pushStore`. - **Returns / side effects:** void; logs `nostr.zap.rejected` / `indexed`; records ingest rows; never logs full bolt11. - **Used by:** `runNostrWorkerTick`. diff --git a/docs/schema/db_change.sql b/docs/schema/db_change.sql index 16d4c23f..9f75e266 100644 --- a/docs/schema/db_change.sql +++ b/docs/schema/db_change.sql @@ -1,7 +1,8 @@ -- Append-only row-change log. AFTER INSERT/UPDATE/DELETE triggers on every -- public table (except db_change) write redacted before/after JSON. Secret --- columns token, challenge, nostr_nsec_ciphertext, nonce, and view_key are stored as --- SHA-256 hex; other columns including name stay plaintext. The log itself +-- columns token, challenge, nostr_nsec_ciphertext, nonce, view_key, endpoint, +-- p256dh, and auth are stored as SHA-256 hex; other columns including name stay +-- plaintext. The log itself -- rejects UPDATE, DELETE, and TRUNCATE at runtime. migrateDbChangeSchema may -- drop that trigger once to hash view_key values that still match a live -- account.view_key, then recreates it. Non-matching rows stay unchanged. @@ -31,7 +32,7 @@ BEGIN IF j IS NULL THEN RETURN NULL; END IF; - FOREACH k IN ARRAY ARRAY['token', 'challenge', 'nostr_nsec_ciphertext', 'nonce', 'view_key'] + FOREACH k IN ARRAY ARRAY['token', 'challenge', 'nostr_nsec_ciphertext', 'nonce', 'view_key', 'endpoint', 'p256dh', 'auth'] LOOP IF outj ? k AND jsonb_typeof(outj -> k) IS DISTINCT FROM 'null' THEN outj := jsonb_set( diff --git a/docs/schema/push.sql b/docs/schema/push.sql new file mode 100644 index 00000000..d140be88 --- /dev/null +++ b/docs/schema/push.sql @@ -0,0 +1,25 @@ +-- Web Push subscriptions (per account) and outbox for the push worker. +-- Endpoint is the primary key so a device rebinding on login moves ownership. +-- Outbox rows are claimed with a lease (same idea as message.claimed_until). + +CREATE TABLE IF NOT EXISTS push_subscription ( + endpoint text PRIMARY KEY, + account_id uuid NOT NULL REFERENCES account (id), + p256dh text NOT NULL, + auth text NOT NULL, + created_at timestamptz NOT NULL +); +CREATE INDEX IF NOT EXISTS push_subscription_account_id_idx ON push_subscription (account_id); + +CREATE TABLE IF NOT EXISTS push_outbox ( + id uuid PRIMARY KEY, + account_id uuid NOT NULL REFERENCES account (id), + type text NOT NULL CHECK (type IN ('forum', 'zap')), + message_id uuid, + payload text NOT NULL, + status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')), + attempts integer NOT NULL DEFAULT 0, + claimed_until timestamptz, + created_at timestamptz NOT NULL +); +CREATE INDEX IF NOT EXISTS push_outbox_pending_idx ON push_outbox (created_at, id) WHERE status = 'pending'; diff --git a/e2e/functions.spec.ts b/e2e/functions.spec.ts index b2e80490..1e577098 100644 --- a/e2e/functions.spec.ts +++ b/e2e/functions.spec.ts @@ -442,6 +442,75 @@ test('Function: migrateContactSchema — default boot has no DATABASE_URL', asyn expect(res.status()).toBe(200); }); +test('Function: migratePushSchema — default boot has no DATABASE_URL', async ({ request }) => { + expect((await request.get('/healthz')).status()).toBe(200); +}); +test('Function: InMemoryPushStore — GET /push/vapid-public without bearer is 401', async ({ + request, +}) => { + expect((await request.get('/push/vapid-public')).status()).toBe(401); +}); +test('Function: PostgresPushStore — default boot has no DATABASE_URL', async ({ request }) => { + expect((await request.get('/healthz')).status()).toBe(200); +}); +test('Function: resolveVapidConfig — GET /push/vapid-public without bearer is 401', async ({ + request, +}) => { + expect((await request.get('/push/vapid-public')).status()).toBe(401); +}); +test('Function: UnconfiguredPushSender — GET /push/vapid-public without bearer is 401', async ({ + request, +}) => { + expect((await request.get('/push/vapid-public')).status()).toBe(401); +}); +test('Function: WebPushSender — GET /push/vapid-public without bearer is 401', async ({ + request, +}) => { + expect((await request.get('/push/vapid-public')).status()).toBe(401); +}); +test('Function: parsePushSubscription — POST /me/push-subscriptions without bearer is 401', async ({ + request, +}) => { + expect((await request.post('/me/push-subscriptions')).status()).toBe(401); +}); +test('Function: buildForumPushPayload — POST /me/push-subscriptions without bearer is 401', async ({ + request, +}) => { + expect((await request.post('/me/push-subscriptions')).status()).toBe(401); +}); +test('Function: buildZapPushPayload — POST /me/push-subscriptions without bearer is 401', async ({ + request, +}) => { + expect((await request.post('/me/push-subscriptions')).status()).toBe(401); +}); +test('Function: enqueueForumPushes — POST /messages without bearer is 401', async ({ request }) => { + expect((await request.post('/messages')).status()).toBe(401); +}); +test('Function: enqueueZapPush — GET /push/vapid-public without bearer is 401', async ({ + request, +}) => { + expect((await request.get('/push/vapid-public')).status()).toBe(401); +}); +test('Function: enqueueDebugPush — POST /debug/push-ping without bearer is 401', async ({ + request, +}) => { + expect((await request.post('/debug/push-ping')).status()).toBe(401); +}); +test('Function: runPushWorkerTick — GET /healthz is ok', async ({ request }) => { + expect((await request.get('/healthz')).status()).toBe(200); +}); +test('Function: startPushWorker — GET /healthz is ok', async ({ request }) => { + expect((await request.get('/healthz')).status()).toBe(200); +}); +test('Function: pushRoutes — GET /push/vapid-public without bearer is 401', async ({ request }) => { + expect((await request.get('/push/vapid-public')).status()).toBe(401); +}); +test('Function: debugPushRoutes — POST /debug/push-ping without bearer is 401', async ({ + request, +}) => { + expect((await request.post('/debug/push-ping')).status()).toBe(401); +}); + test('Function: migrateDbChangeSchema — default boot has no DATABASE_URL', async ({ request }) => { const res = await request.get('/healthz'); expect(res.status()).toBe(200); diff --git a/e2e/http.spec.ts b/e2e/http.spec.ts index 688be0f0..2c141836 100644 --- a/e2e/http.spec.ts +++ b/e2e/http.spec.ts @@ -327,3 +327,31 @@ test('POST /invoices/proof unconfigured is 503', async ({ request }) => { }); expect(res.status()).toBe(503); }); + +test('GET /push/vapid-public without bearer is 401', async ({ request }) => { + const res = await request.get('/push/vapid-public'); + expect(res.status()).toBe(401); +}); + +test('POST /me/push-subscriptions without bearer is 401', async ({ request }) => { + const res = await request.post('/me/push-subscriptions'); + expect(res.status()).toBe(401); +}); + +test('DELETE /me/push-subscriptions without bearer is 401', async ({ request }) => { + const res = await request.delete('/me/push-subscriptions'); + expect(res.status()).toBe(401); +}); + +test('POST /debug/push-ping without bearer is 401', async ({ request }) => { + const res = await request.post('/debug/push-ping'); + expect(res.status()).toBe(401); +}); + +test('POST /debug/push-ping with the e2e token and no VAPID is 503', async ({ request }) => { + const res = await request.post('/debug/push-ping', { + headers: { authorization: 'Bearer e2e-debug-token' }, + data: { accountId: '00000000-0000-0000-0000-000000000001' }, + }); + expect(res.status()).toBe(503); +}); diff --git a/package.json b/package.json index ad9b5f05..6e943e2e 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "hono": "^4.6.14", "light-bolt11-decoder": "^3.2.0", "nostr-tools": "^2.15.0", + "web-push": "^3.6.7", "zod": "^3.23.8" }, "devDependencies": { @@ -35,6 +36,7 @@ "@playwright/test": "^1.62.1", "@types/bun": "^1.1.14", "@types/node": "^22.10.2", + "@types/web-push": "^3.6.4", "@vitest/coverage-v8": "^2.1.8", "eslint": "^9.17.0", "eslint-plugin-tsdoc": "^0.4.0", diff --git a/playwright.config.ts b/playwright.config.ts index 4e228c8e..420184ea 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -29,6 +29,9 @@ export default defineConfig({ NOSTR_RELAY_SPACE: '', NOSTR_RELAY_PUBLIC: '', SPEND_API_TOKEN: '', + VAPID_PUBLIC_KEY: '', + VAPID_PRIVATE_KEY: '', + VAPID_SUBJECT: '', DEBUG_TOKEN: 'e2e-debug-token', WEBAUTHN_RP_ID: 'localhost', CORS_ALLOWED_ORIGINS: 'http://localhost:3000,http://127.0.0.1:3000', diff --git a/scripts/check-handbook.mjs b/scripts/check-handbook.mjs index f3b6d174..ae53d953 100644 --- a/scripts/check-handbook.mjs +++ b/scripts/check-handbook.mjs @@ -133,9 +133,11 @@ function extractEndpoints() { 'lightning-address.ts': '/lightning-address', 'stats.ts': '/gifts/stats', 'brand.ts': '', + 'push.ts': '', 'debug.ts': '/debug/accounts', 'debug-contacts.ts': '/debug/contacts', 'debug-payments.ts': '/debug', + 'debug-push.ts': '/debug/push-ping', }; 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/src/__tests__/lib/boot-stores.test.ts b/src/__tests__/lib/boot-stores.test.ts index 9552449f..11d9036a 100644 --- a/src/__tests__/lib/boot-stores.test.ts +++ b/src/__tests__/lib/boot-stores.test.ts @@ -8,6 +8,7 @@ import { QueryGiftStore } from '@/lib/gift-store'; import { SqlGiftRecorder } from '@/lib/gift-recorder'; import { PostgresContactStore } from '@/lib/contact-store'; import { PostgresMessageStore } from '@/lib/message-store'; +import { PostgresPushStore } from '@/lib/push-store'; function unusedClient(): SqlClient { return { @@ -42,26 +43,42 @@ describe('openBootStores', () => { it('returns in-memory auth, no gift store, and InMemoryBtcUsdStore when unset', async () => { const factory = vi.fn(() => unusedClient()); - const { authStore, giftStore, giftRecorder, btcUsdRates, messageStore, contactStore } = - await openBootStores(undefined, factory); + const { + authStore, + giftStore, + giftRecorder, + btcUsdRates, + messageStore, + contactStore, + pushStore, + } = await openBootStores(undefined, factory); expect(authStore).toBeInstanceOf(InMemoryAuthStore); expect(giftStore).toBeUndefined(); expect(giftRecorder).toBeUndefined(); expect(messageStore).toBeUndefined(); expect(contactStore).toBeUndefined(); + expect(pushStore).toBeUndefined(); expect(btcUsdRates).toBeInstanceOf(InMemoryBtcUsdStore); expect(factory).not.toHaveBeenCalled(); }); it('returns in-memory auth, no gift store, and InMemoryBtcUsdStore when blank', async () => { const factory = vi.fn(() => unusedClient()); - const { authStore, giftStore, giftRecorder, btcUsdRates, messageStore, contactStore } = - await openBootStores(' ', factory); + const { + authStore, + giftStore, + giftRecorder, + btcUsdRates, + messageStore, + contactStore, + pushStore, + } = await openBootStores(' ', factory); expect(authStore).toBeInstanceOf(InMemoryAuthStore); expect(giftStore).toBeUndefined(); expect(giftRecorder).toBeUndefined(); expect(messageStore).toBeUndefined(); expect(contactStore).toBeUndefined(); + expect(pushStore).toBeUndefined(); expect(btcUsdRates).toBeInstanceOf(InMemoryBtcUsdStore); expect(factory).not.toHaveBeenCalled(); }); @@ -99,12 +116,19 @@ describe('openBootStores', () => { }; const factory = vi.fn(() => client); - const { authStore, giftStore, giftRecorder, btcUsdRates, messageStore, contactStore } = - await openBootStores(url, factory, { - fetchImpl: async () => new Response('[]', { status: 200 }), - candlesUrl: 'https://example.test/candles', - now: () => Date.parse('2026-06-01T12:00:00.000Z'), - }); + const { + authStore, + giftStore, + giftRecorder, + btcUsdRates, + messageStore, + contactStore, + pushStore, + } = await openBootStores(url, factory, { + fetchImpl: async () => new Response('[]', { status: 200 }), + candlesUrl: 'https://example.test/candles', + now: () => Date.parse('2026-06-01T12:00:00.000Z'), + }); expect(factory).toHaveBeenCalledTimes(1); expect(factory).toHaveBeenCalledWith(url.trim()); @@ -113,10 +137,12 @@ describe('openBootStores', () => { expect(giftRecorder).toBeInstanceOf(SqlGiftRecorder); expect(messageStore).toBeInstanceOf(PostgresMessageStore); expect(contactStore).toBeInstanceOf(PostgresContactStore); + expect(pushStore).toBeInstanceOf(PostgresPushStore); expect(btcUsdRates).toBeInstanceOf(PostgresBtcUsdStore); expect(executes.length).toBeGreaterThan(0); expect(executes.some((q) => q.includes('message'))).toBe(true); expect(executes.some((q) => q.includes('contact'))).toBe(true); + expect(executes.some((q) => q.includes('push_subscription'))).toBe(true); expect(executes.some((q) => q.includes('db_change'))).toBe(true); expect(executes.some((q) => /CREATE TABLE/i.test(q))).toBe(true); expect(queries.some((q) => q.includes('min(paid_at)'))).toBe(true); @@ -153,16 +179,24 @@ describe('openBootStores', () => { }, execute: async () => undefined, }; - const { authStore, giftStore, giftRecorder, btcUsdRates, messageStore, contactStore } = - await openBootStores('postgres://gifts21@localhost/gifts21', () => client, { - fetchImpl: async () => new Response('[]', { status: 200 }), - candlesUrl: 'https://example.test/candles', - }); + const { + authStore, + giftStore, + giftRecorder, + btcUsdRates, + messageStore, + contactStore, + pushStore, + } = await openBootStores('postgres://gifts21@localhost/gifts21', () => client, { + fetchImpl: async () => new Response('[]', { status: 200 }), + candlesUrl: 'https://example.test/candles', + }); expect(authStore).toBeInstanceOf(PostgresAuthStore); expect(giftStore).toBeInstanceOf(QueryGiftStore); expect(giftRecorder).toBeInstanceOf(SqlGiftRecorder); expect(messageStore).toBeInstanceOf(PostgresMessageStore); expect(contactStore).toBeInstanceOf(PostgresContactStore); + expect(pushStore).toBeInstanceOf(PostgresPushStore); expect(btcUsdRates).toBeInstanceOf(PostgresBtcUsdStore); expect(parsedEvents(warn).some((e) => e['event'] === 'gifts.fx.boot_fill.failed')).toBe(true); }); diff --git a/src/__tests__/lib/db-change.test.ts b/src/__tests__/lib/db-change.test.ts index a11dc334..77b1314a 100644 --- a/src/__tests__/lib/db-change.test.ts +++ b/src/__tests__/lib/db-change.test.ts @@ -35,6 +35,9 @@ describe('DB_CHANGE_SCHEMA_SQL', () => { expect(joined).toMatch(/nostr_nsec_ciphertext/); expect(joined).toMatch(/nonce/); expect(joined).toMatch(/view_key/); + expect(joined).toMatch(/endpoint/); + expect(joined).toMatch(/p256dh/); + expect(joined).toMatch(/auth/); const guardBlock = joined.slice(joined.indexOf('$guard$')); const dropAt = guardBlock.indexOf('DROP TRIGGER IF EXISTS db_change_immutable'); const afterMatchAt = guardBlock.indexOf("a.view_key = d.after ->> 'view_key'"); diff --git a/src/__tests__/lib/nostr/worker.test.ts b/src/__tests__/lib/nostr/worker.test.ts index 459790ec..bd3d0f95 100644 --- a/src/__tests__/lib/nostr/worker.test.ts +++ b/src/__tests__/lib/nostr/worker.test.ts @@ -10,6 +10,7 @@ import { RecordingPublisher } from '@/lib/nostr/publish'; import { RecordingQuerier } from '@/lib/nostr/query'; import { DEFAULT_RELAY_PUBLIC } from '@/lib/nostr/relays'; import { runNostrWorkerTick, startNostrWorker, type NostrWorkerDeps } from '@/lib/nostr/worker'; +import { InMemoryPushStore } from '@/lib/push-store'; vi.mock('@/lib/bolt11', () => ({ decodeBolt11: vi.fn(), @@ -87,6 +88,7 @@ describe('runNostrWorkerTick', () => { publisher: new RecordingPublisher(), now: () => 1_700_000_000_000, env: {}, + pushStore: new InMemoryPushStore(), }), ); const row = await messages.getById('m1'); diff --git a/src/__tests__/lib/nostr/zap-index.test.ts b/src/__tests__/lib/nostr/zap-index.test.ts index 57c81783..7884ce52 100644 --- a/src/__tests__/lib/nostr/zap-index.test.ts +++ b/src/__tests__/lib/nostr/zap-index.test.ts @@ -9,6 +9,7 @@ import type { NostrEventFrame } from '@/lib/nostr/query'; import { RecordingQuerier } from '@/lib/nostr/query'; import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'; import { indexOpenZapReceipts, indexZapReceipt } from '@/lib/nostr/zap-index'; +import { InMemoryPushStore } from '@/lib/push-store'; vi.mock('@/lib/bolt11', () => ({ decodeBolt11: vi.fn(), @@ -1297,4 +1298,127 @@ describe('indexOpenZapReceipts', () => { expect(row?.reason).toBe('pubkey'); expect(row?.receiptPubkey).toBeNull(); }); + + it('enqueues a zap push for the author when a receipt is newly indexed', async () => { + const secret = generateSecretKey(); + const pubkey = getPublicKey(secret); + const signed = finalizeEvent( + { + kind: 9735, + content: '', + created_at: 1_700_000_000, + tags: [ + ['e', NOTE_EVENT_ID], + ['bolt11', 'lnbc-signed-push'], + ], + }, + secret, + ); + const store = new InMemoryMessageStore(); + const auth = new InMemoryAuthStore(); + await seedStore({ + store, + auth, + accountId: 'acc-zap-push', + lightningAddress: 'zap-push@example.com', + }); + const pushStore = new InMemoryPushStore(); + await pushStore.upsertSubscription({ + endpoint: 'https://push.example/author', + accountId: 'acc-zap-push', + p256dh: 'p256dh', + auth: 'authkey', + createdAt: new Date(1), + }); + const querier = new RecordingQuerier(); + querier.events = [ + { + id: signed.id, + pubkey: signed.pubkey, + kind: signed.kind, + tags: signed.tags, + content: signed.content, + created_at: signed.created_at, + sig: signed.sig, + }, + ]; + mockedDecode.mockReturnValue({ paymentHash: '11'.repeat(32), amountMsat: 21_000 }); + await indexOpenZapReceipts({ + store, + auth, + querier, + urls: URLS, + timeoutMs: 50, + now: () => 1, + fetchImpl: lnurlFetch(pubkey), + pushStore, + }); + expect((await store.getByEventId(NOTE_EVENT_ID))?.sats).toBe(21); + const claimed = await pushStore.claimPending(20, 2, 60_000); + expect(claimed).toHaveLength(1); + expect(claimed[0]?.accountId).toBe('acc-zap-push'); + expect(claimed[0]?.type).toBe('zap'); + }); + + it('indexes sats even when zap push enqueue throws', async () => { + const secret = generateSecretKey(); + const pubkey = getPublicKey(secret); + const signed = finalizeEvent( + { + kind: 9735, + content: '', + created_at: 1_700_000_000, + tags: [ + ['e', NOTE_EVENT_ID], + ['bolt11', 'lnbc-signed-push-fail'], + ], + }, + secret, + ); + const store = new InMemoryMessageStore(); + const auth = new InMemoryAuthStore(); + await seedStore({ + store, + auth, + accountId: 'acc-zap-push-fail', + lightningAddress: 'zap-push-fail@example.com', + }); + const pushStore = new InMemoryPushStore(); + pushStore.enqueue = async () => { + throw new Error('enqueue failed'); + }; + await pushStore.upsertSubscription({ + endpoint: 'https://push.example/author', + accountId: 'acc-zap-push-fail', + p256dh: 'p256dh', + auth: 'authkey', + createdAt: new Date(1), + }); + const querier = new RecordingQuerier(); + querier.events = [ + { + id: signed.id, + pubkey: signed.pubkey, + kind: signed.kind, + tags: signed.tags, + content: signed.content, + created_at: signed.created_at, + sig: signed.sig, + }, + ]; + mockedDecode.mockReturnValue({ paymentHash: '11'.repeat(32), amountMsat: 21_000 }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + await indexOpenZapReceipts({ + store, + auth, + querier, + urls: URLS, + timeoutMs: 50, + now: () => 1, + fetchImpl: lnurlFetch(pubkey), + pushStore, + }); + warn.mockRestore(); + expect((await store.getByEventId(NOTE_EVENT_ID))?.sats).toBe(21); + }); }); diff --git a/src/__tests__/lib/push-config.test.ts b/src/__tests__/lib/push-config.test.ts new file mode 100644 index 00000000..ea1b83e4 --- /dev/null +++ b/src/__tests__/lib/push-config.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; +import { resolveVapidConfig } from '@/lib/push-config'; + +function b64url(bytes: Uint8Array): string { + return Buffer.from(bytes) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/u, ''); +} + +const PUBLIC_BYTES = new Uint8Array(65); +PUBLIC_BYTES[0] = 4; +const PRIVATE_BYTES = new Uint8Array(32).fill(1); +const PUBLIC_KEY = b64url(PUBLIC_BYTES); +const PRIVATE_KEY = b64url(PRIVATE_BYTES); + +describe('resolveVapidConfig', () => { + it('returns null when public key is missing', () => { + expect(resolveVapidConfig({ VAPID_PRIVATE_KEY: PRIVATE_KEY })).toBeNull(); + }); + + it('returns null when private key is missing', () => { + expect(resolveVapidConfig({ VAPID_PUBLIC_KEY: PUBLIC_KEY })).toBeNull(); + }); + + it('returns null when either key is blank after trim', () => { + expect( + resolveVapidConfig({ + VAPID_PUBLIC_KEY: ' ', + VAPID_PRIVATE_KEY: PRIVATE_KEY, + }), + ).toBeNull(); + expect( + resolveVapidConfig({ + VAPID_PUBLIC_KEY: PUBLIC_KEY, + VAPID_PRIVATE_KEY: '\t', + }), + ).toBeNull(); + }); + + it('returns null when the public key is 65 bytes but not uncompressed', () => { + const compressed = new Uint8Array(65); + compressed[0] = 2; + expect( + resolveVapidConfig({ + VAPID_PUBLIC_KEY: b64url(compressed), + VAPID_PRIVATE_KEY: PRIVATE_KEY, + }), + ).toBeNull(); + }); + + it('returns null when url-safe base64 decodes to empty bytes', () => { + expect( + resolveVapidConfig({ + VAPID_PUBLIC_KEY: '!!!!', + VAPID_PRIVATE_KEY: PRIVATE_KEY, + }), + ).toBeNull(); + }); + + it('returns null when keys do not decode to P-256 lengths', () => { + expect( + resolveVapidConfig({ + VAPID_PUBLIC_KEY: 'pub', + VAPID_PRIVATE_KEY: PRIVATE_KEY, + }), + ).toBeNull(); + expect( + resolveVapidConfig({ + VAPID_PUBLIC_KEY: PUBLIC_KEY, + VAPID_PRIVATE_KEY: 'priv', + }), + ).toBeNull(); + }); + + it('returns null when subject is not https or mailto', () => { + expect( + resolveVapidConfig({ + VAPID_PUBLIC_KEY: PUBLIC_KEY, + VAPID_PRIVATE_KEY: PRIVATE_KEY, + VAPID_SUBJECT: 'ftp://example.com', + }), + ).toBeNull(); + }); + + it('trims keys and defaults subject to https://21.gifts', () => { + expect( + resolveVapidConfig({ + VAPID_PUBLIC_KEY: ` ${PUBLIC_KEY} `, + VAPID_PRIVATE_KEY: ` ${PRIVATE_KEY} `, + }), + ).toEqual({ + publicKey: PUBLIC_KEY, + privateKey: PRIVATE_KEY, + subject: 'https://21.gifts', + }); + }); + + it('uses trimmed VAPID_SUBJECT when set', () => { + expect( + resolveVapidConfig({ + VAPID_PUBLIC_KEY: PUBLIC_KEY, + VAPID_PRIVATE_KEY: PRIVATE_KEY, + VAPID_SUBJECT: ' mailto:ops@example.com ', + }), + ).toEqual({ + publicKey: PUBLIC_KEY, + privateKey: PRIVATE_KEY, + subject: 'mailto:ops@example.com', + }); + }); + + it('defaults subject when VAPID_SUBJECT is blank', () => { + expect( + resolveVapidConfig({ + VAPID_PUBLIC_KEY: PUBLIC_KEY, + VAPID_PRIVATE_KEY: PRIVATE_KEY, + VAPID_SUBJECT: ' ', + })?.subject, + ).toBe('https://21.gifts'); + }); +}); diff --git a/src/__tests__/lib/push-sender.test.ts b/src/__tests__/lib/push-sender.test.ts new file mode 100644 index 00000000..cf608810 --- /dev/null +++ b/src/__tests__/lib/push-sender.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PushSubscriptionRecord } from '@/lib/push-store'; + +const sendNotification = vi.fn(); +const setVapidDetails = vi.fn(); + +vi.mock('web-push', () => ({ + default: { + setVapidDetails: (...args: unknown[]) => setVapidDetails(...args), + sendNotification: (...args: unknown[]) => sendNotification(...args), + }, +})); + +import { UnconfiguredPushSender, WebPushSender } from '@/lib/push-sender'; + +const SUB: PushSubscriptionRecord = { + endpoint: 'https://push.example/a', + accountId: 'acc', + p256dh: 'p256', + auth: 'auth', + createdAt: new Date('2026-08-01T00:00:00.000Z'), +}; + +describe('UnconfiguredPushSender', () => { + it('reports not configured and refuses send', async () => { + const sender = new UnconfiguredPushSender(); + expect(sender.isConfigured()).toBe(false); + expect(await sender.send(SUB, '{}')).toEqual({ ok: false, reason: 'not_configured' }); + }); +}); + +describe('WebPushSender', () => { + beforeEach(() => { + sendNotification.mockReset(); + setVapidDetails.mockReset(); + }); + + it('sets VAPID details and sends with TTL and topic from tag', async () => { + sendNotification.mockResolvedValue(undefined); + const sender = new WebPushSender({ + publicKey: 'pub', + privateKey: 'priv', + subject: 'https://21.gifts', + }); + expect(setVapidDetails).toHaveBeenCalledWith('https://21.gifts', 'pub', 'priv'); + expect(sender.isConfigured()).toBe(true); + const payload = JSON.stringify({ tag: 'forum-😀-extra-long-tag-value-here' }); + expect(await sender.send(SUB, payload)).toEqual({ ok: true }); + expect(sendNotification).toHaveBeenCalledWith( + { endpoint: SUB.endpoint, keys: { p256dh: 'p256', auth: 'auth' } }, + payload, + expect.objectContaining({ + TTL: 86400, + topic: expect.stringMatching(/^forum-/), + }), + ); + const options = sendNotification.mock.calls[0]?.[2] as { topic: string }; + expect(options.topic.length).toBeLessThanOrEqual(32); + expect(options.topic.includes('😀')).toBe(false); + }); + + it('omits topic when payload JSON is invalid or tag missing', async () => { + sendNotification.mockResolvedValue(undefined); + const sender = new WebPushSender({ + publicKey: 'pub', + privateKey: 'priv', + subject: 'https://21.gifts', + }); + await sender.send(SUB, 'not-json'); + expect(sendNotification.mock.calls[0]?.[2]).toEqual({ TTL: 86400 }); + await sender.send(SUB, JSON.stringify({ title: 'x' })); + expect(sendNotification.mock.calls[1]?.[2]).toEqual({ TTL: 86400 }); + await sender.send(SUB, JSON.stringify({ tag: '😀😀' })); + expect(sendNotification.mock.calls[2]?.[2]).toEqual({ TTL: 86400 }); + await sender.send(SUB, 'null'); + expect(sendNotification.mock.calls[3]?.[2]).toEqual({ TTL: 86400 }); + await sender.send(SUB, '"x"'); + expect(sendNotification.mock.calls[4]?.[2]).toEqual({ TTL: 86400 }); + }); + + it('maps 404/410 to gone and other errors to fail', async () => { + const sender = new WebPushSender({ + publicKey: 'pub', + privateKey: 'priv', + subject: 'https://21.gifts', + }); + sendNotification.mockRejectedValueOnce({ statusCode: 410 }); + expect(await sender.send(SUB, '{}')).toEqual({ ok: false, reason: 'gone' }); + sendNotification.mockRejectedValueOnce({ statusCode: 404 }); + expect(await sender.send(SUB, '{}')).toEqual({ ok: false, reason: 'gone' }); + sendNotification.mockRejectedValueOnce({ statusCode: 500 }); + expect(await sender.send(SUB, '{}')).toEqual({ ok: false, reason: 'fail' }); + sendNotification.mockRejectedValueOnce('boom'); + expect(await sender.send(SUB, '{}')).toEqual({ ok: false, reason: 'fail' }); + sendNotification.mockRejectedValueOnce(null); + expect(await sender.send(SUB, '{}')).toEqual({ ok: false, reason: 'fail' }); + sendNotification.mockRejectedValueOnce({ statusCode: '410' }); + expect(await sender.send(SUB, '{}')).toEqual({ ok: false, reason: 'fail' }); + }); +}); diff --git a/src/__tests__/lib/push-store.test.ts b/src/__tests__/lib/push-store.test.ts new file mode 100644 index 00000000..aa7a71b0 --- /dev/null +++ b/src/__tests__/lib/push-store.test.ts @@ -0,0 +1,337 @@ +import { describe, expect, it } from 'vitest'; +import type { SqlClient } from '@/lib/auth/sql'; +import { + InMemoryPushStore, + PUSH_SCHEMA_SQL, + migratePushSchema, + PostgresPushStore, + type PushOutboxRow, + type PushSubscriptionRecord, +} from '@/lib/push-store'; + +class MockSql implements SqlClient { + executes: { text: string; params: readonly unknown[] }[] = []; + queries: { text: string; params: readonly unknown[] }[] = []; + nextRows: unknown[] = []; + queryError: unknown | undefined; + executeError: unknown | undefined; + + async query(text: string, params: readonly unknown[] = []): Promise { + this.queries.push({ text, params }); + if (this.queryError !== undefined) { + throw this.queryError; + } + return this.nextRows as T[]; + } + + async execute(text: string, params: readonly unknown[] = []): Promise { + this.executes.push({ text, params }); + if (this.executeError !== undefined) { + throw this.executeError; + } + } +} + +const SUB: PushSubscriptionRecord = { + endpoint: 'https://push.example/a', + accountId: 'acc-a', + p256dh: 'p256', + auth: 'auth', + createdAt: new Date('2026-08-01T00:00:00.000Z'), +}; + +function pending( + overrides: Partial & Pick, +): PushOutboxRow { + return { + type: 'forum', + messageId: 'msg', + payload: '{}', + status: 'pending', + attempts: 0, + claimedUntil: null, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + ...overrides, + }; +} + +describe('PUSH_SCHEMA_SQL', () => { + it('creates push_subscription and push_outbox with indexes', () => { + expect(PUSH_SCHEMA_SQL).toHaveLength(4); + expect(PUSH_SCHEMA_SQL[0]).toMatch(/CREATE TABLE IF NOT EXISTS push_subscription/i); + expect(PUSH_SCHEMA_SQL[1]).toMatch(/push_subscription_account_id_idx/i); + expect(PUSH_SCHEMA_SQL[2]).toMatch(/CREATE TABLE IF NOT EXISTS push_outbox/i); + expect(PUSH_SCHEMA_SQL[3]).toMatch(/push_outbox_pending_idx/i); + }); +}); + +describe('migratePushSchema', () => { + it('runs every PUSH_SCHEMA_SQL statement', async () => { + const sql = new MockSql(); + await migratePushSchema(sql); + expect(sql.executes.map((e) => e.text)).toEqual([...PUSH_SCHEMA_SQL]); + }); +}); + +describe('InMemoryPushStore', () => { + it('upserts, rebinds account, and keeps original createdAt', async () => { + const store = new InMemoryPushStore(); + const first = await store.upsertSubscription(SUB); + expect(first.createdAt.toISOString()).toBe(SUB.createdAt.toISOString()); + const later = new Date('2026-08-10T00:00:00.000Z'); + const rebound = await store.upsertSubscription({ + ...SUB, + accountId: 'acc-b', + p256dh: 'new', + auth: 'new-auth', + createdAt: later, + }); + expect(rebound.createdAt.toISOString()).toBe(SUB.createdAt.toISOString()); + const listed = await store.listByAccount('acc-b'); + expect(listed).toHaveLength(1); + expect(listed[0]?.accountId).toBe('acc-b'); + expect(listed[0]?.p256dh).toBe('new'); + expect(listed[0]?.createdAt.toISOString()).toBe(SUB.createdAt.toISOString()); + expect(await store.listByAccount('acc-a')).toEqual([]); + }); + + it('copies listed rows so callers cannot mutate store state', async () => { + const store = new InMemoryPushStore(); + await store.upsertSubscription(SUB); + const listed = await store.listByAccount('acc-a'); + if (listed[0] !== undefined) { + listed[0].auth = 'mutated'; + listed[0].createdAt.setTime(0); + } + const again = await store.listByAccount('acc-a'); + expect(again[0]?.auth).toBe('auth'); + expect(again[0]?.createdAt.toISOString()).toBe(SUB.createdAt.toISOString()); + }); + + it('deleteSubscription returns false when missing or wrong account', async () => { + const store = new InMemoryPushStore(); + expect(await store.deleteSubscription('acc-a', SUB.endpoint)).toBe(false); + await store.upsertSubscription(SUB); + expect(await store.deleteSubscription('other', SUB.endpoint)).toBe(false); + expect(await store.deleteSubscription('acc-a', SUB.endpoint)).toBe(true); + expect(await store.deleteSubscription('acc-a', SUB.endpoint)).toBe(false); + }); + + it('lists distinct account ids with subscriptions', async () => { + const store = new InMemoryPushStore(); + await store.upsertSubscription(SUB); + await store.upsertSubscription({ + ...SUB, + endpoint: 'https://push.example/b', + accountId: 'acc-b', + }); + await store.upsertSubscription({ + ...SUB, + endpoint: 'https://push.example/c', + accountId: 'acc-a', + }); + const ids = await store.listAccountIdsWithSubscriptions(); + expect(ids.sort()).toEqual(['acc-a', 'acc-b']); + }); + + it('claims pending by oldest createdAt then id and respects lease', async () => { + const store = new InMemoryPushStore(); + await store.enqueue( + pending({ + id: 'b', + accountId: 'a', + createdAt: new Date('2026-08-02T00:00:00.000Z'), + }), + ); + await store.enqueue( + pending({ + id: 'a', + accountId: 'a', + createdAt: new Date('2026-08-02T00:00:00.000Z'), + }), + ); + await store.enqueue( + pending({ + id: 'early', + accountId: 'a', + createdAt: new Date('2026-08-01T00:00:00.000Z'), + }), + ); + const now = Date.parse('2026-08-03T00:00:00.000Z'); + const first = await store.claimPending(2, now, 60_000); + expect(first.map((r) => r.id)).toEqual(['early', 'a']); + expect(first[0]?.claimedUntil?.getTime()).toBe(now + 60_000); + const stillHeld = await store.claimPending(10, now + 1_000, 60_000); + expect(stillHeld.map((r) => r.id)).toEqual(['b']); + const afterExpiry = await store.claimPending(10, now + 60_001, 60_000); + expect(afterExpiry.map((r) => r.id).sort()).toEqual(['a', 'early']); + }); + + it('skips non-pending rows when claiming', async () => { + const store = new InMemoryPushStore(); + await store.enqueue(pending({ id: 's', accountId: 'a', status: 'sent' })); + expect(await store.claimPending(10, 1, 1000)).toEqual([]); + }); + + it('markSent and markFailed including terminal at 8 attempts', async () => { + const store = new InMemoryPushStore(); + await store.enqueue(pending({ id: 'x', accountId: 'a' })); + await store.markSent('missing'); + await store.markFailed('missing'); + await store.markSent('x'); + const claimed = await store.claimPending(10, 1, 1000); + expect(claimed).toEqual([]); + + await store.enqueue(pending({ id: 'y', accountId: 'a' })); + for (let i = 0; i < 7; i += 1) { + await store.markFailed('y'); + } + const requeued = await store.claimPending(10, 1, 1000); + expect(requeued).toHaveLength(1); + expect(requeued[0]?.attempts).toBe(7); + expect(requeued[0]?.claimedUntil).not.toBeNull(); + await store.markFailed('y'); + expect(await store.claimPending(10, 1, 1000)).toEqual([]); + }); +}); + +describe('PostgresPushStore', () => { + it('upserts with ON CONFLICT and maps list/delete/enqueue/claim/mark', async () => { + const sql = new MockSql(); + const store = new PostgresPushStore(sql); + sql.nextRows = [ + { + endpoint: SUB.endpoint, + account_id: SUB.accountId, + p256dh: SUB.p256dh, + auth: SUB.auth, + created_at: SUB.createdAt, + }, + ]; + const stored = await store.upsertSubscription(SUB); + expect(sql.queries[0]?.text).toMatch(/ON CONFLICT \(endpoint\) DO UPDATE/i); + expect(sql.queries[0]?.text).toMatch(/RETURNING/i); + expect(sql.queries[0]?.text).not.toMatch(/created_at = EXCLUDED/i); + expect(stored.createdAt.toISOString()).toBe(SUB.createdAt.toISOString()); + + sql.nextRows = [ + { + endpoint: SUB.endpoint, + account_id: SUB.accountId, + p256dh: SUB.p256dh, + auth: SUB.auth, + created_at: '2026-08-01T00:00:00.000Z', + }, + ]; + const storedFromString = await store.upsertSubscription(SUB); + expect(storedFromString.createdAt.toISOString()).toBe('2026-08-01T00:00:00.000Z'); + + sql.nextRows = [{ endpoint: SUB.endpoint }]; + expect(await store.deleteSubscription('acc-a', SUB.endpoint)).toBe(true); + sql.nextRows = []; + expect(await store.deleteSubscription('acc-a', SUB.endpoint)).toBe(false); + + sql.nextRows = [ + { + endpoint: SUB.endpoint, + account_id: 'acc-a', + p256dh: 'p', + auth: 'a', + created_at: '2026-08-01T00:00:00.000Z', + }, + ]; + const listed = await store.listByAccount('acc-a'); + expect(listed[0]?.createdAt).toBeInstanceOf(Date); + + sql.nextRows = [ + { + endpoint: SUB.endpoint, + account_id: 'acc-a', + p256dh: 'p', + auth: 'a', + created_at: new Date('2026-08-01T00:00:00.000Z'), + }, + ]; + const listedDate = await store.listByAccount('acc-a'); + expect(listedDate[0]?.createdAt.toISOString()).toBe('2026-08-01T00:00:00.000Z'); + + sql.nextRows = [{ account_id: 'acc-a' }, { account_id: 'acc-b' }]; + expect(await store.listAccountIdsWithSubscriptions()).toEqual(['acc-a', 'acc-b']); + + await store.enqueue(pending({ id: 'o1', accountId: 'acc-a' })); + expect(sql.executes.at(-1)?.text).toMatch(/INSERT INTO push_outbox/i); + + sql.nextRows = [ + { + id: 'o1', + account_id: 'acc-a', + type: 'forum', + message_id: 'm', + payload: '{}', + status: 'pending', + attempts: 0, + claimed_until: '2026-08-03T00:01:00.000Z', + created_at: '2026-08-01T00:00:00.000Z', + }, + ]; + const claimed = await store.claimPending(5, Date.parse('2026-08-03T00:00:00.000Z'), 60_000); + expect(claimed[0]?.claimedUntil).toBeInstanceOf(Date); + expect(sql.queries.at(-1)?.text).toMatch(/FOR UPDATE SKIP LOCKED/); + + await store.markSent('o1'); + expect(sql.executes.at(-1)?.text).toMatch(/status = 'sent'/); + await store.markFailed('o1'); + expect(sql.executes.at(-1)?.text).toMatch(/attempts = attempts \+ 1/); + }); + + it('throws when upsert RETURNING is empty', async () => { + const sql = new MockSql(); + const store = new PostgresPushStore(sql); + sql.nextRows = []; + await expect(store.upsertSubscription(SUB)).rejects.toThrow(/upsert_empty/); + }); + + it('maps unknown type/status and null claimed_until safely', async () => { + const sql = new MockSql(); + const store = new PostgresPushStore(sql); + sql.nextRows = [ + { + id: 'o2', + account_id: 'acc-a', + type: 'other', + message_id: null, + payload: '{}', + status: 'weird', + attempts: 1, + claimed_until: null, + created_at: new Date('2026-08-01T00:00:00.000Z'), + }, + ]; + const claimed = await store.claimPending(1, 1, 1000); + expect(claimed[0]?.type).toBe('forum'); + expect(claimed[0]?.status).toBe('pending'); + expect(claimed[0]?.claimedUntil).toBeNull(); + }); + + it('maps a Date claimed_until from Postgres without wrapping twice', async () => { + const sql = new MockSql(); + const store = new PostgresPushStore(sql); + const until = new Date('2026-08-03T00:01:00.000Z'); + sql.nextRows = [ + { + id: 'o3', + account_id: 'acc-a', + type: 'forum', + message_id: 'msg', + payload: '{}', + status: 'pending', + attempts: 0, + claimed_until: until, + created_at: new Date('2026-08-01T00:00:00.000Z'), + }, + ]; + const claimed = await store.claimPending(1, 1, 1000); + expect(claimed[0]?.claimedUntil).toBeInstanceOf(Date); + expect(claimed[0]?.claimedUntil?.toISOString()).toBe(until.toISOString()); + }); +}); diff --git a/src/__tests__/lib/push-worker.test.ts b/src/__tests__/lib/push-worker.test.ts new file mode 100644 index 00000000..cb048605 --- /dev/null +++ b/src/__tests__/lib/push-worker.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { PushSendResult, PushSender } from '@/lib/push-sender'; +import { + InMemoryPushStore, + type PushOutboxRow, + type PushSubscriptionRecord, +} from '@/lib/push-store'; +import { + PUSH_WORKER_BATCH, + PUSH_WORKER_INTERVAL_MS, + PUSH_WORKER_LEASE_MS, + enqueueDebugPush, + enqueueForumPushes, + enqueueZapPush, + runPushWorkerTick, + startPushWorker, +} from '@/lib/push-worker'; + +class FakeSender implements PushSender { + configured: boolean; + results: PushSendResult[]; + calls: { endpoint: string; payload: string }[] = []; + + constructor(configured = true, results: PushSendResult[] = [{ ok: true }]) { + this.configured = configured; + this.results = results; + } + + isConfigured(): boolean { + return this.configured; + } + + send(sub: PushSubscriptionRecord, payload: string): Promise { + this.calls.push({ endpoint: sub.endpoint, payload }); + const next = this.results.shift() ?? { ok: true }; + return Promise.resolve(next); + } +} + +const SUB_A: PushSubscriptionRecord = { + endpoint: 'https://push.example/a', + accountId: 'author', + p256dh: 'p', + auth: 'a', + createdAt: new Date('2026-08-01T00:00:00.000Z'), +}; + +const SUB_B: PushSubscriptionRecord = { + endpoint: 'https://push.example/b', + accountId: 'other', + p256dh: 'p', + auth: 'a', + createdAt: new Date('2026-08-01T00:00:00.000Z'), +}; + +describe('push worker constants', () => { + it('exports batch, lease, and interval numbers', () => { + expect(PUSH_WORKER_BATCH).toBe(20); + expect(PUSH_WORKER_LEASE_MS).toBe(60_000); + expect(PUSH_WORKER_INTERVAL_MS).toBe(2_000); + }); +}); + +describe('enqueueForumPushes', () => { + it('skips the author and enqueues one row per other subscriber', async () => { + const store = new InMemoryPushStore(); + await store.upsertSubscription(SUB_A); + await store.upsertSubscription(SUB_B); + await enqueueForumPushes(store, 'author', 'msg-1', 1_700_000_000_000); + const claimed = await store.claimPending(10, 1_700_000_000_000, 60_000); + expect(claimed).toHaveLength(1); + expect(claimed[0]?.accountId).toBe('other'); + expect(claimed[0]?.type).toBe('forum'); + expect(JSON.parse(claimed[0]?.payload ?? '{}')).toMatchObject({ type: 'forum', tag: 'forum' }); + }); + + it('enqueues nothing when only the author is subscribed', async () => { + const store = new InMemoryPushStore(); + await store.upsertSubscription(SUB_A); + await enqueueForumPushes(store, 'author', 'msg-1', 1); + expect(await store.claimPending(10, 1, 1000)).toEqual([]); + }); +}); + +describe('enqueueZapPush', () => { + it('enqueues when the author has subscriptions', async () => { + const store = new InMemoryPushStore(); + await store.upsertSubscription(SUB_A); + await enqueueZapPush(store, 'author', 'msg-9', 5); + const claimed = await store.claimPending(10, 5, 1000); + expect(claimed).toHaveLength(1); + expect(claimed[0]?.type).toBe('zap'); + expect(JSON.parse(claimed[0]?.payload ?? '{}').tag).toBe('zap:msg-9'); + }); + + it('does nothing when the author has no subscriptions', async () => { + const store = new InMemoryPushStore(); + await enqueueZapPush(store, 'author', 'msg-9', 5); + expect(await store.claimPending(10, 5, 1000)).toEqual([]); + }); +}); + +describe('enqueueDebugPush', () => { + it('returns 0 or 1 and uses the debug payload', async () => { + const store = new InMemoryPushStore(); + expect(await enqueueDebugPush(store, 'author', 1)).toBe(0); + await store.upsertSubscription(SUB_A); + expect(await enqueueDebugPush(store, 'author', 2)).toBe(1); + const claimed = await store.claimPending(10, 2, 1000); + expect(claimed[0]?.messageId).toBeNull(); + expect(JSON.parse(claimed[0]?.payload ?? '{}')).toMatchObject({ + type: 'zap', + tag: 'debug', + title: 'Test notification', + }); + }); +}); + +describe('runPushWorkerTick', () => { + it('returns immediately when sender is unconfigured', async () => { + const store = new InMemoryPushStore(); + await store.upsertSubscription(SUB_B); + await enqueueForumPushes(store, 'author', 'm', 1); + const sender = new FakeSender(false); + await runPushWorkerTick({ store, sender, now: () => 1 }); + expect(sender.calls).toEqual([]); + expect(await store.claimPending(10, 1, 1000)).toHaveLength(1); + }); + + it('marks sent when there are no subscriptions left', async () => { + const store = new InMemoryPushStore(); + const row: PushOutboxRow = { + id: 'o1', + accountId: 'ghost', + type: 'forum', + messageId: 'm', + payload: '{}', + status: 'pending', + attempts: 0, + claimedUntil: null, + createdAt: new Date(1), + }; + await store.enqueue(row); + const sender = new FakeSender(true); + await runPushWorkerTick({ store, sender, now: () => 1 }); + expect(await store.claimPending(10, 1, 1000)).toEqual([]); + }); + + it('deletes gone subscriptions and marks sent when all gone', async () => { + const store = new InMemoryPushStore(); + await store.upsertSubscription(SUB_B); + await enqueueForumPushes(store, 'author', 'm', 1); + const sender = new FakeSender(true, [{ ok: false, reason: 'gone' }]); + await runPushWorkerTick({ store, sender, now: () => 1 }); + expect(await store.listByAccount('other')).toEqual([]); + expect(await store.claimPending(10, 1, 1000)).toEqual([]); + }); + + it('marks failed when any send fails', async () => { + const store = new InMemoryPushStore(); + await store.upsertSubscription(SUB_B); + await enqueueForumPushes(store, 'author', 'm', 1); + const sender = new FakeSender(true, [{ ok: false, reason: 'fail' }]); + await runPushWorkerTick({ store, sender, now: () => 1 }); + const again = await store.claimPending(10, 1, 1000); + expect(again).toHaveLength(1); + expect(again[0]?.attempts).toBe(1); + }); + + it('marks sent when at least one ok and none fail (gone ok)', async () => { + const store = new InMemoryPushStore(); + await store.upsertSubscription(SUB_B); + await store.upsertSubscription({ + ...SUB_B, + endpoint: 'https://push.example/c', + }); + await enqueueForumPushes(store, 'author', 'm', 1); + const sender = new FakeSender(true, [{ ok: true }, { ok: false, reason: 'gone' }]); + await runPushWorkerTick({ store, sender, now: () => 1 }); + expect(await store.listByAccount('other')).toHaveLength(1); + expect(await store.claimPending(10, 1, 1000)).toEqual([]); + }); + + it('treats not_configured mid-send as fail', async () => { + const store = new InMemoryPushStore(); + await store.upsertSubscription(SUB_B); + await enqueueForumPushes(store, 'author', 'm', 1); + const sender = new FakeSender(true, [{ ok: false, reason: 'not_configured' }]); + await runPushWorkerTick({ store, sender, now: () => 1 }); + const again = await store.claimPending(10, 1, 1000); + expect(again[0]?.attempts).toBe(1); + }); +}); + +describe('startPushWorker', () => { + it('returns a stop handle that clears the interval', () => { + vi.useFakeTimers(); + const store = new InMemoryPushStore(); + const sender = new FakeSender(false); + const handle = startPushWorker({ store, sender, now: () => 1 }, 5_000); + handle.stop(); + vi.advanceTimersByTime(10_000); + vi.useRealTimers(); + }); +}); diff --git a/src/__tests__/lib/push.test.ts b/src/__tests__/lib/push.test.ts new file mode 100644 index 00000000..bbfdc4fe --- /dev/null +++ b/src/__tests__/lib/push.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { buildForumPushPayload, buildZapPushPayload, parsePushSubscription } from '@/lib/push'; + +describe('parsePushSubscription', () => { + const validKeys = { p256dh: 'abcABC123_-', auth: 'xyzXYZ789_-' }; + + it('returns null for non-objects', () => { + expect(parsePushSubscription(null)).toBeNull(); + expect(parsePushSubscription('x')).toBeNull(); + expect(parsePushSubscription(1)).toBeNull(); + }); + + it('returns null when endpoint or keys are missing', () => { + expect(parsePushSubscription({})).toBeNull(); + expect(parsePushSubscription({ endpoint: 'https://x.test/p', keys: null })).toBeNull(); + expect(parsePushSubscription({ endpoint: '', keys: validKeys })).toBeNull(); + expect(parsePushSubscription({ endpoint: 1, keys: validKeys })).toBeNull(); + }); + + it('returns null for invalid key charset or empty keys', () => { + expect( + parsePushSubscription({ + endpoint: 'https://x.test/p', + keys: { p256dh: '', auth: 'abc' }, + }), + ).toBeNull(); + expect( + parsePushSubscription({ + endpoint: 'https://x.test/p', + keys: { p256dh: 'abc', auth: '' }, + }), + ).toBeNull(); + expect( + parsePushSubscription({ + endpoint: 'https://x.test/p', + keys: { p256dh: 'abc+', auth: 'xyz' }, + }), + ).toBeNull(); + }); + + it('returns null for non-URL endpoints and non-https remote hosts', () => { + expect(parsePushSubscription({ endpoint: 'not a url', keys: validKeys })).toBeNull(); + expect(parsePushSubscription({ endpoint: 'http://example.com/p', keys: validKeys })).toBeNull(); + expect(parsePushSubscription({ endpoint: 'ftp://localhost/p', keys: validKeys })).toBeNull(); + }); + + it('allows https endpoints and localhost/127.0.0.1 http for tests', () => { + expect( + parsePushSubscription({ + endpoint: 'https://push.example/sub', + keys: { p256dh: 'abc=', auth: 'xyz==' }, + }), + ).toEqual({ + endpoint: 'https://push.example/sub', + p256dh: 'abc=', + auth: 'xyz==', + }); + expect( + parsePushSubscription({ + endpoint: 'http://localhost:8080/p', + keys: validKeys, + })?.endpoint, + ).toBe('http://localhost:8080/p'); + expect( + parsePushSubscription({ + endpoint: 'http://127.0.0.1/p', + keys: validKeys, + })?.endpoint, + ).toBe('http://127.0.0.1/p'); + }); +}); + +describe('buildForumPushPayload', () => { + it('returns the fixed English forum payload', () => { + expect(buildForumPushPayload()).toEqual({ + type: 'forum', + title: 'New message on 21.gifts', + body: 'Someone posted in the living room.', + url: '/welcome', + tag: 'forum', + }); + }); +}); + +describe('buildZapPushPayload', () => { + it('includes the message id in the tag', () => { + expect(buildZapPushPayload('msg-1')).toEqual({ + type: 'zap', + title: 'Bitcoin on your post', + body: 'Someone sent you sats.', + url: '/welcome', + tag: 'zap:msg-1', + }); + }); +}); diff --git a/src/__tests__/routes/debug-push.test.ts b/src/__tests__/routes/debug-push.test.ts new file mode 100644 index 00000000..f051d4eb --- /dev/null +++ b/src/__tests__/routes/debug-push.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest'; +import { Hono } from 'hono'; +import { InMemoryAuthStore } from '@/lib/auth/store'; +import { InMemoryPushStore } from '@/lib/push-store'; +import { debugPushRoutes } from '@/routes/debug-push'; + +const now = (): number => 1_700_000_000_000; +const LINKING_KEY = `02${'a'.repeat(64)}`; + +function mount(args: { + authStore?: InMemoryAuthStore; + pushStore?: InMemoryPushStore; + debugToken?: string | undefined; + vapidPublicKey?: string | undefined; +}): Hono { + return new Hono().route( + '/debug/push-ping', + debugPushRoutes({ + authStore: args.authStore ?? new InMemoryAuthStore(), + pushStore: args.pushStore ?? new InMemoryPushStore(), + now, + debugToken: args.debugToken, + vapidPublicKey: args.vapidPublicKey, + }), + ); +} + +async function seededStore(): Promise { + const store = new InMemoryAuthStore(); + await store.createAccount({ + id: 'acc', + linkingKey: LINKING_KEY, + role: 'basis', + name: 'Ada', + lightningAddress: null, + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: 'a'.repeat(64), + createdAt: 1_000_000, + rulesAgreedAt: null, + }); + return store; +} + +describe('POST /debug/push-ping', () => { + it('returns 503 before JSON when debug is not configured', async () => { + const res = await mount({ debugToken: '' }).request('/debug/push-ping', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: 'not-json', + }); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ error: 'Debug is not configured' }); + }); + + it('returns 401 before JSON when the debug token is wrong', async () => { + const res = await mount({ debugToken: 'secret' }).request('/debug/push-ping', { + method: 'POST', + headers: { + authorization: 'Bearer wrong', + 'content-type': 'application/json', + }, + body: 'not-json', + }); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: 'Unauthorized' }); + }); + + it('returns 503 when push is not configured', async () => { + const res = await mount({ + debugToken: 'secret', + vapidPublicKey: undefined, + }).request('/debug/push-ping', { + method: 'POST', + headers: { + authorization: 'Bearer secret', + 'content-type': 'application/json', + }, + body: JSON.stringify({ accountId: 'acc' }), + }); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ error: 'Push is not configured' }); + }); + + it('returns 400 when accountId is missing', async () => { + const res = await mount({ + debugToken: 'secret', + vapidPublicKey: 'pub', + }).request('/debug/push-ping', { + method: 'POST', + headers: { + authorization: 'Bearer secret', + 'content-type': 'application/json', + }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: 'Expected a JSON body with an "accountId" string', + }); + }); + + it('returns 404 for an unknown account', async () => { + const res = await mount({ + debugToken: 'secret', + vapidPublicKey: 'pub', + }).request('/debug/push-ping', { + method: 'POST', + headers: { + authorization: 'Bearer secret', + 'content-type': 'application/json', + }, + body: JSON.stringify({ accountId: 'missing' }), + }); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'Not found' }); + }); + + it('returns enqueued 0 or 1', async () => { + const auth = await seededStore(); + const pushStore = new InMemoryPushStore(); + const none = await mount({ + authStore: auth, + pushStore, + debugToken: 'secret', + vapidPublicKey: 'pub', + }).request('/debug/push-ping', { + method: 'POST', + headers: { + authorization: 'Bearer secret', + 'content-type': 'application/json', + }, + body: JSON.stringify({ accountId: 'acc' }), + }); + expect(none.status).toBe(200); + expect(await none.json()).toEqual({ enqueued: 0 }); + + await pushStore.upsertSubscription({ + endpoint: 'https://push.example/a', + accountId: 'acc', + p256dh: 'p', + auth: 'a', + createdAt: new Date(now()), + }); + const one = await mount({ + authStore: auth, + pushStore, + debugToken: 'secret', + vapidPublicKey: 'pub', + }).request('/debug/push-ping', { + method: 'POST', + headers: { + authorization: 'Bearer secret', + 'content-type': 'application/json', + }, + body: JSON.stringify({ accountId: 'acc' }), + }); + expect(one.status).toBe(200); + expect(await one.json()).toEqual({ enqueued: 1 }); + }); +}); diff --git a/src/__tests__/routes/messages.test.ts b/src/__tests__/routes/messages.test.ts index 26fc40aa..b7e6d10a 100644 --- a/src/__tests__/routes/messages.test.ts +++ b/src/__tests__/routes/messages.test.ts @@ -5,6 +5,7 @@ import { InMemoryMessageStore, type MessageStore } from '@/lib/message-store'; 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'; function parsedEvents(warn: ReturnType): Array> { return warn.mock.calls @@ -366,6 +367,79 @@ describe('POST /messages', () => { expect(body.messages[0]).toEqual(created); }); + it('enqueues a forum push for other subscribed accounts, not the author', 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 post = await app.request('/messages', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'hello living room' }), + }); + expect(post.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('still returns 200 when forum 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 post = await app.request('/messages', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'hello living room' }), + }); + expect(post.status).toBe(200); + expect(parsedEvents(warn).some((e) => e['event'] === 'push.enqueue.failed')).toBe(true); + }); + it('includes the session account role on POST', async () => { const authStore = await namedStore('Ada'); const account = await authStore.getAccount('acc'); diff --git a/src/__tests__/routes/push.test.ts b/src/__tests__/routes/push.test.ts new file mode 100644 index 00000000..75beb968 --- /dev/null +++ b/src/__tests__/routes/push.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from 'vitest'; +import { Hono } from 'hono'; +import { InMemoryAuthStore } from '@/lib/auth/store'; +import { InMemoryPushStore } from '@/lib/push-store'; +import { pushRoutes } from '@/routes/push'; + +const now = (): number => 1_700_000_000_000; +const AUTH = { authorization: 'Bearer tok' }; +const LINKING_KEY = `02${'a'.repeat(64)}`; + +function mount( + authStore: InMemoryAuthStore, + pushStore: InMemoryPushStore = new InMemoryPushStore(), + vapidPublicKey: string | undefined = 'vapid-pub', +): Hono { + return new Hono().route( + '/', + pushRoutes({ + authStore, + pushStore, + now, + vapidPublicKey, + }), + ); +} + +async function seededStore(): Promise { + const store = new InMemoryAuthStore(); + await store.createAccount({ + id: 'acc', + linkingKey: LINKING_KEY, + role: 'basis', + name: 'Ada', + lightningAddress: null, + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: 'a'.repeat(64), + createdAt: 1_000_000, + rulesAgreedAt: null, + }); + await store.createSession({ token: 'tok', accountId: 'acc', createdAt: now() }); + return store; +} + +const validBody = { + endpoint: 'https://push.example/sub', + keys: { p256dh: 'abcABC123_-', auth: 'xyzXYZ789_-' }, +}; + +describe('GET /push/vapid-public', () => { + it('returns 401 without bearer even when unconfigured', async () => { + const res = await mount(new InMemoryAuthStore(), new InMemoryPushStore(), undefined).request( + '/push/vapid-public', + ); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: 'Unauthorized' }); + }); + + it('returns 503 when push is not configured', async () => { + const auth = await seededStore(); + const res = await mount(auth, new InMemoryPushStore(), ' ').request('/push/vapid-public', { + headers: AUTH, + }); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ error: 'Push is not configured' }); + }); + + it('returns the public key when configured', async () => { + const auth = await seededStore(); + const res = await mount(auth).request('/push/vapid-public', { headers: AUTH }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ publicKey: 'vapid-pub' }); + }); +}); + +describe('POST /me/push-subscriptions', () => { + it('returns 401 / 503 / 400 / 200', async () => { + expect( + ( + await mount(new InMemoryAuthStore()).request('/me/push-subscriptions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(validBody), + }) + ).status, + ).toBe(401); + + const auth = await seededStore(); + const unconfigured = await mount(auth, new InMemoryPushStore(), ' ').request( + '/me/push-subscriptions', + { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify(validBody), + }, + ); + expect(unconfigured.status).toBe(503); + + const bad = await mount(auth).request('/me/push-subscriptions', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ endpoint: 'http://evil.test/p', keys: validBody.keys }), + }); + expect(bad.status).toBe(400); + expect(await bad.json()).toEqual({ error: 'Invalid subscription' }); + + const pushStore = new InMemoryPushStore(); + const ok = await mount(auth, pushStore).request('/me/push-subscriptions', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify(validBody), + }); + expect(ok.status).toBe(200); + expect(await ok.json()).toEqual({ + endpoint: validBody.endpoint, + createdAt: new Date(now()).toISOString(), + }); + expect(await pushStore.listByAccount('acc')).toHaveLength(1); + }); +}); + +describe('DELETE /me/push-subscriptions', () => { + it('returns 401 / 503 / 400 / 404 / 200', async () => { + expect( + ( + await mount(new InMemoryAuthStore()).request('/me/push-subscriptions', { + method: 'DELETE', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ endpoint: validBody.endpoint }), + }) + ).status, + ).toBe(401); + + const auth = await seededStore(); + expect( + ( + await mount(auth, new InMemoryPushStore(), ' ').request('/me/push-subscriptions', { + method: 'DELETE', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ endpoint: validBody.endpoint }), + }) + ).status, + ).toBe(503); + + expect( + ( + await mount(auth).request('/me/push-subscriptions', { + method: 'DELETE', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({}), + }) + ).status, + ).toBe(400); + + expect( + ( + await mount(auth).request('/me/push-subscriptions', { + method: 'DELETE', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ endpoint: validBody.endpoint }), + }) + ).status, + ).toBe(404); + + const pushStore = new InMemoryPushStore(); + await pushStore.upsertSubscription({ + endpoint: validBody.endpoint, + accountId: 'acc', + p256dh: validBody.keys.p256dh, + auth: validBody.keys.auth, + createdAt: new Date(now()), + }); + const ok = await mount(auth, pushStore).request('/me/push-subscriptions', { + method: 'DELETE', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ endpoint: validBody.endpoint }), + }); + expect(ok.status).toBe(200); + expect(await ok.json()).toEqual({ ok: true }); + }); +}); diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts index 5f0c0888..8ca9a986 100644 --- a/src/__tests__/server.test.ts +++ b/src/__tests__/server.test.ts @@ -1,6 +1,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { InMemoryAuthStore } from '@/lib/auth/store'; import { createApp, resolveBindAddr, parseBindAddr } from '@/server'; +function b64url(bytes: Uint8Array): string { + return Buffer.from(bytes) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/u, ''); +} + +const VAPID_PUBLIC_BYTES = new Uint8Array(65); +VAPID_PUBLIC_BYTES[0] = 4; +const VAPID_PUBLIC_KEY = b64url(VAPID_PUBLIC_BYTES); +const VAPID_PRIVATE_KEY = b64url(new Uint8Array(32).fill(1)); + function parsedEvents(warn: ReturnType): Array> { return warn.mock.calls .map((call) => call[0]) @@ -69,6 +83,47 @@ describe('createApp', () => { expect(res.status).toBe(503); }); + it('reads VAPID public key from the environment when createApp omits it', async () => { + process.env['VAPID_PUBLIC_KEY'] = VAPID_PUBLIC_KEY; + process.env['VAPID_PRIVATE_KEY'] = VAPID_PRIVATE_KEY; + const store = new InMemoryAuthStore(); + await store.createAccount({ + id: 'acc', + linkingKey: `02${'a'.repeat(64)}`, + role: 'basis', + name: 'Ada', + lightningAddress: null, + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: 'a'.repeat(64), + createdAt: 1_000_000, + rulesAgreedAt: null, + }); + await store.createSession({ token: 'tok', accountId: 'acc', createdAt: Date.now() }); + const app = createApp({ authStore: store }); + const res = await app.request('/push/vapid-public', { + headers: { authorization: 'Bearer tok' }, + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ publicKey: VAPID_PUBLIC_KEY }); + delete process.env['VAPID_PUBLIC_KEY']; + delete process.env['VAPID_PRIVATE_KEY']; + }); + + it('returns 401 on GET /push/vapid-public without a session', async () => { + delete process.env['VAPID_PUBLIC_KEY']; + delete process.env['VAPID_PRIVATE_KEY']; + const app = createApp(); + const res = await app.request('/push/vapid-public'); + expect(res.status).toBe(401); + }); + + it('returns 503 on POST /debug/push-ping when debugToken is blank', async () => { + const app = createApp({ debugToken: '' }); + const res = await app.request('/debug/push-ping', { method: 'POST' }); + expect(res.status).toBe(503); + }); + it('emits http.request for GET /info', async () => { await createApp().request('/info'); const httpEvents = parsedEvents(warn).filter((e) => e['event'] === 'http.request'); diff --git a/src/index.ts b/src/index.ts index db81a4a9..89bcee90 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,10 @@ import type { SqlClient } from './lib/auth/sql'; import { WebsocketNostrPublisher } from './lib/nostr/publish'; import { WebsocketNostrQuerier } from './lib/nostr/query'; import { startNostrWorker, WORKER_INTERVAL_MS } from './lib/nostr/worker'; +import { resolveVapidConfig } from './lib/push-config'; +import { UnconfiguredPushSender, WebPushSender, type PushSender } from './lib/push-sender'; +import { InMemoryPushStore } from './lib/push-store'; +import { PUSH_WORKER_INTERVAL_MS, startPushWorker } from './lib/push-worker'; import { createApp, parseBindAddr, resolveBindAddr } from './server'; /* v8 ignore start — Bun runtime boot path; exercised by smoke tests, not unit tests */ @@ -35,19 +39,37 @@ if (import.meta.main) { const databaseUrl = process.env['DATABASE_URL']; // BTC_USD_CANDLES_URL is optional — resolveCandlesUrl inside openBootStores // falls back to Coinbase; unset does not fail boot. + const boot = await openBootStores(databaseUrl, createBunSqlClient); const { authStore, giftStore, giftRecorder, btcUsdRates, messageStore, nostrKek, contactStore } = - await openBootStores(databaseUrl, createBunSqlClient); + boot; + const pushStore = boot.pushStore ?? new InMemoryPushStore(); + const vapid = resolveVapidConfig(process.env); + let sender: PushSender = new UnconfiguredPushSender(); + let vapidPublicKey: string | undefined; + if (vapid !== null) { + try { + sender = new WebPushSender(vapid); + vapidPublicKey = vapid.publicKey; + } catch { + console.warn(JSON.stringify({ event: 'push.vapid.invalid' })); + } + } const app = createApp({ authStore, btcUsdRates, + pushStore, ...(giftStore === undefined ? {} : { giftStore }), ...(giftRecorder === undefined ? {} : { giftRecorder }), ...(messageStore === undefined ? {} : { messageStore }), ...(nostrKek === undefined ? {} : { nostrKek }), ...(contactStore === undefined ? {} : { contactStore }), + vapidPublicKey: vapidPublicKey ?? '', }); Bun.serve({ fetch: app.fetch, hostname: host, port }); console.warn(`21gifts-api listening on ${host}:${port}`); + if (sender.isConfigured()) { + startPushWorker({ store: pushStore, sender, now: Date.now }, PUSH_WORKER_INTERVAL_MS); + } if (nostrKek !== undefined && messageStore !== undefined) { const publisher = new WebsocketNostrPublisher(); startNostrWorker( @@ -60,6 +82,7 @@ if (import.meta.main) { fetchImpl: globalThis.fetch, now: Date.now, env: process.env, + pushStore, }, WORKER_INTERVAL_MS, ); diff --git a/src/lib/boot-stores.ts b/src/lib/boot-stores.ts index e7c928dc..45c2d6b6 100644 --- a/src/lib/boot-stores.ts +++ b/src/lib/boot-stores.ts @@ -17,8 +17,9 @@ import { SqlGiftRecorder, type GiftRecorder } from '@/lib/gift-recorder'; import { logEvent } from '@/lib/log'; import { migrateContactSchema, PostgresContactStore, type ContactStore } from '@/lib/contact-store'; import { migrateMessageSchema, PostgresMessageStore, type MessageStore } from '@/lib/message-store'; +import { migratePushSchema, PostgresPushStore, type PushStore } from '@/lib/push-store'; -/** Auth, gift, forum, contact, and FX persistence produced from `DATABASE_URL`. */ +/** Auth, gift, forum, contact, push, and FX persistence produced from `DATABASE_URL`. */ export interface BootStores { /** Durable or in-memory account store. */ authStore: AuthStore; @@ -46,6 +47,11 @@ export interface BootStores { * opened so `createApp` keeps the empty in-memory default. */ contactStore: ContactStore | undefined; + /** + * Postgres-backed push store, or `undefined` when no SQL client was + * opened so the entry point keeps an in-memory default. + */ + pushStore: PushStore | undefined; } /** Optional FX wiring so tests never hit the network. */ @@ -59,17 +65,18 @@ export interface BootFxOptions { } /** - * Open auth, optional gift, forum, and contact persistence, and the BTC-USD - * rate book from `DATABASE_URL`. + * Open auth, optional gift, forum, contact, and push persistence, and the + * BTC-USD rate book from `DATABASE_URL`. * * Blank or unset URL yields in-memory auth, `giftStore: undefined`, * `giftRecorder: undefined`, `messageStore: undefined`, - * `contactStore: undefined`, `nostrKek: undefined`, and an empty - * {@link InMemoryBtcUsdStore}. A set URL asks `createClient` for one - * `SqlClient`, migrates auth (via `openAuthStore`) then the FX, `message`, - * `contact`, and `db_change` schemas, builds a {@link QueryGiftStore}, - * {@link SqlGiftRecorder}, {@link PostgresMessageStore}, and - * {@link PostgresContactStore}, parses `NOSTR_NSEC_KEK` into `nostrKek`, + * `contactStore: undefined`, `pushStore: undefined`, `nostrKek: undefined`, + * and an empty {@link InMemoryBtcUsdStore}. A set URL asks `createClient` + * for one `SqlClient`, migrates auth (via `openAuthStore`) then the FX, + * `message`, `contact`, `push`, and `db_change` schemas, builds a + * {@link QueryGiftStore}, {@link SqlGiftRecorder}, + * {@link PostgresMessageStore}, {@link PostgresContactStore}, and + * {@link PostgresPushStore}, parses `NOSTR_NSEC_KEK` into `nostrKek`, * constructs {@link PostgresBtcUsdStore}, and best-effort fills rates for * the outbound gift day range (failures log `gifts.fx.boot_fill.failed` and * do not throw). Memory boots leave `nostrKek` undefined and do not run @@ -106,6 +113,7 @@ export async function openBootStores( messageStore: undefined, nostrKek: undefined, contactStore: undefined, + pushStore: undefined, }; } @@ -114,6 +122,7 @@ export async function openBootStores( await migrateBtcUsdSchema(sqlClient); await migrateMessageSchema(sqlClient); await migrateContactSchema(sqlClient); + await migratePushSchema(sqlClient); await migrateDbChangeSchema(sqlClient); const fetchImpl = fx?.fetchImpl ?? globalThis.fetch; @@ -144,5 +153,15 @@ export async function openBootStores( const giftRecorder = new SqlGiftRecorder(giftSql); const messageStore = new PostgresMessageStore(sqlClient); const contactStore = new PostgresContactStore(sqlClient); - return { authStore, giftStore, giftRecorder, btcUsdRates, messageStore, nostrKek, contactStore }; + const pushStore = new PostgresPushStore(sqlClient); + return { + authStore, + giftStore, + giftRecorder, + btcUsdRates, + messageStore, + nostrKek, + contactStore, + pushStore, + }; } diff --git a/src/lib/db-change.ts b/src/lib/db-change.ts index b8239460..7b9ddccd 100644 --- a/src/lib/db-change.ts +++ b/src/lib/db-change.ts @@ -27,7 +27,7 @@ BEGIN IF j IS NULL THEN RETURN NULL; END IF; - FOREACH k IN ARRAY ARRAY['token', 'challenge', 'nostr_nsec_ciphertext', 'nonce', 'view_key'] + FOREACH k IN ARRAY ARRAY['token', 'challenge', 'nostr_nsec_ciphertext', 'nonce', 'view_key', 'endpoint', 'p256dh', 'auth'] LOOP IF outj ? k AND jsonb_typeof(outj -> k) IS DISTINCT FROM 'null' THEN outj := jsonb_set( diff --git a/src/lib/nostr/worker.ts b/src/lib/nostr/worker.ts index 70cd3b76..d8750152 100644 --- a/src/lib/nostr/worker.ts +++ b/src/lib/nostr/worker.ts @@ -21,6 +21,7 @@ import { } from '@/lib/nostr/relays'; import { signEventForAccount } from '@/lib/nostr/sign'; import { indexOpenZapReceipts } from '@/lib/nostr/zap-index'; +import type { PushStore } from '@/lib/push-store'; /** Max rows claimed or keyed profile attempts per tick. */ export const WORKER_BATCH = 20; @@ -54,6 +55,8 @@ export interface NostrWorkerDeps { now: () => number; /** Env slice for write-set flags. */ env: Record; + /** Optional push store for zap enqueue after a newly indexed receipt. */ + pushStore?: PushStore; } type Kind0Reservation = { @@ -141,6 +144,7 @@ export async function runNostrWorkerTick(deps: NostrWorkerDeps): Promise { now: deps.now, fetchImpl: deps.fetchImpl, ...(deps.verifyReceipt === undefined ? {} : { verifyReceipt: deps.verifyReceipt }), + ...(deps.pushStore === undefined ? {} : { pushStore: deps.pushStore }), }); } diff --git a/src/lib/nostr/zap-index.ts b/src/lib/nostr/zap-index.ts index 3098d9bc..b6aafd01 100644 --- a/src/lib/nostr/zap-index.ts +++ b/src/lib/nostr/zap-index.ts @@ -7,6 +7,8 @@ import type { MessageStore, ZapIngestRow } from '@/lib/message-store'; import type { FetchFn } from '@/lib/lnurlp'; import { resolveLnurlp } from '@/lib/lnurlp'; import type { NostrEventFrame, NostrQuerier } from '@/lib/nostr/query'; +import type { PushStore } from '@/lib/push-store'; +import { enqueueZapPush } from '@/lib/push-worker'; import { verifyEvent } from 'nostr-tools/pure'; /** Minimal zap receipt fields we validate. */ @@ -235,6 +237,8 @@ export async function indexOpenZapReceipts(args: { fetchImpl: FetchFn; /** Signature check; production uses nostr-tools `verifyEvent`. */ verifyReceipt?: (event: NostrEventFrame) => boolean; + /** Optional push store; newly indexed receipts enqueue a zap push. */ + pushStore?: PushStore; }): Promise { if (args.urls.length === 0) { return; @@ -304,6 +308,7 @@ async function ingestOneReceipt( now: () => number; fetchImpl: FetchFn; verifyReceipt: (event: NostrEventFrame) => boolean; + pushStore?: PushStore; }, ): Promise { if (event.kind !== 9735) { @@ -487,7 +492,7 @@ async function ingestOneReceipt( return; } - await indexZapReceipt({ + const indexed = await indexZapReceipt({ store: args.store, messageId: row.id, receipt: { id: event.id, pubkey: event.pubkey, tags: event.tags }, @@ -496,6 +501,13 @@ async function ingestOneReceipt( receiptEvent: receipt, noteEventId, }); + if (indexed && args.pushStore !== undefined) { + try { + await enqueueZapPush(args.pushStore, row.accountId, row.id, args.now()); + } catch { + logEvent('push.enqueue.failed'); + } + } } /** diff --git a/src/lib/push-config.ts b/src/lib/push-config.ts new file mode 100644 index 00000000..c9c39193 --- /dev/null +++ b/src/lib/push-config.ts @@ -0,0 +1,80 @@ +/** + * VAPID configuration for self-hosted Web Push. + * + * Missing or blank keys yield `null` so the process still boots; push HTTP + * returns 503 until both keys are set. + */ + +/** Resolved VAPID credentials used by the Web Push sender. */ +export interface VapidConfig { + /** URL-safe base64 P-256 public key (uncompressed). Not a secret. */ + publicKey: string; + /** URL-safe base64 P-256 private key. Secret — never log. */ + privateKey: string; + /** Contact / subject URI for VAPID (default `https://21.gifts`). */ + subject: string; +} + +/** + * Decode URL-safe base64 (padding optional) to bytes. + * + * @param value - URL-safe base64 string. + * @returns Bytes, or `null` when the alphabet is invalid. + */ +function decodeUrlSafeBase64(value: string): Uint8Array | null { + const padded = value.replace(/-/g, '+').replace(/_/g, '/'); + const pad = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4)); + try { + const buf = Buffer.from(`${padded}${pad}`, 'base64'); + if (buf.length === 0 && value.trim() !== '') { + return null; + } + return new Uint8Array(buf); + /* v8 ignore next 3 -- Buffer.from does not throw on invalid alphabet */ + } catch { + return null; + } +} + +/** + * Resolve VAPID credentials from an environment slice. + * + * Missing, blank, or malformed keys/subject yield `null` so the process + * still boots; push HTTP returns 503. Public key must decode to 65 bytes + * (uncompressed P-256), private key to 32 bytes. Subject must be `https:` + * or `mailto:` so `web-push` `setVapidDetails` cannot throw at boot. + * + * @param env - Process environment (injected so tests need not mutate it). + * @returns Config when both keys and the subject are usable; otherwise `null`. + */ +export function resolveVapidConfig(env: Record): VapidConfig | null { + const publicKey = env['VAPID_PUBLIC_KEY']; + const privateKey = env['VAPID_PRIVATE_KEY']; + if ( + publicKey === undefined || + publicKey.trim() === '' || + privateKey === undefined || + privateKey.trim() === '' + ) { + return null; + } + const subjectRaw = env['VAPID_SUBJECT']; + const subject = + subjectRaw !== undefined && subjectRaw.trim() !== '' ? subjectRaw.trim() : 'https://21.gifts'; + if (!/^https:/i.test(subject) && !/^mailto:/i.test(subject)) { + return null; + } + const pubBytes = decodeUrlSafeBase64(publicKey.trim()); + const privBytes = decodeUrlSafeBase64(privateKey.trim()); + if (pubBytes === null || pubBytes.length !== 65 || pubBytes[0] !== 4) { + return null; + } + if (privBytes === null || privBytes.length !== 32) { + return null; + } + return { + publicKey: publicKey.trim(), + privateKey: privateKey.trim(), + subject, + }; +} diff --git a/src/lib/push-sender.ts b/src/lib/push-sender.ts new file mode 100644 index 00000000..084f0d6e --- /dev/null +++ b/src/lib/push-sender.ts @@ -0,0 +1,143 @@ +/** + * Web Push delivery collaborator (VAPID via `web-push`). + */ + +import webpush from 'web-push'; +import type { VapidConfig } from '@/lib/push-config'; +import type { PushSubscriptionRecord } from '@/lib/push-store'; + +/** Outcome of one `send` attempt. */ +export type PushSendResult = + { ok: true } | { ok: false; reason: 'gone' | 'fail' | 'not_configured' }; + +/** + * Sends a JSON payload to one browser subscription. + */ +export interface PushSender { + /** + * Whether VAPID is configured and sends may succeed. + * + * @returns Configuration flag. + */ + isConfigured(): boolean; + + /** + * Deliver `payload` to `sub`. + * + * @param sub - Target subscription. + * @param payload - JSON string body. + * @returns Send outcome. + */ + send(sub: PushSubscriptionRecord, payload: string): Promise; +} + +/** + * No-op sender used when VAPID env is missing. Process still boots. + */ +export class UnconfiguredPushSender implements PushSender { + /** + * Always false. + * + * @returns `false`. + */ + isConfigured(): boolean { + return false; + } + + /** + * Refuse delivery. + * + * @param _sub - Unused. + * @param _payload - Unused. + * @returns `{ ok: false, reason: 'not_configured' }`. + */ + send(_sub: PushSubscriptionRecord, _payload: string): Promise { + return Promise.resolve({ ok: false, reason: 'not_configured' }); + } +} + +/** Extract a Web Push HTTP status from a thrown error when present. */ +function statusCodeOf(err: unknown): number | undefined { + if (err === null || typeof err !== 'object') { + return undefined; + } + const code = (err as { statusCode?: unknown }).statusCode; + return typeof code === 'number' ? code : undefined; +} + +/** Build an optional ASCII topic from payload JSON `tag` (max 32). */ +function topicFromPayload(payload: string): string | undefined { + try { + const parsed = JSON.parse(payload) as unknown; + if (parsed === null || typeof parsed !== 'object') { + return undefined; + } + const tag = (parsed as { tag?: unknown }).tag; + if (typeof tag !== 'string') { + return undefined; + } + const ascii = [...tag].filter((ch) => ch.charCodeAt(0) <= 127).join(''); + if (ascii === '') { + return undefined; + } + return ascii.slice(0, 32); + } catch { + return undefined; + } +} + +/** + * VAPID Web Push sender using the `web-push` package. + */ +export class WebPushSender implements PushSender { + /** + * @param config - Resolved VAPID credentials. + */ + constructor(config: VapidConfig) { + webpush.setVapidDetails(config.subject, config.publicKey, config.privateKey); + } + + /** + * Always true for a constructed sender. + * + * @returns `true`. + */ + isConfigured(): boolean { + return true; + } + + /** + * Call `web-push` `sendNotification`. Maps 404/410 to `gone`. + * + * @param sub - Target subscription. + * @param payload - JSON string body. + * @returns Send outcome. + */ + async send(sub: PushSubscriptionRecord, payload: string): Promise { + const topic = topicFromPayload(payload); + const options: { + TTL: number; + topic?: string; + } = { TTL: 86400 }; + if (topic !== undefined) { + options.topic = topic; + } + try { + await webpush.sendNotification( + { + endpoint: sub.endpoint, + keys: { p256dh: sub.p256dh, auth: sub.auth }, + }, + payload, + options, + ); + return { ok: true }; + } catch (err) { + const status = statusCodeOf(err); + if (status === 404 || status === 410) { + return { ok: false, reason: 'gone' }; + } + return { ok: false, reason: 'fail' }; + } + } +} diff --git a/src/lib/push-store.ts b/src/lib/push-store.ts new file mode 100644 index 00000000..35295598 --- /dev/null +++ b/src/lib/push-store.ts @@ -0,0 +1,548 @@ +/** + * Persistence for Web Push subscriptions and the notification outbox. + * + * v1 default is in-memory. Production boot injects Postgres when + * `DATABASE_URL` is set. + */ + +import type { SqlClient } from '@/lib/auth/sql'; + +/** One browser PushSubscription bound to an account. */ +export interface PushSubscriptionRecord { + /** Push service endpoint URL (primary key). */ + endpoint: string; + /** Owning account id. */ + accountId: string; + /** Client public key (url-safe base64). */ + p256dh: string; + /** Auth secret (url-safe base64). */ + auth: string; + /** When the subscription was first stored. */ + createdAt: Date; +} + +/** One queued notification awaiting the push worker. */ +export interface PushOutboxRow { + /** Outbox row id. */ + id: string; + /** Recipient account id. */ + accountId: string; + /** Notification kind. */ + type: 'forum' | 'zap'; + /** Forum message id when applicable; null for debug pings. */ + messageId: string | null; + /** JSON string payload. */ + payload: string; + /** Delivery status. */ + status: 'pending' | 'sent' | 'failed'; + /** Failed send attempts so far. */ + attempts: number; + /** Lease expiry while a worker owns the row. */ + claimedUntil: Date | null; + /** Enqueue time. */ + createdAt: Date; +} + +/** + * Persistence port for push subscriptions and outbox rows. + */ +export interface PushStore { + /** + * Insert or rebind a subscription by endpoint. + * + * @param row - Fully formed subscription. + * @returns The stored row (original `createdAt` on endpoint conflict). + */ + upsertSubscription(row: PushSubscriptionRecord): Promise; + + /** + * Remove a subscription for an account + endpoint. + * + * @param accountId - Owning account. + * @param endpoint - Push endpoint URL. + * @returns Whether a matching row was removed. + */ + deleteSubscription(accountId: string, endpoint: string): Promise; + + /** + * List subscriptions for one account (caller-owned copies). + * + * @param accountId - Account id. + * @returns Subscription rows. + */ + listByAccount(accountId: string): Promise; + + /** + * Distinct account ids that currently have at least one subscription. + * + * @returns Account ids. + */ + listAccountIdsWithSubscriptions(): Promise; + + /** + * Append a pending outbox row. + * + * @param row - Fully formed outbox row. + */ + enqueue(row: PushOutboxRow): Promise; + + /** + * Claim pending outbox rows with a lease (oldest first). + * + * @param limit - Max rows. + * @param nowMs - Clock. + * @param leaseMs - Lease duration. + * @returns Claimed rows. + */ + claimPending(limit: number, nowMs: number, leaseMs: number): Promise; + + /** + * Mark an outbox row as sent. + * + * @param id - Outbox id. + */ + markSent(id: string): Promise; + + /** + * Increment attempts; terminal failed at 8, otherwise re-queue pending. + * + * @param id - Outbox id. + */ + markFailed(id: string): Promise; +} + +/** Idempotent DDL for push tables (matches `docs/schema/push.sql`). */ +export const PUSH_SCHEMA_SQL: readonly string[] = [ + `CREATE TABLE IF NOT EXISTS push_subscription ( + endpoint text PRIMARY KEY, + account_id uuid NOT NULL REFERENCES account (id), + p256dh text NOT NULL, + auth text NOT NULL, + created_at timestamptz NOT NULL +)`, + `CREATE INDEX IF NOT EXISTS push_subscription_account_id_idx ON push_subscription (account_id)`, + `CREATE TABLE IF NOT EXISTS push_outbox ( + id uuid PRIMARY KEY, + account_id uuid NOT NULL REFERENCES account (id), + type text NOT NULL CHECK (type IN ('forum', 'zap')), + message_id uuid, + payload text NOT NULL, + status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')), + attempts integer NOT NULL DEFAULT 0, + claimed_until timestamptz, + created_at timestamptz NOT NULL +)`, + `CREATE INDEX IF NOT EXISTS push_outbox_pending_idx ON push_outbox (created_at, id) WHERE status = 'pending'`, +]; + +/** + * Apply {@link PUSH_SCHEMA_SQL} in order. Idempotent. + * + * @param sql - Parameter-bound SQL client. + * @returns Resolves when every statement has executed. + */ +export async function migratePushSchema(sql: SqlClient): Promise { + for (const statement of PUSH_SCHEMA_SQL) { + await sql.execute(statement); + } +} + +/** Copy a subscription so callers cannot mutate store state. */ +function copySub(row: PushSubscriptionRecord): PushSubscriptionRecord { + return { + ...row, + createdAt: new Date(row.createdAt.getTime()), + }; +} + +/** Copy an outbox row so callers cannot mutate store state. */ +function copyOutbox(row: PushOutboxRow): PushOutboxRow { + return { + ...row, + createdAt: new Date(row.createdAt.getTime()), + claimedUntil: row.claimedUntil === null ? null : new Date(row.claimedUntil.getTime()), + }; +} + +/** + * Process-local {@link PushStore}. Used in tests and when no database URL + * is configured — the process still boots. + */ +export class InMemoryPushStore implements PushStore { + readonly #subs = new Map(); + readonly #outbox: PushOutboxRow[] = []; + + /** + * Insert or rebind by endpoint; keep original `createdAt` on conflict. + * + * @param row - Subscription to store. + * @returns A copy of the stored row. + */ + upsertSubscription(row: PushSubscriptionRecord): Promise { + const existing = this.#subs.get(row.endpoint); + if (existing !== undefined) { + const stored: PushSubscriptionRecord = { + endpoint: row.endpoint, + accountId: row.accountId, + p256dh: row.p256dh, + auth: row.auth, + createdAt: new Date(existing.createdAt.getTime()), + }; + this.#subs.set(row.endpoint, stored); + return Promise.resolve(copySub(stored)); + } + this.#subs.set(row.endpoint, copySub(row)); + return Promise.resolve(copySub(row)); + } + + /** + * Remove a matching account + endpoint subscription. + * + * @param accountId - Owning account. + * @param endpoint - Push endpoint. + * @returns Whether a row was removed. + */ + deleteSubscription(accountId: string, endpoint: string): Promise { + const existing = this.#subs.get(endpoint); + if (existing === undefined || existing.accountId !== accountId) { + return Promise.resolve(false); + } + this.#subs.delete(endpoint); + return Promise.resolve(true); + } + + /** + * List subscriptions for one account (copies). + * + * @param accountId - Account id. + * @returns Subscription copies. + */ + listByAccount(accountId: string): Promise { + const rows: PushSubscriptionRecord[] = []; + for (const row of this.#subs.values()) { + if (row.accountId === accountId) { + rows.push(copySub(row)); + } + } + return Promise.resolve(rows); + } + + /** + * Distinct account ids with at least one subscription. + * + * @returns Account ids. + */ + listAccountIdsWithSubscriptions(): Promise { + const ids = new Set(); + for (const row of this.#subs.values()) { + ids.add(row.accountId); + } + return Promise.resolve([...ids]); + } + + /** + * Append a copy of the outbox row. + * + * @param row - Outbox row. + */ + enqueue(row: PushOutboxRow): Promise { + this.#outbox.push(copyOutbox(row)); + return Promise.resolve(); + } + + /** + * Claim pending rows whose lease is null or expired (oldest first). + * + * @param limit - Max rows. + * @param nowMs - Clock. + * @param leaseMs - Lease duration. + * @returns Claimed copies. + */ + claimPending(limit: number, nowMs: number, leaseMs: number): Promise { + const until = new Date(nowMs + leaseMs); + const candidates = this.#outbox + .filter((row) => { + if (row.status !== 'pending') { + return false; + } + if (row.claimedUntil !== null && row.claimedUntil.getTime() > nowMs) { + return false; + } + return true; + }) + .sort((a, b) => { + const byTime = a.createdAt.getTime() - b.createdAt.getTime(); + if (byTime !== 0) { + return byTime; + } + return a.id.localeCompare(b.id); + }) + .slice(0, limit); + const claimed: PushOutboxRow[] = []; + for (const row of candidates) { + row.claimedUntil = until; + claimed.push(copyOutbox(row)); + } + return Promise.resolve(claimed); + } + + /** + * Mark an outbox row sent. + * + * @param id - Outbox id. + */ + markSent(id: string): Promise { + const row = this.#outbox.find((item) => item.id === id); + if (row !== undefined) { + row.status = 'sent'; + } + return Promise.resolve(); + } + + /** + * Increment attempts; fail at 8, else clear lease and stay pending. + * + * @param id - Outbox id. + */ + markFailed(id: string): Promise { + const row = this.#outbox.find((item) => item.id === id); + if (row === undefined) { + return Promise.resolve(); + } + row.attempts += 1; + if (row.attempts >= 8) { + row.status = 'failed'; + } else { + row.status = 'pending'; + row.claimedUntil = null; + } + return Promise.resolve(); + } +} + +/** Row shape selected from `push_subscription`. */ +interface PushSubSqlRow { + endpoint: string; + account_id: string; + p256dh: string; + auth: string; + created_at: Date | string; +} + +/** Row shape selected from `push_outbox`. */ +interface PushOutboxSqlRow { + id: string; + account_id: string; + type: string; + message_id: string | null; + payload: string; + status: string; + attempts: number; + claimed_until: Date | string | null; + created_at: Date | string; +} + +function mapSub(row: PushSubSqlRow): PushSubscriptionRecord { + return { + endpoint: row.endpoint, + accountId: row.account_id, + p256dh: row.p256dh, + auth: row.auth, + createdAt: row.created_at instanceof Date ? row.created_at : new Date(row.created_at), + }; +} + +function mapOutbox(row: PushOutboxSqlRow): PushOutboxRow { + const type = row.type === 'forum' || row.type === 'zap' ? row.type : 'forum'; + const status = + row.status === 'pending' || row.status === 'sent' || row.status === 'failed' + ? row.status + : 'pending'; + return { + id: row.id, + accountId: row.account_id, + type, + messageId: row.message_id, + payload: row.payload, + status, + attempts: row.attempts, + claimedUntil: + row.claimed_until === null || row.claimed_until === undefined + ? null + : row.claimed_until instanceof Date + ? row.claimed_until + : new Date(row.claimed_until), + createdAt: row.created_at instanceof Date ? row.created_at : new Date(row.created_at), + }; +} + +const OUTBOX_SELECT = + 'id, account_id, type, message_id, payload, status, attempts, claimed_until, created_at'; + +/** + * Durable {@link PushStore} backed by Postgres. + */ +export class PostgresPushStore implements PushStore { + readonly #sql: SqlClient; + + /** + * @param sql - Parameter-bound SQL client (already migrated). + */ + constructor(sql: SqlClient) { + this.#sql = sql; + } + + /** + * Insert or rebind by endpoint; do not overwrite `created_at` on conflict. + * + * @param row - Subscription to store. + * @returns The stored row from `RETURNING` (original `created_at` on conflict). + */ + async upsertSubscription(row: PushSubscriptionRecord): Promise { + const rows = await this.#sql.query<{ + endpoint: string; + account_id: string; + p256dh: string; + auth: string; + created_at: Date | string; + }>( + `INSERT INTO push_subscription (endpoint, account_id, p256dh, auth, created_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (endpoint) DO UPDATE SET + account_id = EXCLUDED.account_id, + p256dh = EXCLUDED.p256dh, + auth = EXCLUDED.auth + RETURNING endpoint, account_id, p256dh, auth, created_at`, + [row.endpoint, row.accountId, row.p256dh, row.auth, row.createdAt], + ); + const stored = rows[0]; + if (stored === undefined) { + throw new Error('push.subscription.upsert_empty'); + } + return { + endpoint: stored.endpoint, + accountId: stored.account_id, + p256dh: stored.p256dh, + auth: stored.auth, + createdAt: + stored.created_at instanceof Date ? stored.created_at : new Date(stored.created_at), + }; + } + + /** + * Delete by account + endpoint. + * + * @param accountId - Owning account. + * @param endpoint - Push endpoint. + * @returns Whether a row was removed. + */ + async deleteSubscription(accountId: string, endpoint: string): Promise { + const rows = await this.#sql.query<{ endpoint: string }>( + `DELETE FROM push_subscription WHERE account_id = $1 AND endpoint = $2 RETURNING endpoint`, + [accountId, endpoint], + ); + return rows.length > 0; + } + + /** + * List subscriptions for one account. + * + * @param accountId - Account id. + * @returns Mapped rows. + */ + async listByAccount(accountId: string): Promise { + const rows = await this.#sql.query( + `SELECT endpoint, account_id, p256dh, auth, created_at + FROM push_subscription WHERE account_id = $1`, + [accountId], + ); + return rows.map((row) => mapSub(row)); + } + + /** + * Distinct account ids with subscriptions. + * + * @returns Account ids. + */ + async listAccountIdsWithSubscriptions(): Promise { + const rows = await this.#sql.query<{ account_id: string }>( + `SELECT DISTINCT account_id FROM push_subscription`, + ); + return rows.map((row) => row.account_id); + } + + /** + * Insert an outbox row. + * + * @param row - Fully formed outbox row. + */ + async enqueue(row: PushOutboxRow): Promise { + await this.#sql.execute( + `INSERT INTO push_outbox + (id, account_id, type, message_id, payload, status, attempts, claimed_until, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + row.id, + row.accountId, + row.type, + row.messageId, + row.payload, + row.status, + row.attempts, + row.claimedUntil, + row.createdAt, + ], + ); + } + + /** + * Claim pending rows with `FOR UPDATE SKIP LOCKED`. + * + * @param limit - Max rows. + * @param nowMs - Clock. + * @param leaseMs - Lease duration. + * @returns Claimed rows. + */ + async claimPending(limit: number, nowMs: number, leaseMs: number): Promise { + const until = new Date(nowMs + leaseMs); + const rows = await this.#sql.query( + `UPDATE push_outbox SET claimed_until = $1 + WHERE id IN ( + SELECT id FROM push_outbox + WHERE status = 'pending' + AND (claimed_until IS NULL OR claimed_until < $2) + ORDER BY created_at ASC, id ASC + LIMIT $3 + FOR UPDATE SKIP LOCKED + ) + RETURNING ${OUTBOX_SELECT}`, + [until, new Date(nowMs), limit], + ); + return rows.map((row) => mapOutbox(row)); + } + + /** + * Mark an outbox row sent. + * + * @param id - Outbox id. + */ + async markSent(id: string): Promise { + await this.#sql.execute(`UPDATE push_outbox SET status = 'sent' WHERE id = $1`, [id]); + } + + /** + * Increment attempts; terminal failed at 8, else pending with cleared lease. + * + * @param id - Outbox id. + */ + async markFailed(id: string): Promise { + await this.#sql.execute( + `UPDATE push_outbox SET + attempts = attempts + 1, + status = CASE WHEN attempts + 1 >= 8 THEN 'failed' ELSE 'pending' END, + claimed_until = CASE WHEN attempts + 1 >= 8 THEN claimed_until ELSE NULL END + WHERE id = $1`, + [id], + ); + } +} diff --git a/src/lib/push-worker.ts b/src/lib/push-worker.ts new file mode 100644 index 00000000..7122e8e7 --- /dev/null +++ b/src/lib/push-worker.ts @@ -0,0 +1,203 @@ +/** + * Enqueue helpers and the Web Push outbox worker. + */ + +import { buildForumPushPayload, buildZapPushPayload, type PushPayload } from '@/lib/push'; +import type { PushSender } from '@/lib/push-sender'; +import type { PushOutboxRow, PushStore } from '@/lib/push-store'; + +/** Max outbox rows claimed per tick. */ +export const PUSH_WORKER_BATCH = 20; + +/** Lease duration while a worker owns a row (ms). */ +export const PUSH_WORKER_LEASE_MS = 60_000; + +/** Default `setInterval` period (ms). */ +export const PUSH_WORKER_INTERVAL_MS = 2_000; + +/** Debug ping payload (zap type, null message id). */ +function buildDebugPushPayload(): PushPayload { + return { + type: 'zap', + title: 'Test notification', + body: 'This is a test from 21.gifts.', + url: '/welcome', + tag: 'debug', + }; +} + +/** + * Enqueue one forum notification per subscriber except the author. + * + * @param store - Push store. + * @param authorId - Message author (never notified). + * @param messageId - Forum message id. + * @param nowMs - Enqueue clock. + */ +export async function enqueueForumPushes( + store: PushStore, + authorId: string, + messageId: string, + nowMs: number, +): Promise { + const accountIds = await store.listAccountIdsWithSubscriptions(); + const payload = JSON.stringify(buildForumPushPayload()); + const createdAt = new Date(nowMs); + for (const accountId of accountIds) { + if (accountId === authorId) { + continue; + } + const row: PushOutboxRow = { + id: crypto.randomUUID(), + accountId, + type: 'forum', + messageId, + payload, + status: 'pending', + attempts: 0, + claimedUntil: null, + createdAt, + }; + await store.enqueue(row); + } +} + +/** + * Enqueue one zap notification for the note author when they have a subscription. + * + * @param store - Push store. + * @param authorId - Note author to notify. + * @param messageId - Forum message id. + * @param nowMs - Enqueue clock. + */ +export async function enqueueZapPush( + store: PushStore, + authorId: string, + messageId: string, + nowMs: number, +): Promise { + const subs = await store.listByAccount(authorId); + if (subs.length === 0) { + return; + } + const row: PushOutboxRow = { + id: crypto.randomUUID(), + accountId: authorId, + type: 'zap', + messageId, + payload: JSON.stringify(buildZapPushPayload(messageId)), + status: 'pending', + attempts: 0, + claimedUntil: null, + createdAt: new Date(nowMs), + }; + await store.enqueue(row); +} + +/** + * Enqueue a debug test notification when the account has a subscription. + * + * @param store - Push store. + * @param accountId - Target account. + * @param nowMs - Enqueue clock. + * @returns Number of rows enqueued (`0` or `1`). + */ +export async function enqueueDebugPush( + store: PushStore, + accountId: string, + nowMs: number, +): Promise { + const subs = await store.listByAccount(accountId); + if (subs.length === 0) { + return 0; + } + const row: PushOutboxRow = { + id: crypto.randomUUID(), + accountId, + type: 'zap', + messageId: null, + payload: JSON.stringify(buildDebugPushPayload()), + status: 'pending', + attempts: 0, + claimedUntil: null, + createdAt: new Date(nowMs), + }; + await store.enqueue(row); + return 1; +} + +/** Collaborators for one push worker tick. */ +export interface PushWorkerDeps { + /** Shared push store (same instance as HTTP). */ + store: PushStore; + /** Delivery collaborator. */ + sender: PushSender; + /** Clock. */ + now: () => number; +} + +/** + * Claim a batch and deliver each row to every subscription for its account. + * + * @param deps - Store, sender, clock. + */ +export async function runPushWorkerTick(deps: PushWorkerDeps): Promise { + if (!deps.sender.isConfigured()) { + return; + } + const nowMs = deps.now(); + const rows = await deps.store.claimPending(PUSH_WORKER_BATCH, nowMs, PUSH_WORKER_LEASE_MS); + for (const row of rows) { + const subs = await deps.store.listByAccount(row.accountId); + if (subs.length === 0) { + await deps.store.markSent(row.id); + continue; + } + let anyFail = false; + for (const sub of subs) { + const result = await deps.sender.send(sub, row.payload); + if (result.ok) { + continue; + } + if (result.reason === 'gone') { + await deps.store.deleteSubscription(row.accountId, sub.endpoint); + continue; + } + anyFail = true; + } + if (anyFail) { + await deps.store.markFailed(row.id); + } else { + await deps.store.markSent(row.id); + } + } +} + +/** + * Start a periodic push worker. Returns a handle to stop the interval. + * + * @param deps - Store, sender, clock. + * @param intervalMs - Tick period (default {@link PUSH_WORKER_INTERVAL_MS}). + * @returns `{ stop }` to clear the interval. + */ +export function startPushWorker( + deps: PushWorkerDeps, + intervalMs: number = PUSH_WORKER_INTERVAL_MS, +): { stop: () => void } { + let inFlight = false; + /* v8 ignore next 8 -- interval callback */ + const timer = setInterval(() => { + if (inFlight) { + return; + } + inFlight = true; + void runPushWorkerTick(deps).finally(() => { + inFlight = false; + }); + }, intervalMs); + return { + stop: () => { + clearInterval(timer); + }, + }; +} diff --git a/src/lib/push.ts b/src/lib/push.ts new file mode 100644 index 00000000..58ea581e --- /dev/null +++ b/src/lib/push.ts @@ -0,0 +1,104 @@ +/** + * Web Push subscription parsing and small English notification payloads. + */ + +/** Parsed PushSubscription fields stored for an account. */ +export interface ParsedPushSubscription { + /** Push service endpoint URL. */ + endpoint: string; + /** Client public key (url-safe base64). */ + p256dh: string; + /** Auth secret (url-safe base64). */ + auth: string; +} + +/** Compact JSON payload delivered to browsers. */ +export interface PushPayload { + /** Discriminator (`forum` or `zap`). */ + type: 'forum' | 'zap'; + /** Notification title. */ + title: string; + /** Notification body. */ + body: string; + /** In-app path to open. */ + url: string; + /** Collapse / topic tag. */ + tag: string; +} + +/** Url-safe base64 charset with optional `=` padding. */ +const URL_SAFE_B64 = /^[A-Za-z0-9_-]+={0,2}$/; + +/** + * Validate a browser PushSubscription JSON body. + * + * @param input - Unknown request body. + * @returns Parsed fields, or `null` when invalid. + */ +export function parsePushSubscription(input: unknown): ParsedPushSubscription | null { + if (input === null || typeof input !== 'object') { + return null; + } + const record = input as Record; + const endpoint = record['endpoint']; + const keys = record['keys']; + if (typeof endpoint !== 'string' || endpoint.trim() === '') { + return null; + } + if (keys === null || typeof keys !== 'object') { + return null; + } + const keyRecord = keys as Record; + const p256dh = keyRecord['p256dh']; + const auth = keyRecord['auth']; + if (typeof p256dh !== 'string' || p256dh === '' || !URL_SAFE_B64.test(p256dh)) { + return null; + } + if (typeof auth !== 'string' || auth === '' || !URL_SAFE_B64.test(auth)) { + return null; + } + let url: URL; + try { + url = new URL(endpoint); + } catch { + return null; + } + if (url.protocol === 'https:') { + return { endpoint, p256dh, auth }; + } + if (url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1')) { + return { endpoint, p256dh, auth }; + } + return null; +} + +/** + * Forum notification payload (shared English copy). + * + * @returns Payload object; callers `JSON.stringify`. + */ +export function buildForumPushPayload(): PushPayload { + return { + type: 'forum', + title: 'New message on 21.gifts', + body: 'Someone posted in the living room.', + url: '/welcome', + tag: 'forum', + }; +} + +/** + * Zap notification payload for a note author. + * + * @param messageId - Forum message id (used in `tag`). + * @returns Payload object; callers `JSON.stringify`. + */ +export function buildZapPushPayload(messageId: string): PushPayload { + return { + type: 'zap', + title: 'Bitcoin on your post', + body: 'Someone sent you sats.', + url: '/welcome', + tag: `zap:${messageId}`, + }; +} diff --git a/src/routes/debug-push.ts b/src/routes/debug-push.ts new file mode 100644 index 00000000..2161a56d --- /dev/null +++ b/src/routes/debug-push.ts @@ -0,0 +1,69 @@ +/** + * Operator debug ping that enqueues a test Web Push for one account. + * Authenticated by `DEBUG_TOKEN` (Bearer), not by an end-user session. + */ + +import { Hono } from 'hono'; +import type { MiddlewareHandler } from 'hono'; +import type { AuthStore } from '@/lib/auth/store'; +import { bearerMatchesDebugToken } from '@/lib/debug-token'; +import type { PushStore } from '@/lib/push-store'; +import { enqueueDebugPush } from '@/lib/push-worker'; + +/** Collaborators the debug push-ping route needs. */ +export interface DebugPushRouteDeps { + /** Shared auth persistence port. */ + authStore: AuthStore; + /** Push subscription / outbox store. */ + pushStore: PushStore; + /** Clock returning epoch milliseconds. */ + now: () => number; + /** Configured operator token, or `undefined` when debug is disabled. */ + debugToken: string | undefined; + /** + * Public VAPID key when configured; missing/blank → 503 + * `{ error: "Push is not configured" }` after the debug gate. + */ + vapidPublicKey: string | undefined; +} + +/** Shared 503/401 gate for every `/debug/push-ping` method (before JSON). */ +function requireDebugToken(deps: DebugPushRouteDeps): MiddlewareHandler { + return async (c, next) => { + const token = deps.debugToken; + if (token === undefined || token.trim() === '') { + return c.json({ error: 'Debug is not configured' }, 503); + } + if (!bearerMatchesDebugToken(token, c.req.header('authorization'))) { + return c.json({ error: 'Unauthorized' }, 401); + } + await next(); + }; +} + +/** + * Build the `/debug/push-ping` route group. + * + * Mounted at `/debug/push-ping` so the public path is `POST /debug/push-ping`. + * + * @param deps - Auth store, push store, clock, debug token, VAPID public key. + * @returns A Hono app exposing `POST /`. + */ +export function debugPushRoutes(deps: DebugPushRouteDeps): Hono { + return new Hono().use('*', requireDebugToken(deps)).post('/', async (c) => { + if (deps.vapidPublicKey === undefined || deps.vapidPublicKey.trim() === '') { + return c.json({ error: 'Push is not configured' }, 503); + } + const body = (await c.req.json().catch(() => null)) as { accountId?: unknown } | null; + const accountId = body?.accountId; + if (typeof accountId !== 'string' || accountId.trim() === '') { + return c.json({ error: 'Expected a JSON body with an "accountId" string' }, 400); + } + const account = await deps.authStore.getAccount(accountId); + if (account === undefined) { + return c.json({ error: 'Not found' }, 404); + } + const enqueued = await enqueueDebugPush(deps.pushStore, accountId, deps.now()); + return c.json({ enqueued }, 200); + }); +} diff --git a/src/routes/messages.ts b/src/routes/messages.ts index 0f844023..54a1d244 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -26,6 +26,8 @@ import { InvoiceRateLimiter, PostRateLimiter } from '@/lib/nostr/rate-limit'; import { resolveZapRelays } from '@/lib/nostr/relays'; import { signEventForAccount } from '@/lib/nostr/sign'; 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'; /** Placeholder author id when the message/author is unknown at persist time. */ @@ -110,6 +112,8 @@ export interface MessagesRouteDeps { postLimiter?: PostRateLimiter; /** Invoice limiter (tests inject). */ invoiceLimiter?: InvoiceRateLimiter; + /** Optional push outbox; forum create enqueues when present. */ + pushStore?: PushStore; } const defaultPostLimiter = new PostRateLimiter(); @@ -191,7 +195,7 @@ const invoiceBody = z.object({ sats: z.number().int().positive() }); * `POST /messages`, `GET /messages/:id/photo` (and `.jpg` / `.jpeg` / `.png` / * `.webp`), and `POST /messages/:id/invoice`. * - * @param deps - Message store, auth store, and clock. + * @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`. */ @@ -267,6 +271,13 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { try { const created = photo === undefined ? await deps.store.create(row) : await deps.store.create(row, photo); + 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'); diff --git a/src/routes/push.ts b/src/routes/push.ts new file mode 100644 index 00000000..b95cc5a6 --- /dev/null +++ b/src/routes/push.ts @@ -0,0 +1,106 @@ +/** + * Member Web Push routes: VAPID public key and subscription CRUD. + * + * Mounted at `/` so discovery sees the full public paths + * `/push/vapid-public` and `/me/push-subscriptions`. + */ + +import { Hono } from 'hono'; +import { resolveSession } from '@/lib/auth/service'; +import type { Account, AuthStore } from '@/lib/auth/store'; +import { parsePushSubscription } from '@/lib/push'; +import type { PushStore } from '@/lib/push-store'; +import { bearerToken } from '@/routes/me'; + +/** Collaborators the push routes need. */ +export interface PushRouteDeps { + /** Shared auth persistence port. */ + authStore: AuthStore; + /** Push subscription / outbox store. */ + pushStore: PushStore; + /** Clock returning epoch milliseconds. */ + now: () => number; + /** + * Public VAPID key when configured; missing/blank → 503 on push HTTP + * (session check still runs first). + */ + vapidPublicKey: string | undefined; +} + +/** Resolve the account behind a request's bearer session, or `null`. */ +async function authedAccount( + deps: PushRouteDeps, + header: string | undefined, +): Promise { + const token = bearerToken(header); + if (token === null) { + return null; + } + return resolveSession(deps.authStore, deps.now(), token); +} + +/** Whether push HTTP may proceed past the configured gate. */ +function pushConfigured(deps: PushRouteDeps): boolean { + return deps.vapidPublicKey !== undefined && deps.vapidPublicKey.trim() !== ''; +} + +/** + * Build the Web Push route group (full public path strings for handbook discovery). + * + * @param deps - Auth store, push store, clock, optional VAPID public key. + * @returns A Hono app with GET/POST/DELETE on the public paths. + */ +export function pushRoutes(deps: PushRouteDeps): Hono { + return new Hono() + .get('/push/vapid-public', async (c) => { + const account = await authedAccount(deps, c.req.header('authorization')); + if (account === null) { + return c.json({ error: 'Unauthorized' }, 401); + } + if (!pushConfigured(deps)) { + return c.json({ error: 'Push is not configured' }, 503); + } + return c.json({ publicKey: deps.vapidPublicKey }, 200); + }) + .post('/me/push-subscriptions', async (c) => { + const account = await authedAccount(deps, c.req.header('authorization')); + if (account === null) { + return c.json({ error: 'Unauthorized' }, 401); + } + if (!pushConfigured(deps)) { + return c.json({ error: 'Push is not configured' }, 503); + } + const parsed = parsePushSubscription(await c.req.json().catch(() => null)); + if (parsed === null) { + return c.json({ error: 'Invalid subscription' }, 400); + } + const createdAt = new Date(deps.now()); + const stored = await deps.pushStore.upsertSubscription({ + endpoint: parsed.endpoint, + accountId: account.id, + p256dh: parsed.p256dh, + auth: parsed.auth, + createdAt, + }); + return c.json({ endpoint: stored.endpoint, createdAt: stored.createdAt.toISOString() }, 200); + }) + .delete('/me/push-subscriptions', async (c) => { + const account = await authedAccount(deps, c.req.header('authorization')); + if (account === null) { + return c.json({ error: 'Unauthorized' }, 401); + } + if (!pushConfigured(deps)) { + return c.json({ error: 'Push is not configured' }, 503); + } + const body = (await c.req.json().catch(() => null)) as { endpoint?: unknown } | null; + const endpoint = body?.endpoint; + if (typeof endpoint !== 'string' || endpoint.trim() === '') { + return c.json({ error: 'Invalid subscription' }, 400); + } + const removed = await deps.pushStore.deleteSubscription(account.id, endpoint); + if (!removed) { + return c.json({ error: 'Not found' }, 404); + } + return c.json({ ok: true }, 200); + }); +} diff --git a/src/server.ts b/src/server.ts index 1a696dcb..ee63513d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -18,6 +18,8 @@ import { messagesRoutes } from '@/routes/messages'; import { contactRoutes } from '@/routes/contact'; import { debugContactsRoutes } from '@/routes/debug-contacts'; import { debugPaymentsRoutes } from '@/routes/debug-payments'; +import { pushRoutes } from '@/routes/push'; +import { debugPushRoutes } from '@/routes/debug-push'; import { InMemoryAuthStore } from '@/lib/auth/store'; import type { AuthStore } from '@/lib/auth/store'; import { InMemoryBtcUsdStore, type BtcUsdRateBook } from '@/lib/btc-usd-store'; @@ -27,6 +29,8 @@ import { InMemoryContactStore } from '@/lib/contact-store'; import type { ContactStore } from '@/lib/contact-store'; import { InMemoryMessageStore } from '@/lib/message-store'; import type { MessageStore } from '@/lib/message-store'; +import { resolveVapidConfig } from '@/lib/push-config'; +import { InMemoryPushStore, type PushStore } from '@/lib/push-store'; import { resolveAllowedOrigins } from '@/lib/config'; import { UnconfiguredInvoicePayer } from '@/lib/invoice-payer'; import type { InvoicePayer } from '@/lib/invoice-payer'; @@ -118,6 +122,17 @@ export interface AppDeps { * {@link PostgresContactStore} when `DATABASE_URL` is set. */ contactStore?: ContactStore; + /** + * Web Push subscriptions and outbox (default: empty + * {@link InMemoryPushStore}). Boot injects + * {@link PostgresPushStore} when `DATABASE_URL` is set. + */ + pushStore?: PushStore; + /** + * Public VAPID key when both keys resolve (default: + * `resolveVapidConfig(process.env)?.publicKey`). Missing → push HTTP 503. + */ + vapidPublicKey?: string; } /** @@ -127,12 +142,13 @@ export interface AppDeps { * via Hono's `app.request()` helper without binding to a TCP port. Every * wire-up change — middleware, routes, error handlers — flows through this * single factory so the test surface matches production exactly. Mounts - * public `GET /view/:viewKey` alongside `/me` and the rest of the surface. + * public `GET /view/:viewKey` alongside `/me`, Web Push subscription routes, + * and the rest of the surface. * * @param deps - Optional overrides for the auth store, clock, invoice payer, * LNURL-pay fetch, LN-Address cache, brand reader, debugToken, gift store, - * gift recorder, BTC-USD rates, message store, contact store, nostrKek, WebAuthn RP, spend - * token, and gift invoice store. + * gift recorder, BTC-USD rates, message store, contact store, push store, + * vapidPublicKey, nostrKek, WebAuthn RP, spend token, and gift invoice store. * @returns A Hono app with all routes and middleware attached. */ export function createApp(deps: AppDeps = {}): Hono { @@ -149,6 +165,8 @@ export function createApp(deps: AppDeps = {}): Hono { const messageStore = deps.messageStore ?? new InMemoryMessageStore(); const nostrKek = deps.nostrKek; const contactStore = deps.contactStore ?? new InMemoryContactStore(); + const pushStore = deps.pushStore ?? new InMemoryPushStore(); + const vapidPublicKey = deps.vapidPublicKey ?? resolveVapidConfig(process.env)?.publicKey; const webAuthnRpId = deps.webAuthnRpId ?? process.env['WEBAUTHN_RP_ID']; const webAuthnRpName = deps.webAuthnRpName ?? process.env['WEBAUTHN_RP_NAME']; const passkeyCeremony = deps.passkeyCeremony ?? new SimpleWebAuthnPasskeyCeremony(); @@ -173,6 +191,7 @@ export function createApp(deps: AppDeps = {}): Hono { ); app.route('/', brandRoutes({ read: readBrand })); + app.route('/', pushRoutes({ authStore: store, pushStore, now, vapidPublicKey })); app.route('/healthz', healthRoute); app.route('/info', infoRoute); app.route( @@ -196,6 +215,16 @@ export function createApp(deps: AppDeps = {}): Hono { app.route('/debug/accounts', debugRoutes({ store, debugToken })); app.route('/debug/contacts', debugContactsRoutes({ store: contactStore, debugToken })); app.route('/debug', debugPaymentsRoutes({ store: messageStore, debugToken })); + app.route( + '/debug/push-ping', + debugPushRoutes({ + authStore: store, + pushStore, + now, + debugToken, + vapidPublicKey, + }), + ); app.route('/gifts', giftsRoutes({ store: giftStore, rates: btcUsdRates, now })); app.route('/gifts/stats', giftsStatsRoutes({ store: giftStore, rates: btcUsdRates, now })); app.route( @@ -205,6 +234,7 @@ export function createApp(deps: AppDeps = {}): Hono { authStore: store, now, fetchImpl, + pushStore, ...(nostrKek === undefined ? {} : { nostrKek }), }), ); From 1526a441fe115080477359724538e40c1079a151 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Sun, 30 Aug 2026 16:56:22 +0200 Subject: [PATCH 09/12] 01a051eb - Reject non-NIP-57 forum invoices before showing a QR (#76) * Reject non-NIP-57 forum invoices before returning pr POST /messages/:id/invoice now returns { pr, amountSats } only when the minted BOLT11 description_hash matches the zap request. Plaintext LN invoices are persisted as not_zap for debug and rejected with 400 so the client never shows a QR that cannot produce a kind:9735 receipt. * Document that isNip57Invoice is only used for the invoice gate The ok persist path stores a literal true after the gate, so the handbook Used-by line must not claim a second call. * Cover not_zap when the minted BOLT11 cannot be inspected The 100% branch gate failed on inspected?.paymentHash when inspectBolt11 returns null (malformed LNURL invoice). Persist still stores the pr. --------- Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- SPEC.md | 17 +- docs/handbook/endpoints.md | 8 +- docs/handbook/functions.md | 8 +- src/__tests__/routes/messages.test.ts | 274 ++++++++++++++++++++++---- src/lib/message-store.ts | 1 + src/routes/messages.ts | 24 ++- 6 files changed, 275 insertions(+), 57 deletions(-) diff --git a/SPEC.md b/SPEC.md index 011abbe3..c3fd044d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -807,11 +807,13 @@ Success → **Response** `200`: ``` Rows are newest-first, capped at **200**. Never includes nsec. `result` is one -of `ok`, `noZap`, `unreachable`, `no_event`, `no_author`, `no_key`, +of `ok`, `noZap`, `not_zap`, `unreachable`, `no_event`, `no_author`, `no_key`, `sign_failed`, `rate_limited`, `bad_body`, `not_found`. `isNip57Invoice` is true only when `descriptionHash` equals SHA-256 of the zap-request JSON string sent as LNURL `nostr=`. Failure rows have `pr` null and `isNip57Invoice` -false. When `DATABASE_URL` is unset the in-memory store starts empty. +false, except `not_zap` which stores the rejected BOLT11 (`pr` set, +`isNip57Invoice` false). When `DATABASE_URL` is unset the in-memory store +starts empty. Environment: @@ -1335,8 +1337,13 @@ Success → **Response** `200`: Signed-in pay-on-note. Bearer session required. `:id` is a UUID (`MESSAGE_ID_RE`). Body `{ "sats": }`. The api signs a NIP-57 zap request with the -**payer** key and returns a BOLT11 invoice for the **author** Lightning Address. -It does **not** increment `sats` (that happens when a validated kind:9735 +**payer** key and returns a BOLT11 invoice for the **author** Lightning Address +**only** when the minted invoice's `description_hash` equals SHA-256 of the +zap-request JSON (`isNip57Invoice`). LNURL success with a non-NIP-57 invoice +(plaintext description, missing/mismatched `description_hash`, or malformed +BOLT11) → persist `not_zap` (with rejected `pr` for debug) and **400** +`{ "error": "Could not start the Bitcoin payment" }` with **no** `pr` in the +body. It does **not** increment `sats` (that happens when a validated kind:9735 receipt is indexed). After auth, every attempt with a valid UUID is persisted best-effort to `message_invoice` (result, HTTP status, `pr`, description vs `description_hash`, `isNip57Invoice`). Store failures log @@ -1357,7 +1364,7 @@ Unknown id → **404** `{ "error": "Not found" }`. Unsigned note, author without Over-limit → **429** `{ "error": "Too many payments" }` (`Retry-After: 10`) — checked only after auth, amount, payable, and KEK checks succeed, so early 400/404/401/503 do not consume quota. LNURL/zap or sign failure after the -limiter still counts. LNURL/zap failure → +limiter still counts. LNURL/zap failure (including non-NIP-57 invoice) → **400** `{ "error": "Could not start the Bitcoin payment" }`. Keygen/sign failure → **503** `{ "error": "Messages are unavailable" }`. diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 069c7b46..a1acc467 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -44,9 +44,9 @@ ## Endpoint: GET /debug/invoices -- **Purpose:** Operator listing of forum `POST /messages/:id/invoice` attempts newest-first (cap 200): result, HTTP status, BOLT11 `pr`, payment hash, description / description_hash, and `isNip57Invoice`. ISO `createdAt`. Never includes nsec. +- **Purpose:** Operator listing of forum `POST /messages/:id/invoice` attempts newest-first (cap 200): result, HTTP status, BOLT11 `pr`, payment hash, description / description_hash, and `isNip57Invoice`. ISO `createdAt`. Never includes nsec. Rejected non-NIP-57 attempts (`not_zap`) still list the rejected `pr` for debug. - **Errors:** 503 `{ error: 'Debug is not configured' }` when `DEBUG_TOKEN` is unset or blank; 401 `{ error: 'Unauthorized' }` when the Bearer token does not match; 503 `{ error: 'Messages are unavailable' }` when listing throws (`debug.invoices.list_failed`). -- **Used by:** Operators debugging zap invoice issuance (including non-NIP-57 fallbacks). +- **Used by:** Operators debugging zap invoice issuance (including rejected non-NIP-57 `not_zap` rows with `pr`). - **Auth:** `Authorization: Bearer` with `DEBUG_TOKEN`. Not an end-user session. ## Endpoint: GET /debug/zap-ingests @@ -240,8 +240,8 @@ ## Endpoint: POST /messages/:id/invoice -- **Purpose:** Bearer required. `:id` is a UUID. Body `{ sats }` (integer 1..10_000_000). Builds a NIP-57 kind:9734 zap request for the note, signs it with the payer's custodial key (ensuring one exists when KEK is present), and returns a BOLT11 `{ pr, amountSats }` via the author's LNURL-pay. After auth, valid-UUID attempts are persisted best-effort (`message_invoice`); persist failures do not change the HTTP response. The invoice rate limit is applied only after auth, amount, payable, and KEK checks. -- **Errors:** 401 Unauthorized; 400 bad body / note not yet payable; 404 Not found (unknown id or non-UUID `:id`, the latter without a persist row); 429 Too many payments (`Retry-After: 10`, after payable checks); 503 Messages are unavailable (missing KEK before limiter, or keygen/sign failure after). +- **Purpose:** Bearer required. `:id` is a UUID. Body `{ sats }` (integer 1..10_000_000). Builds a NIP-57 kind:9734 zap request for the note, signs it with the payer's custodial key (ensuring one exists when KEK is present), and returns `{ pr, amountSats }` only when the minted BOLT11 is a NIP-57 `description_hash` invoice (`isNip57Invoice`); otherwise persists `not_zap` (with rejected `pr` for debug) and responds 400 without `pr` in the body. After auth, valid-UUID attempts are persisted best-effort (`message_invoice`); persist failures do not change the HTTP response. The invoice rate limit is applied only after auth, amount, payable, and KEK checks (NIP-57 reject still counts, same as other LNURL failures). +- **Errors:** 401 Unauthorized; 400 bad body / note not yet payable / Could not start the Bitcoin payment (LNURL failure or non-NIP-57 invoice); 404 Not found (unknown id or non-UUID `:id`, the latter without a persist row); 429 Too many payments (`Retry-After: 10`, after payable checks); 503 Messages are unavailable (missing KEK before limiter, or keygen/sign failure after). - **Used by:** App pay sheet for forum notes. - **Auth:** `Authorization: Bearer` session. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 059837e9..4f607fc1 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -250,14 +250,14 @@ - **Purpose:** Decode BOLT11 payment hash, amount, plaintext description, description_hash, and expiry for operator debug (does not change `decodeBolt11`). - **Inputs:** BOLT11 string; optional decoder inject for tests. - **Returns / side effects:** `InspectedBolt11` or `null` when malformed / zero-amount. -- **Used by:** `POST /messages/:id/invoice` when persisting an ok attempt. +- **Used by:** `POST /messages/:id/invoice` for the NIP-57 gate (reject before returning `pr`) and when persisting ok / `not_zap` attempts. ## Function: isNip57Invoice - **Purpose:** True when `descriptionHash` equals `sha256(utf8(zapRequestJson))`. - **Inputs:** description hash (or null) and zap request JSON string (or null). - **Returns / side effects:** boolean. -- **Used by:** `POST /messages/:id/invoice` when persisting an ok attempt. +- **Used by:** `POST /messages/:id/invoice` for the NIP-57 gate (reject before returning `pr`). ## Function: resolveVapidConfig @@ -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` issues a NIP-57 zap BOLT11 (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`, `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 + persist `not_zap`; 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; 404 photo missing; 429 on post or invoice rate limits (invoice only after payable checks); 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 / unpaid note / non-NIP-57 invoice; 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). - **Used by:** `createApp`. ## Function: contactRoutes diff --git a/src/__tests__/routes/messages.test.ts b/src/__tests__/routes/messages.test.ts index b7e6d10a..86898223 100644 --- a/src/__tests__/routes/messages.test.ts +++ b/src/__tests__/routes/messages.test.ts @@ -14,6 +14,17 @@ function parsedEvents(warn: ReturnType): Array JSON.parse(arg) as Record); } +/** Fake BOLT11s are not NIP-57; spy `isNip57Invoice` true for HTTP 200 invoice paths. */ +async function withNip57True(run: () => Promise): Promise { + const bolt11 = await import('@/lib/bolt11'); + const nip57Spy = vi.spyOn(bolt11, 'isNip57Invoice').mockReturnValue(true); + try { + return await run(); + } finally { + nip57Spy.mockRestore(); + } +} + let warn: ReturnType; beforeEach(() => { @@ -700,7 +711,9 @@ describe('POST /messages/:id/invoice', () => { body: JSON.stringify({ sats: 21 }), }) ).status; - expect(await hit()).toBe(200); + await withNip57True(async () => { + expect(await hit()).toBe(200); + }); expect(await hit()).toBe(429); }); @@ -805,7 +818,9 @@ describe('POST /messages/:id/invoice', () => { ).status; expect(await hit('66666666-6666-4666-8666-666666666666')).toBe(400); expect(await hit('66666666-6666-4666-8666-666666666666')).toBe(400); - expect(await hit('77777777-7777-4777-8777-777777777777')).toBe(200); + await withNip57True(async () => { + expect(await hit('77777777-7777-4777-8777-777777777777')).toBe(200); + }); }); it('returns 400 for a non-integer sats body', async () => { @@ -963,20 +978,22 @@ describe('POST /messages/:id/invoice', () => { invoiceLimiter: new InvoiceRateLimiter(), }), ); - const res = await app.request('/messages/11111111-1111-4111-8111-111111111111/invoice', { - method: 'POST', - headers: { ...AUTH, 'content-type': 'application/json' }, - body: JSON.stringify({ sats: 21 }), + await withNip57True(async () => { + const res = await app.request('/messages/11111111-1111-4111-8111-111111111111/invoice', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ sats: 21 }), + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ pr: 'lnbc21n1test', amountSats: 21 }); + expect(callbackUrl).toBeDefined(); + const nostrParam = new URL(callbackUrl ?? '').searchParams.get('nostr'); + expect(nostrParam).toBeTruthy(); + const zapRequest = JSON.parse(nostrParam ?? '') as { tags: string[][] }; + const relaysTag = zapRequest.tags.find((tag) => tag[0] === 'relays'); + expect(relaysTag).toBeDefined(); + expect(relaysTag?.slice(1)).toContain('wss://relay.damus.io'); }); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ pr: 'lnbc21n1test', amountSats: 21 }); - expect(callbackUrl).toBeDefined(); - const nostrParam = new URL(callbackUrl ?? '').searchParams.get('nostr'); - expect(nostrParam).toBeTruthy(); - const zapRequest = JSON.parse(nostrParam ?? '') as { tags: string[][] }; - const relaysTag = zapRequest.tags.find((tag) => tag[0] === 'relays'); - expect(relaysTag).toBeDefined(); - expect(relaysTag?.slice(1)).toContain('wss://relay.damus.io'); } finally { if (prevPublishPublic === undefined) { delete process.env['NOSTR_PUBLISH_PUBLIC']; @@ -1056,17 +1073,19 @@ describe('POST /messages/:id/invoice', () => { invoiceLimiter: new InvoiceRateLimiter(), }), ); - const res = await app.request('/messages/44444444-4444-4444-8444-444444444444/invoice', { - method: 'POST', - headers: { - authorization: 'Bearer payer-tok', - 'content-type': 'application/json', - }, - body: JSON.stringify({ sats: 21 }), + await withNip57True(async () => { + const res = await app.request('/messages/44444444-4444-4444-8444-444444444444/invoice', { + method: 'POST', + headers: { + authorization: 'Bearer payer-tok', + 'content-type': 'application/json', + }, + body: JSON.stringify({ sats: 21 }), + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ pr: 'lnbc21n1test', amountSats: 21 }); + expect(await authStore.getNostrPublicKey('payer')).toMatch(/^[0-9a-f]{64}$/); }); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ pr: 'lnbc21n1test', amountSats: 21 }); - expect(await authStore.getNostrPublicKey('payer')).toMatch(/^[0-9a-f]{64}$/); }); it('returns 400 when the note is unsigned', async () => { @@ -1207,6 +1226,7 @@ describe('POST /messages/:id/invoice', () => { descriptionHash: 'bb'.repeat(32), expirySeconds: 86400, }); + const nip57Spy = vi.spyOn(bolt11, 'isNip57Invoice').mockReturnValue(true); try { const app = new Hono().route( '/messages', @@ -1229,16 +1249,180 @@ describe('POST /messages/:id/invoice', () => { const attempts = await messageStore.listInvoiceAttempts(10); expect(attempts).toHaveLength(1); expect(attempts[0]?.result).toBe('ok'); - expect(attempts[0]?.pr).toBe('lnbc21n1test'); + expect(attempts[0]?.isNip57Invoice).toBe(true); expect(attempts[0]?.httpStatus).toBe(200); + expect(attempts[0]?.pr).toBe('lnbc21n1test'); expect(attempts[0]?.paymentHash).toBe('aa'.repeat(32)); expect(attempts[0]?.descriptionHash).toBe('bb'.repeat(32)); expect(attempts[0]?.zapRequest).not.toBeNull(); } finally { inspectSpy.mockRestore(); + nip57Spy.mockRestore(); } }); + it('persists not_zap when LNURL returns a non-NIP-57 invoice', async () => { + const { parseNostrKek } = await import('@/lib/nostr/kek'); + const { ensureAccountNostrKey } = await import('@/lib/nostr/keys'); + const kek = parseNostrKek('11'.repeat(32)); + const authStore = await namedStore('Ada'); + const account = await authStore.getAccount('acc'); + expect(account).toBeDefined(); + if (account === undefined) { + throw new Error('expected account'); + } + await authStore.updateAccount({ + ...account, + lightningAddress: 'ada@walletofsatoshi.com', + }); + await ensureAccountNostrKey(authStore, 'acc', kek); + const messageStore = new InMemoryMessageStore(); + await messageStore.create({ + id: '99999999-9999-4999-8999-999999999999', + accountId: 'acc', + name: 'Ada', + text: 'hi', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + eventId: 'ee'.repeat(32), + }); + const fetchImpl = async (input: string | URL | Request): Promise => { + const url = String(input); + if (url.includes('/.well-known/lnurlp/')) { + return new Response( + JSON.stringify({ + callback: 'https://walletofsatoshi.com/lnurlp/callback', + minSendable: 1000, + maxSendable: 10_000_000_000, + allowsNostr: true, + nostrPubkey: 'aa'.repeat(32), + }), + { headers: { 'content-type': 'application/json' } }, + ); + } + return new Response(JSON.stringify({ pr: 'lnbc21n1test' }), { + headers: { 'content-type': 'application/json' }, + }); + }; + const bolt11 = await import('@/lib/bolt11'); + const inspectSpy = vi.spyOn(bolt11, 'inspectBolt11').mockReturnValue({ + paymentHash: 'aa'.repeat(32), + amountMsat: 21_000, + description: 'Wallet of Satoshi', + descriptionHash: null, + expirySeconds: 86400, + }); + try { + const app = new Hono().route( + '/messages', + messagesRoutes({ + store: messageStore, + authStore, + now, + nostrKek: kek, + fetchImpl, + postLimiter: new PostRateLimiter(), + invoiceLimiter: new InvoiceRateLimiter(), + }), + ); + const res = await app.request('/messages/99999999-9999-4999-8999-999999999999/invoice', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ sats: 21 }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as Record; + expect(body).toEqual({ error: 'Could not start the Bitcoin payment' }); + expect(body).not.toHaveProperty('pr'); + const attempts = await messageStore.listInvoiceAttempts(10); + expect(attempts).toHaveLength(1); + expect(attempts[0]?.result).toBe('not_zap'); + expect(attempts[0]?.httpStatus).toBe(400); + expect(attempts[0]?.pr).toBe('lnbc21n1test'); + expect(attempts[0]?.isNip57Invoice).toBe(false); + expect(attempts[0]?.description).toBe('Wallet of Satoshi'); + expect(attempts[0]?.descriptionHash).toBeNull(); + expect(attempts[0]?.paymentHash).toBe('aa'.repeat(32)); + expect(attempts[0]?.zapRequest).not.toBeNull(); + } finally { + inspectSpy.mockRestore(); + } + }); + + it('persists not_zap when inspectBolt11 cannot decode the invoice', async () => { + const { parseNostrKek } = await import('@/lib/nostr/kek'); + const { ensureAccountNostrKey } = await import('@/lib/nostr/keys'); + const kek = parseNostrKek('11'.repeat(32)); + const authStore = await namedStore('Ada'); + const account = await authStore.getAccount('acc'); + expect(account).toBeDefined(); + if (account === undefined) { + throw new Error('expected account'); + } + await authStore.updateAccount({ + ...account, + lightningAddress: 'ada@walletofsatoshi.com', + }); + await ensureAccountNostrKey(authStore, 'acc', kek); + const messageStore = new InMemoryMessageStore(); + await messageStore.create({ + id: '12121212-1212-4121-8121-121212121212', + accountId: 'acc', + name: 'Ada', + text: 'hi', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + eventId: 'ee'.repeat(32), + }); + const fetchImpl = async (input: string | URL | Request): Promise => { + const url = String(input); + if (url.includes('/.well-known/lnurlp/')) { + return new Response( + JSON.stringify({ + callback: 'https://walletofsatoshi.com/lnurlp/callback', + minSendable: 1000, + maxSendable: 10_000_000_000, + allowsNostr: true, + nostrPubkey: 'aa'.repeat(32), + }), + { headers: { 'content-type': 'application/json' } }, + ); + } + return new Response(JSON.stringify({ pr: 'lnbc21n1test' }), { + headers: { 'content-type': 'application/json' }, + }); + }; + const app = new Hono().route( + '/messages', + messagesRoutes({ + store: messageStore, + authStore, + now, + nostrKek: kek, + fetchImpl, + postLimiter: new PostRateLimiter(), + invoiceLimiter: new InvoiceRateLimiter(), + }), + ); + const res = await app.request('/messages/12121212-1212-4121-8121-121212121212/invoice', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ sats: 21 }), + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Could not start the Bitcoin payment' }); + const attempts = await messageStore.listInvoiceAttempts(10); + expect(attempts).toHaveLength(1); + expect(attempts[0]?.result).toBe('not_zap'); + expect(attempts[0]?.pr).toBe('lnbc21n1test'); + expect(attempts[0]?.paymentHash).toBeNull(); + expect(attempts[0]?.description).toBeNull(); + expect(attempts[0]?.descriptionHash).toBeNull(); + expect(attempts[0]?.isNip57Invoice).toBe(false); + }); + it('persists noZap and unreachable with pr null and http 400', async () => { const { parseNostrKek } = await import('@/lib/nostr/kek'); const { ensureAccountNostrKey } = await import('@/lib/nostr/keys'); @@ -1471,16 +1655,18 @@ describe('POST /messages/:id/invoice', () => { invoiceLimiter: new InvoiceRateLimiter(), }), ); - const res = await app.request('/messages/dddddddd-dddd-4ddd-8ddd-dddddddddddd/invoice', { - method: 'POST', - headers: { ...AUTH, 'content-type': 'application/json' }, - body: JSON.stringify({ sats: 21 }), + await withNip57True(async () => { + const res = await app.request('/messages/dddddddd-dddd-4ddd-8ddd-dddddddddddd/invoice', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ sats: 21 }), + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ pr: 'lnbc21n1test', amountSats: 21 }); + expect(parsedEvents(warn).some((e) => e['event'] === 'message.invoice.record_failed')).toBe( + true, + ); }); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ pr: 'lnbc21n1test', amountSats: 21 }); - expect(parsedEvents(warn).some((e) => e['event'] === 'message.invoice.record_failed')).toBe( - true, - ); }); it('persists sign_failed when signing throws', async () => { @@ -1598,15 +1784,17 @@ describe('POST /messages/:id/invoice', () => { invoiceLimiter: new InvoiceRateLimiter(), }), ); - const res = await app.request('/messages/ffffffff-ffff-4fff-8fff-ffffffffffff/invoice', { - method: 'POST', - headers: { ...AUTH, 'content-type': 'application/json' }, - body: JSON.stringify({ sats: 21 }), + await withNip57True(async () => { + const res = await app.request('/messages/ffffffff-ffff-4fff-8fff-ffffffffffff/invoice', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ sats: 21 }), + }); + expect(res.status).toBe(200); + const attempts = await messageStore.listInvoiceAttempts(10); + expect(attempts[0]?.result).toBe('ok'); + expect(attempts[0]?.zapRequest).toBeNull(); }); - expect(res.status).toBe(200); - const attempts = await messageStore.listInvoiceAttempts(10); - expect(attempts[0]?.result).toBe('ok'); - expect(attempts[0]?.zapRequest).toBeNull(); } finally { spy.mockRestore(); } diff --git a/src/lib/message-store.ts b/src/lib/message-store.ts index 3c447b30..32442153 100644 --- a/src/lib/message-store.ts +++ b/src/lib/message-store.ts @@ -180,6 +180,7 @@ export interface MessageStore { export type MessageInvoiceResult = | 'ok' | 'noZap' + | 'not_zap' | 'unreachable' | 'no_event' | 'no_author' diff --git a/src/routes/messages.ts b/src/routes/messages.ts index 54a1d244..b5647ae7 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -545,6 +545,28 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { const inspected = inspectBolt11(zap.pr); const description = inspected?.description ?? null; const descriptionHash = inspected?.descriptionHash ?? null; + const nip57 = isNip57Invoice(descriptionHash, zapRequestJson); + if (!nip57) { + await persistInvoiceAttempt( + deps.store, + invoiceAttemptBase({ + messageId: row.id, + payerAccountId: account.id, + authorAccountId: author.id, + amountSats: parsed.data.sats, + lightningAddress: author.lightningAddress, + zapRequest, + result: 'not_zap', + httpStatus: 400, + pr: zap.pr, // keep for debug; this is the exception to "failure rows have pr null" + paymentHash: inspected?.paymentHash ?? null, + description, + descriptionHash, + isNip57Invoice: false, + }), + ); + return c.json({ error: 'Could not start the Bitcoin payment' }, 400); + } await persistInvoiceAttempt( deps.store, invoiceAttemptBase({ @@ -560,7 +582,7 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { paymentHash: inspected?.paymentHash ?? null, description, descriptionHash, - isNip57Invoice: isNip57Invoice(descriptionHash, zapRequestJson), + isNip57Invoice: true, }), ); return c.json({ pr: zap.pr, amountSats: zap.amountSats }, 200); From 8cf8ad66ff6e546077747f00cdeb468e699291a8 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Sun, 30 Aug 2026 17:12:31 +0200 Subject: [PATCH 10/12] 01a04e04 - Update only the name on provision upsert (#75) * Update only the account name on provision upsert POST /debug/accounts no longer rewrites the whole account row when the Lightning Address already exists. Postgres SET name is atomic so a concurrent role or rules write is not lost. * Harden the name-only provision update against stale reads Look up and mutate the in-memory row without yielding. Fail the provision request when the persisted name is not the requested one. * Document the name-mismatch 500 on provision upsert POST /debug/accounts already returns 500 when the stored name is not the requested name. SPEC and handbook now say so. --------- Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- SPEC.md | 11 +- docs/handbook/endpoints.md | 4 +- docs/handbook/functions.md | 10 +- src/__tests__/lib/auth/postgres-store.test.ts | 43 ++++ src/__tests__/lib/auth/store.test.ts | 48 ++++ src/__tests__/routes/debug.test.ts | 219 ++++++++++++++++-- src/lib/auth/postgres-store.ts | 15 ++ src/lib/auth/store.ts | 27 +++ src/routes/debug.ts | 30 ++- 9 files changed, 370 insertions(+), 37 deletions(-) diff --git a/SPEC.md b/SPEC.md index c3fd044d..a3cf5a55 100644 --- a/SPEC.md +++ b/SPEC.md @@ -639,7 +639,9 @@ passkey and `rulesAgreedAt` null. Same `DEBUG_TOKEN` bearer as GET. non-empty). Invalid body, C0/DEL in a name, or an address that is not LUD-16 → **Response** `400` `{ "error": "Expected a JSON body with an \"accounts\" array" }` (no row is written). Create that does not persist the -address → **Response** `500` `{ "error": "Could not save the account" }`. +address, a name-only update that matches no row, or a name-only update +that returns a row whose `name` is not the requested name → **Response** `500` +`{ "error": "Could not save the account" }`. Success → **Response** `200`: @@ -656,9 +658,10 @@ Success → **Response** `200`: } ``` -Existing address (`lower(trim)`): updates `name`, keeps `viewKey`, `created` is -`false`. New address: fresh `viewKey`, `created` is `true`. GET still omits -`viewKey`. +Existing address (`lower(trim)`): updates **only** `name` (atomic name-only +write; `viewKey`, `role`, `rulesAgreedAt`, and other columns stay unchanged), +`created` is `false`. New address: fresh `viewKey`, `created` is `true`. GET +still omits `viewKey`. ### `PATCH /debug/accounts/:id` diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index a1acc467..87819c11 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -23,8 +23,8 @@ ## Endpoint: POST /debug/accounts -- **Purpose:** Operator provision of accounts by display name + Lightning Address (no passkey, `rulesAgreedAt` null). Body `{ "accounts": [ { "name", "lightningAddress" } ] }` (1–100 rows). Creates a new `basis` row with a fresh `viewKey`, or updates `name` when the address already exists (`lower(trim)` match). Response `{ accounts: [ { name, lightningAddress, viewKey, created } ] }` includes `viewKey` for the invite link; `GET` still omits it. -- **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 an "accounts" array' }` for invalid/missing/non-JSON body, C0/DEL names, or non-LUD-16 addresses (no row is written); 500 `{ error: 'Could not save the account' }` when create does not persist the address. +- **Purpose:** Operator provision of accounts by display name + Lightning Address (no passkey, `rulesAgreedAt` null). Body `{ "accounts": [ { "name", "lightningAddress" } ] }` (1–100 rows). Creates a new `basis` row with a fresh `viewKey`, or updates **only** `name` when the address already exists (`lower(trim)` match; other columns including `viewKey`, `role`, and `rulesAgreedAt` stay unchanged). Response `{ accounts: [ { name, lightningAddress, viewKey, created } ] }` includes `viewKey` for the invite link; `GET` still omits it. +- **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 an "accounts" array' }` for invalid/missing/non-JSON body, C0/DEL names, or non-LUD-16 addresses (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. - **Used by:** Operator provisioning before passkey claim. - **Auth:** `Authorization: Bearer` with `DEBUG_TOKEN`. Not an end-user session. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 4f607fc1..9710fe71 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -177,15 +177,15 @@ ## Function: InMemoryAuthStore -- **Purpose:** Process-local AuthStore: passkey challenges/credentials, accounts, sessions, verifications, and custodial Nostr keys (`getNostrPublicKey` / `getNostrSecret` / `setNostrKeyIfAbsent` / `listAccountIdsWithoutNostrKey`). Evicts expired challenges/sessions on write. Indexes `linkingKey` only when non-null. Maintains an O(1) `viewKey` index; `getAccountByViewKey` looks it up. `getAccountByLightningAddress` scans for a `lower(trim)` match and skips null addresses. `accountHasPasskey` is true when any credential maps to the account id. `createAccount` is a no-op when `viewKey` is already stored, a non-null `linkingKey` already exists, or `lightningAddress` (`lower(trim)`) belongs to another id. `updateAccount` reindexes `viewKey` when it changes and refuses a `viewKey`, non-null `linkingKey`, or `lightningAddress` owned by another id. `deleteAccount` drops the row and its linking-key and viewKey indexes. `listAccounts` returns every account oldest-first. -- **Inputs:** Constructor none. Methods take domain objects (`PasskeyChallenge`, `PasskeyCredential`, `Account`, `Session`, `AddressVerification`). `createAccount` is a no-op when a non-null `linkingKey` already exists, when `viewKey` is already stored, or when `lightningAddress` (`lower(trim)`) is taken. `updateAccount` refuses a `linkingKey` / `viewKey` / `lightningAddress` owned by another account and keeps the viewKey index consistent. `deleteAccount` drops the row and its linking-key and viewKey indexes. `createPasskeyCredential` returns false when this account already has a credential or the id is taken. `createFirstPasskeyCredential` returns false when this account already has a credential or the id is taken. `updatePasskeyCredential` returns false unless `(newCount === 0 && stored === 0)` or `newCount > stored`; missing id is false; does not rebind `accountId` / `publicKey`. `updatePasskeyChallenge` returns false when the row is missing or already consumed. +- **Purpose:** Process-local AuthStore: passkey challenges/credentials, accounts, sessions, verifications, and custodial Nostr keys (`getNostrPublicKey` / `getNostrSecret` / `setNostrKeyIfAbsent` / `listAccountIdsWithoutNostrKey`). Evicts expired challenges/sessions on write. Indexes `linkingKey` only when non-null. Maintains an O(1) `viewKey` index; `getAccountByViewKey` looks it up. `getAccountByLightningAddress` scans for a `lower(trim)` match and skips null addresses. `updateAccountNameByLightningAddress` mutates only `name` on the matched account (`lower(trim)`); other fields stay unchanged; unknown address → `undefined`. `accountHasPasskey` is true when any credential maps to the account id. `createAccount` is a no-op when `viewKey` is already stored, a non-null `linkingKey` already exists, or `lightningAddress` (`lower(trim)`) belongs to another id. `updateAccount` reindexes `viewKey` when it changes and refuses a `viewKey`, non-null `linkingKey`, or `lightningAddress` owned by another id. `deleteAccount` drops the row and its linking-key and viewKey indexes. `listAccounts` returns every account oldest-first. +- **Inputs:** Constructor none. Methods take domain objects (`PasskeyChallenge`, `PasskeyCredential`, `Account`, `Session`, `AddressVerification`). `createAccount` is a no-op when a non-null `linkingKey` already exists, when `viewKey` is already stored, or when `lightningAddress` (`lower(trim)`) is taken. `updateAccount` refuses a `linkingKey` / `viewKey` / `lightningAddress` owned by another account and keeps the viewKey index consistent. `updateAccountNameByLightningAddress(lightningAddress, name)` takes the address and new display name. `deleteAccount` drops the row and its linking-key and viewKey indexes. `createPasskeyCredential` returns false when this account already has a credential or the id is taken. `createFirstPasskeyCredential` returns false when this account already has a credential or the id is taken. `updatePasskeyCredential` returns false unless `(newCount === 0 && stored === 0)` or `newCount > stored`; missing id is false; does not rebind `accountId` / `publicKey`. `updatePasskeyChallenge` returns false when the row is missing or already consumed. - **Returns / side effects:** Lookups return the object or `undefined`. Writes resolve when persisted. `listAccounts` returns `Account[]`. - **Used by:** `createApp` default store; all auth/me/debug/view routes. ## Function: PostgresAuthStore -- **Purpose:** Durable AuthStore over Postgres (`SqlClient`). Same eviction-on-write semantics as the in-memory adapter, including passkey challenges, credentials, custodial Nostr key columns, and the `view_key` column. `getAccountByViewKey` is `WHERE view_key = $1`. `getAccountByLightningAddress` is `WHERE lower(trim(lightning_address)) = lower(trim($1))` (null addresses do not match). `accountHasPasskey` is `SELECT 1 FROM passkey_credential WHERE account_id = $1 LIMIT 1`. `mapAccount` skips null `view_key` (`getAccount` / `getAccountByViewKey` / `getAccountByLightningAddress` return undefined; `listAccounts` omits those rows). Passkey `signCount` advances with an atomic `WHERE` (`0/0` or `new > stored`) `RETURNING`, not `GREATEST`; duplicate credential ids are `ON CONFLICT DO NOTHING`. `createPasskeyCredential` also returns false on unique_violation `23505` for `passkey_credential_account_uidx` (one credential per account). `createFirstPasskeyCredential` inserts only when the account has no credential (`WHERE NOT EXISTS` plus unique `account_id`); unique_violation is false. `createAccount` INSERT unique_violation `23505` is a no-op. `updateAccount` refuses a `linkingKey` owned by another id (`UPDATE` matches no row; unique_violation `23505` is a no-op). `deleteAccount` is `DELETE FROM account WHERE id = $1`. Unique index on `lower(trim(lightning_address))` where the address is not null. -- **Inputs:** Constructor takes a `SqlClient`. Methods match `AuthStore` including `getAccountByViewKey`, `getAccountByLightningAddress`, and `accountHasPasskey`. +- **Purpose:** Durable AuthStore over Postgres (`SqlClient`). Same eviction-on-write semantics as the in-memory adapter, including passkey challenges, credentials, custodial Nostr key columns, and the `view_key` column. `getAccountByViewKey` is `WHERE view_key = $1`. `getAccountByLightningAddress` is `WHERE lower(trim(lightning_address)) = lower(trim($1))` (null addresses do not match). `updateAccountNameByLightningAddress` is `UPDATE account SET name = $2 WHERE lower(trim(lightning_address)) = lower(trim($1)) RETURNING …` (other columns unchanged; empty `RETURNING` → `undefined`). `accountHasPasskey` is `SELECT 1 FROM passkey_credential WHERE account_id = $1 LIMIT 1`. `mapAccount` skips null `view_key` (`getAccount` / `getAccountByViewKey` / `getAccountByLightningAddress` / `updateAccountNameByLightningAddress` return undefined; `listAccounts` omits those rows). Passkey `signCount` advances with an atomic `WHERE` (`0/0` or `new > stored`) `RETURNING`, not `GREATEST`; duplicate credential ids are `ON CONFLICT DO NOTHING`. `createPasskeyCredential` also returns false on unique_violation `23505` for `passkey_credential_account_uidx` (one credential per account). `createFirstPasskeyCredential` inserts only when the account has no credential (`WHERE NOT EXISTS` plus unique `account_id`); unique_violation is false. `createAccount` INSERT unique_violation `23505` is a no-op. `updateAccount` refuses a `linkingKey` owned by another id (`UPDATE` matches no row; unique_violation `23505` is a no-op). `deleteAccount` is `DELETE FROM account WHERE id = $1`. Unique index on `lower(trim(lightning_address))` where the address is not null. +- **Inputs:** Constructor takes a `SqlClient`. Methods match `AuthStore` including `getAccountByViewKey`, `getAccountByLightningAddress`, `updateAccountNameByLightningAddress`, and `accountHasPasskey`. - **Returns / side effects:** Parameter-bound SQL; maps snake_case rows to domain objects. - **Used by:** `openAuthStore` when `DATABASE_URL` is set. @@ -228,7 +228,7 @@ - **Purpose:** Operator listing, provisioning, and role assignment 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; upserts by Lightning Address and 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 }` → 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. - **Used by:** `createApp` at `/debug/accounts`. ## Function: debugContactsRoutes diff --git a/src/__tests__/lib/auth/postgres-store.test.ts b/src/__tests__/lib/auth/postgres-store.test.ts index e1985ed5..5bcb3481 100644 --- a/src/__tests__/lib/auth/postgres-store.test.ts +++ b/src/__tests__/lib/auth/postgres-store.test.ts @@ -208,6 +208,47 @@ describe('PostgresAuthStore', () => { ).toBeUndefined(); }); + it('updateAccountNameByLightningAddress sets only name by lower(trim) address', async () => { + const sql = new MockSql(); + sql.nextRows = [ + { + ...ACCOUNT_ROW, + role: 'moderator', + name: 'Ada Lovelace', + lightning_address: 'guest@walletofsatoshi.com', + rules_agreed_at: new Date(9_000), + }, + ]; + const store = new PostgresAuthStore(sql); + const named = await store.updateAccountNameByLightningAddress( + ' Guest@WalletOfSatoshi.com ', + 'Ada Lovelace', + ); + expect(sql.queries[0]?.text).toMatch(/SET name = \$2\s+WHERE/); + expect(sql.queries[0]?.text).toMatch( + /lower\(trim\(lightning_address\)\) = lower\(trim\(\$1\)\)/, + ); + expect(sql.queries[0]?.text).toMatch(/RETURNING id, linking_key, role, name/); + expect(sql.queries[0]?.params).toEqual([' Guest@WalletOfSatoshi.com ', 'Ada Lovelace']); + expect(named).toMatchObject({ + id: 'acc', + name: 'Ada Lovelace', + role: 'moderator', + lightningAddress: 'guest@walletofsatoshi.com', + rulesAgreedAt: 9_000, + viewKey: VIEW_KEY, + }); + }); + + it('updateAccountNameByLightningAddress returns undefined when no row matches', async () => { + expect( + await new PostgresAuthStore(new MockSql()).updateAccountNameByLightningAddress( + 'missing@example.com', + 'Ada', + ), + ).toBeUndefined(); + }); + it('accountHasPasskey queries passkey_credential by account_id', async () => { const sql = new MockSql(); const store = new PostgresAuthStore(sql); @@ -227,6 +268,8 @@ describe('PostgresAuthStore', () => { expect(await store.getAccount('acc')).toBeUndefined(); expect(await store.getAccountByViewKey(VIEW_KEY)).toBeUndefined(); expect(await store.listAccounts()).toEqual([]); + sql.nextRows = [{ ...ACCOUNT_ROW, view_key: null, lightning_address: 'a@b.com' }]; + expect(await store.updateAccountNameByLightningAddress('a@b.com', 'Ada')).toBeUndefined(); }); it('createAccount treats a unique_violation as a no-op', async () => { diff --git a/src/__tests__/lib/auth/store.test.ts b/src/__tests__/lib/auth/store.test.ts index abe9b45c..038668a2 100644 --- a/src/__tests__/lib/auth/store.test.ts +++ b/src/__tests__/lib/auth/store.test.ts @@ -640,6 +640,54 @@ describe('InMemoryAuthStore', () => { expect(await store.getAccountByLightningAddress('missing@example.com')).toBeUndefined(); }); + it('updateAccountNameByLightningAddress changes only name', async () => { + const store = new InMemoryAuthStore(); + await store.createAccount({ + id: 'nameless', + linkingKey: null, + role: 'basis', + name: 'Skip', + lightningAddress: null, + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: 'b'.repeat(64), + createdAt: 1, + rulesAgreedAt: null, + }); + await store.createAccount({ + id: 'acc', + linkingKey: null, + role: 'moderator', + name: 'Ada', + lightningAddress: 'guest@walletofsatoshi.com', + lightningAddressVerified: true, + forumLawsDismissed: true, + viewKey: 'a'.repeat(64), + createdAt: 1, + rulesAgreedAt: 9_000, + }); + const named = await store.updateAccountNameByLightningAddress( + ' Guest@WalletOfSatoshi.com ', + 'Ada Lovelace', + ); + expect(named).toMatchObject({ + id: 'acc', + name: 'Ada Lovelace', + role: 'moderator', + rulesAgreedAt: 9_000, + viewKey: 'a'.repeat(64), + lightningAddressVerified: true, + forumLawsDismissed: true, + }); + const stored = await store.getAccount('acc'); + expect(stored?.name).toBe('Ada Lovelace'); + expect(stored?.role).toBe('moderator'); + expect(stored?.rulesAgreedAt).toBe(9_000); + expect( + await store.updateAccountNameByLightningAddress('missing@example.com', 'X'), + ).toBeUndefined(); + }); + it('refuses createAccount and updateAccount when the lightningAddress is taken', async () => { const store = new InMemoryAuthStore(); const base = { diff --git a/src/__tests__/routes/debug.test.ts b/src/__tests__/routes/debug.test.ts index b1d20102..de412663 100644 --- a/src/__tests__/routes/debug.test.ts +++ b/src/__tests__/routes/debug.test.ts @@ -376,6 +376,41 @@ describe('debugRoutes', () => { ).toBe(true); }); + it('POST updates only name on an existing moderator account', async () => { + const store = new InMemoryAuthStore(); + await store.createAccount({ + id: 'existing', + linkingKey: null, + role: 'moderator', + name: 'Old', + lightningAddress: 'guest@walletofsatoshi.com', + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: 'c'.repeat(64), + createdAt: 1, + rulesAgreedAt: 9_000, + }); + const app = new Hono().route('/debug/accounts', debugRoutes({ store, debugToken: 'secret' })); + const res = await app.request('/debug/accounts', { + method: 'POST', + headers: { authorization: 'Bearer secret', 'content-type': 'application/json' }, + body: JSON.stringify({ + accounts: [{ name: 'Ada', lightningAddress: 'guest@walletofsatoshi.com' }], + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + accounts: Array<{ name: string; created: boolean; viewKey: string }>; + }; + expect(body.accounts[0]?.created).toBe(false); + expect(body.accounts[0]?.name).toBe('Ada'); + expect(body.accounts[0]?.viewKey).toBe('c'.repeat(64)); + const stored = await store.getAccount('existing'); + expect(stored?.name).toBe('Ada'); + expect(stored?.role).toBe('moderator'); + expect(stored?.rulesAgreedAt).toBe(9_000); + }); + it('POST returns 500 when create does not persist the address', async () => { class HollowStore extends InMemoryAuthStore { override async getAccountByLightningAddress(): Promise { @@ -399,6 +434,40 @@ describe('debugRoutes', () => { expect(res.status).toBe(500); }); + it('POST returns 500 when the name-only update finds no row', async () => { + class MissingNameUpdateStore extends InMemoryAuthStore { + override async getAccountByLightningAddress() { + return { + id: 'existing', + linkingKey: null, + role: 'basis' as const, + name: 'Old', + lightningAddress: 'guest@walletofsatoshi.com', + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: 'c'.repeat(64), + createdAt: 1, + rulesAgreedAt: null, + }; + } + override async updateAccountNameByLightningAddress(): Promise { + return undefined; + } + } + const app = new Hono().route( + '/debug/accounts', + debugRoutes({ store: new MissingNameUpdateStore(), debugToken: 'secret' }), + ); + const res = await app.request('/debug/accounts', { + method: 'POST', + headers: { authorization: 'Bearer secret', 'content-type': 'application/json' }, + body: JSON.stringify({ + accounts: [{ name: 'Ada', lightningAddress: 'guest@walletofsatoshi.com' }], + }), + }); + expect(res.status).toBe(500); + }); + it('POST applies the name when create loses a race to an existing address', async () => { class RaceStore extends InMemoryAuthStore { #lookups = 0; @@ -414,14 +483,14 @@ describe('debugRoutes', () => { await store.createAccount({ id: 'existing', linkingKey: null, - role: 'basis', + role: 'moderator', name: 'Old', lightningAddress: 'guest@walletofsatoshi.com', lightningAddressVerified: false, forumLawsDismissed: false, viewKey: 'c'.repeat(64), createdAt: 1, - rulesAgreedAt: null, + rulesAgreedAt: 9_000, }); const app = new Hono().route('/debug/accounts', debugRoutes({ store, debugToken: 'secret' })); const res = await app.request('/debug/accounts', { @@ -438,10 +507,51 @@ describe('debugRoutes', () => { expect(body.accounts[0]?.created).toBe(false); expect(body.accounts[0]?.name).toBe('Ada'); expect(body.accounts[0]?.viewKey).toBe('c'.repeat(64)); - expect((await store.getAccount('existing'))?.name).toBe('Ada'); + const stored = await store.getAccount('existing'); + expect(stored?.name).toBe('Ada'); + expect(stored?.role).toBe('moderator'); + expect(stored?.rulesAgreedAt).toBe(9_000); + }); + + it('POST returns 500 when a create race cannot apply the name-only update', async () => { + class RaceHollowNameStore extends InMemoryAuthStore { + #lookups = 0; + override async getAccountByLightningAddress(address: string) { + this.#lookups += 1; + if (this.#lookups === 1) { + return undefined; + } + return super.getAccountByLightningAddress(address); + } + override async updateAccountNameByLightningAddress(): Promise { + return undefined; + } + } + const store = new RaceHollowNameStore(); + await store.createAccount({ + id: 'existing', + linkingKey: null, + role: 'basis', + name: 'Old', + lightningAddress: 'guest@walletofsatoshi.com', + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: 'c'.repeat(64), + createdAt: 1, + rulesAgreedAt: null, + }); + const app = new Hono().route('/debug/accounts', debugRoutes({ store, debugToken: 'secret' })); + const res = await app.request('/debug/accounts', { + method: 'POST', + headers: { authorization: 'Bearer secret', 'content-type': 'application/json' }, + body: JSON.stringify({ + accounts: [{ name: 'Ada', lightningAddress: 'guest@walletofsatoshi.com' }], + }), + }); + expect(res.status).toBe(500); }); - it('POST falls back to the request address when the stored row has none', async () => { + it('POST returns 500 when the name-only update does not persist the request name', async () => { class NullAddressStore extends InMemoryAuthStore { override async getAccountByLightningAddress() { return { @@ -457,6 +567,20 @@ describe('debugRoutes', () => { rulesAgreedAt: null, }; } + override async updateAccountNameByLightningAddress() { + return { + id: 'existing', + linkingKey: null, + role: 'basis' as const, + name: null, + lightningAddress: null, + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: 'c'.repeat(64), + createdAt: 1, + rulesAgreedAt: null, + }; + } } const app = new Hono().route( '/debug/accounts', @@ -469,12 +593,8 @@ describe('debugRoutes', () => { accounts: [{ name: 'Ada', lightningAddress: 'guest@walletofsatoshi.com' }], }), }); - expect(res.status).toBe(200); - const body = (await res.json()) as { - accounts: Array<{ lightningAddress: string; created: boolean }>; - }; - expect(body.accounts[0]?.created).toBe(false); - expect(body.accounts[0]?.lightningAddress).toBe('guest@walletofsatoshi.com'); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: 'Could not save the account' }); }); it('POST falls back to the request name and address when create returns null fields', async () => { @@ -505,7 +625,7 @@ describe('debugRoutes', () => { expect(body.accounts[0]?.lightningAddress).toBe('guest@walletofsatoshi.com'); }); - it('POST falls back to the request address when a create race returns a nameless row', async () => { + it('POST returns 500 when a create-race name-only update does not persist the request name', async () => { class RaceNullAddressStore extends InMemoryAuthStore { #lookups = 0; override async getAccountByLightningAddress(address: string) { @@ -514,7 +634,11 @@ describe('debugRoutes', () => { return undefined; } const acc = await super.getAccountByLightningAddress(address); - return acc === undefined ? undefined : { ...acc, lightningAddress: null }; + return acc === undefined ? undefined : { ...acc, name: null, lightningAddress: null }; + } + override async updateAccountNameByLightningAddress(address: string, name: string) { + const acc = await super.updateAccountNameByLightningAddress(address, name); + return acc === undefined ? undefined : { ...acc, name: null, lightningAddress: null }; } } const store = new RaceNullAddressStore(); @@ -538,11 +662,74 @@ describe('debugRoutes', () => { accounts: [{ name: 'Ada', lightningAddress: 'guest@walletofsatoshi.com' }], }), }); - expect(res.status).toBe(200); - const body = (await res.json()) as { + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: 'Could not save the account' }); + }); + + it('POST falls back to the request address when the name-only update omits it', async () => { + const saved = { + id: 'existing', + linkingKey: null, + role: 'basis' as const, + name: 'Ada', + lightningAddress: null as string | null, + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: 'c'.repeat(64), + createdAt: 1, + rulesAgreedAt: null, + }; + class AddressFallbackStore extends InMemoryAuthStore { + override async getAccountByLightningAddress() { + return saved; + } + override async updateAccountNameByLightningAddress() { + return saved; + } + } + const app = new Hono().route( + '/debug/accounts', + debugRoutes({ store: new AddressFallbackStore(), debugToken: 'secret' }), + ); + const existing = await app.request('/debug/accounts', { + method: 'POST', + headers: { authorization: 'Bearer secret', 'content-type': 'application/json' }, + body: JSON.stringify({ + accounts: [{ name: 'Ada', lightningAddress: 'guest@walletofsatoshi.com' }], + }), + }); + expect(existing.status).toBe(200); + expect( + ((await existing.json()) as { accounts: Array<{ lightningAddress: string }> }).accounts[0] + ?.lightningAddress, + ).toBe('guest@walletofsatoshi.com'); + + class RaceAddressFallbackStore extends InMemoryAuthStore { + #lookups = 0; + override async getAccountByLightningAddress() { + this.#lookups += 1; + return this.#lookups === 1 ? undefined : saved; + } + override async updateAccountNameByLightningAddress() { + return saved; + } + } + const raceApp = new Hono().route( + '/debug/accounts', + debugRoutes({ store: new RaceAddressFallbackStore(), debugToken: 'secret' }), + ); + const raced = await raceApp.request('/debug/accounts', { + method: 'POST', + headers: { authorization: 'Bearer secret', 'content-type': 'application/json' }, + body: JSON.stringify({ + accounts: [{ name: 'Ada', lightningAddress: 'guest@walletofsatoshi.com' }], + }), + }); + expect(raced.status).toBe(200); + const racedBody = (await raced.json()) as { accounts: Array<{ lightningAddress: string; created: boolean }>; }; - expect(body.accounts[0]?.created).toBe(false); - expect(body.accounts[0]?.lightningAddress).toBe('guest@walletofsatoshi.com'); + expect(racedBody.accounts[0]?.created).toBe(false); + expect(racedBody.accounts[0]?.lightningAddress).toBe('guest@walletofsatoshi.com'); }); }); diff --git a/src/lib/auth/postgres-store.ts b/src/lib/auth/postgres-store.ts index bc21d44d..5c66b1da 100644 --- a/src/lib/auth/postgres-store.ts +++ b/src/lib/auth/postgres-store.ts @@ -150,6 +150,21 @@ export class PostgresAuthStore implements AuthStore { } } + async updateAccountNameByLightningAddress( + lightningAddress: string, + name: string, + ): Promise { + const rows = await this.#sql.query( + `UPDATE account + SET name = $2 + WHERE lower(trim(lightning_address)) = lower(trim($1)) + RETURNING id, linking_key, role, name, lightning_address, lightning_address_verified, forum_laws_dismissed, view_key, created_at, rules_agreed_at`, + [lightningAddress, name], + ); + const row = rows[0]; + return row === undefined ? undefined : mapAccount(row); + } + async getAccount(id: string): Promise { const rows = await this.#sql.query( `SELECT id, linking_key, role, name, lightning_address, lightning_address_verified, forum_laws_dismissed, view_key, created_at, rules_agreed_at diff --git a/src/lib/auth/store.ts b/src/lib/auth/store.ts index 9850fa3d..c46feb45 100644 --- a/src/lib/auth/store.ts +++ b/src/lib/auth/store.ts @@ -128,6 +128,16 @@ export interface AuthStore { * unique_violation). */ updateAccount(account: Account): Promise; + /** + * Set only `name` on the account that owns this Lightning Address + * (`lower(trim)` match). Other columns stay unchanged. + * + * @returns The updated account, or `undefined` when no row matches. + */ + updateAccountNameByLightningAddress( + lightningAddress: string, + name: string, + ): Promise; /** Look up an account by id, or `undefined` if unknown. */ getAccount(id: string): Promise; /** @@ -287,6 +297,23 @@ export class InMemoryAuthStore implements AuthStore { } } + async updateAccountNameByLightningAddress( + lightningAddress: string, + name: string, + ): Promise { + const needle = lightningAddress.trim().toLowerCase(); + for (const account of this.#accounts.values()) { + if (account.lightningAddress === null) { + continue; + } + if (account.lightningAddress.trim().toLowerCase() === needle) { + account.name = name; + return account; + } + } + return undefined; + } + async deleteAccount(id: string): Promise { const previous = this.#accounts.get(id); if (previous === undefined) { diff --git a/src/routes/debug.ts b/src/routes/debug.ts index 33f75bea..2100eda7 100644 --- a/src/routes/debug.ts +++ b/src/routes/debug.ts @@ -98,13 +98,18 @@ export function debugRoutes(deps: DebugRouteDeps): Hono { for (const row of accounts) { const found = await deps.store.getAccountByLightningAddress(row.lightningAddress); if (found !== undefined) { - const next = { ...found, name: row.name }; - await deps.store.updateAccount(next); + const named = await deps.store.updateAccountNameByLightningAddress( + row.lightningAddress, + row.name, + ); + if (named === undefined || named.name !== row.name) { + return c.json({ error: 'Could not save the account' }, 500); + } updated += 1; results.push({ - name: row.name, - lightningAddress: found.lightningAddress ?? row.lightningAddress, - viewKey: found.viewKey, + name: named.name, + lightningAddress: named.lightningAddress ?? row.lightningAddress, + viewKey: named.viewKey, created: false, }); continue; @@ -136,13 +141,18 @@ export function debugRoutes(deps: DebugRouteDeps): Hono { created: true, }); } else { - const next = { ...stored, name: row.name }; - await deps.store.updateAccount(next); + const named = await deps.store.updateAccountNameByLightningAddress( + row.lightningAddress, + row.name, + ); + if (named === undefined || named.name !== row.name) { + return c.json({ error: 'Could not save the account' }, 500); + } updated += 1; results.push({ - name: row.name, - lightningAddress: stored.lightningAddress ?? row.lightningAddress, - viewKey: stored.viewKey, + name: named.name, + lightningAddress: named.lightningAddress ?? row.lightningAddress, + viewKey: named.viewKey, created: false, }); } From aeb3b433b8ef2e642094c10d44a5847af5b8f6b3 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Sun, 30 Aug 2026 17:37:34 +0200 Subject: [PATCH 11/12] 01a052b6 - Do not reset pending notes to stamp Damus hashtags (#78) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 01a052b6 - Do not reset pending notes to stamp Damus hashtags Resetting every signed row every tick renewed the 60s sign lease, so nothing reached a relay — including new forum posts. Only published unpaid notes are re-signed, and only after unsigned rows are signed, matching the photo-URL lease rule. * 01a052b6 - Align photo and hashtag resign after sign Both published-only resets now run after unsigned rows are signed, so new forum posts are not queued behind a 20-row rewrite of old notes. Pending notes still EVENT without being reset. Docs match that order. --------- Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- CONCEPT.md | 14 ++-- FLOWS.md | 2 +- SPEC.md | 2 +- docs/handbook/functions.md | 6 +- src/__tests__/lib/message-store.test.ts | 12 ++++ src/__tests__/lib/nostr/worker.test.ts | 90 ++++++++++++++++++++++--- src/lib/message-store.ts | 15 +++-- src/lib/nostr/worker.ts | 35 +++++----- 8 files changed, 133 insertions(+), 43 deletions(-) diff --git a/CONCEPT.md b/CONCEPT.md index fd1ee5af..2575a949 100644 --- a/CONCEPT.md +++ b/CONCEPT.md @@ -241,13 +241,13 @@ below applies to v1 for the surfaces v1 ships — profile metadata, campaign post, public comment; the DM and Zap-receipt rows stay deferred, see MVP scope. The client-side-signing flow beneath it is target state.) -| UI surface | NOSTR primitive | -| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| Profile metadata (name, photo, story) | `kind:0` (NIP-01 metadata) | -| Receiver profile / campaign post | `kind:1` (text note), tagged with campaign metadata | -| Public comment / encouragement | top-level `kind:1` (frozen `t=bitcoin` / `t=21gifts` / `r=https://21.gifts`; no `e`/`p`/`q`; Damus-visible `#bitcoin #21gifts` in kind:1 **content**) | -| Private message donor ↔ receiver | `kind:14` (NIP-17 sealed DM, modern) or `kind:4` (legacy) | -| Donation acknowledgement | `kind:9735` Zap receipt (when NIP-57 enabled) | +| UI surface | NOSTR primitive | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Profile metadata (name, photo, story) | `kind:0` (NIP-01 metadata) | +| Receiver profile / campaign post | `kind:1` (text note), tagged with campaign metadata | +| Public comment / encouragement | top-level `kind:1` (frozen `t=bitcoin` / `t=21gifts` / `r=https://21.gifts`; no `e`/`p`/`q`; Damus-visible `#bitcoin #21gifts` in kind:1 **content**; pending fan-out is not reset to stamp hashtags or photo URLs) | +| Private message donor ↔ receiver | `kind:14` (NIP-17 sealed DM, modern) or `kind:4` (legacy) | +| Donation acknowledgement | `kind:9735` Zap receipt (when NIP-57 enabled) | **Flow** — the app does not talk to NOSTR relays directly. It talks to the backend API, which acts as the user's edge to the network: diff --git a/FLOWS.md b/FLOWS.md index 9a95d732..fee1bf62 100644 --- a/FLOWS.md +++ b/FLOWS.md @@ -155,7 +155,7 @@ snapshotted at post, `sats`, `payable`, `hasPhoto`, and live author `role` is a messenger-group thread: oldest notes at the top, newest at the bottom, composer under the newest note. The welcome-forum living-room laws hint is dismissed via `POST /me/forum-laws-dismissed`. Posts are standalone kind:1 -notes (Damus-visible `#bitcoin` / `#21gifts` in content; forum `text` unchanged); +notes (Damus-visible `#bitcoin` / `#21gifts` in content on first sign; forum `text` unchanged; pending notes EVENT before any hashtag/photo re-sign so the sign lease cannot starve fan-out); the worker fans out when `NOSTR_PUBLISH=1`. Pay-on-note is `POST /messages/:id/invoice`. Do not invent `/events` or `/comments` paths. diff --git a/SPEC.md b/SPEC.md index a3cf5a55..b0aab062 100644 --- a/SPEC.md +++ b/SPEC.md @@ -4,7 +4,7 @@ > Product decisions live in [`CONCEPT.md`](./CONCEPT.md); this file owns > request/response contracts for routes that exist in code today. -**Status**: living document. Last revised 2026-08-30 (Web Push VAPID: `GET /push/vapid-public`, `POST`/`DELETE /me/push-subscriptions`, `POST /debug/push-ping`; kind:1 content includes Damus-visible `#bitcoin` / `#21gifts`; `POST /debug/accounts` provision; `POST /auth/passkey/register/begin` optional `{ viewKey }` claim; `POST /me/lightning-address` `409` when the address is taken; `GET /debug/invoices` and `GET /debug/zap-ingests`; kind:0 `picture` + NIP-65 kind:10002; kind:1 NIP-92 `imeta` photo URLs; public `GET /messages/:id/photo`; forum `account.role` `basis`\|`verified`\|`moderator`\|`founder`; live `role` on `GET/POST /messages`; `PATCH /debug/accounts/:id`; private in-app `POST /contact` + `GET /debug/contacts`; `POST /me/lightning-address` live-resolves and requires zap metadata; invoice limiter after payable checks; public forum `GET/POST /messages` with `sats`/`payable`/`hasPhoto`; worker indexes kind:9735 zap receipts onto `sats`; `POST /messages/:id/invoice` NIP-57 zap; SQL boot requires `NOSTR_NSEC_KEK`; passkey-only login; gift stats BTC + historical USD via Coinbase daily close; `GET /gifts?day=`). +**Status**: living document. Last revised 2026-08-30 (pending kind:1 EVENT before hashtag/photo re-sign; Web Push VAPID: `GET /push/vapid-public`, `POST`/`DELETE /me/push-subscriptions`, `POST /debug/push-ping`; kind:1 content includes Damus-visible `#bitcoin` / `#21gifts`; `POST /debug/accounts` provision; `POST /auth/passkey/register/begin` optional `{ viewKey }` claim; `POST /me/lightning-address` `409` when the address is taken; `GET /debug/invoices` and `GET /debug/zap-ingests`; kind:0 `picture` + NIP-65 kind:10002; kind:1 NIP-92 `imeta` photo URLs; public `GET /messages/:id/photo`; forum `account.role` `basis`\|`verified`\|`moderator`\|`founder`; live `role` on `GET/POST /messages`; `PATCH /debug/accounts/:id`; private in-app `POST /contact` + `GET /debug/contacts`; `POST /me/lightning-address` live-resolves and requires zap metadata; invoice limiter after payable checks; public forum `GET/POST /messages` with `sats`/`payable`/`hasPhoto`; worker indexes kind:9735 zap receipts onto `sats`; `POST /messages/:id/invoice` NIP-57 zap; SQL boot requires `NOSTR_NSEC_KEK`; passkey-only login; gift stats BTC + historical USD via Coinbase daily close; `GET /gifts?day=`). --- diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 9710fe71..be6a6031 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -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; `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 signed unpaid rows whose kind:1 content lacks `#bitcoin` or `#21gifts` (any publish state, `sats = 0`, 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; `getPhoto` loads bytes by id; `getById`; `getByEventId` (`WHERE event_id`); `claimUnsigned`/`claimUnpublished` lease rows; `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 `#bitcoin` or `#21gifts` (`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. @@ -366,7 +366,7 @@ ## 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, `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` (signed unpaid, kind:1 content lacks `#bitcoin` or `#21gifts`, oldest-first, any publish state, `sats === 0`, includes null / non-string content), `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`; `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). +- **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, `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 `#bitcoin` or `#21gifts`, 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. - **Used by:** `createApp` default `messageStore`. @@ -1066,7 +1066,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. Published photo posts missing the public photo URL are reset and re-signed only when `PUBLIC_BASE_URL` is set and `sats` is 0 (zapped rows keep `eventId` so receipts still resolve). Pending URL-less photo notes are EVENT'd as-is so a reset cannot renew the sign lease and starve fan-out. An empty API base leaves them alone. Sign looks up photo bytes even when `hasPhoto` is stale. Signed unpaid notes whose kind:1 content lacks `#bitcoin` or `#21gifts` are reset and re-signed; zapped rows keep `eventId`. 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 (`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. - **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. diff --git a/src/__tests__/lib/message-store.test.ts b/src/__tests__/lib/message-store.test.ts index 474351b4..22d4e91e 100644 --- a/src/__tests__/lib/message-store.test.ts +++ b/src/__tests__/lib/message-store.test.ts @@ -353,6 +353,7 @@ describe('InMemoryMessageStore', () => { eventId: '11'.repeat(32), nostrEvent: null, }); + await store.updatePublishState('n', 'published', 'space'); await store.create({ ...EARLY, id: 'z', @@ -360,6 +361,7 @@ describe('InMemoryMessageStore', () => { eventId: '22'.repeat(32), nostrEvent: { content: 1 }, }); + await store.updatePublishState('z', 'published', 'space'); const tiedAt = new Date('2026-08-15T00:00:00.000Z'); await store.create({ ...EARLY, @@ -369,6 +371,7 @@ describe('InMemoryMessageStore', () => { eventId: '33'.repeat(32), nostrEvent: { content: 'only bitcoin\n\n#bitcoin' }, }); + await store.updatePublishState('q', 'published', 'space'); await store.create({ ...EARLY, id: 'p', @@ -377,6 +380,7 @@ describe('InMemoryMessageStore', () => { eventId: '44'.repeat(32), nostrEvent: { content: 'only 21gifts\n\n#21gifts' }, }); + await store.updatePublishState('p', 'published', 'space'); await store.create( { ...EARLY, @@ -391,6 +395,13 @@ describe('InMemoryMessageStore', () => { }, jpeg, ); + await store.create({ + ...EARLY, + id: 'pend', + createdAt: new Date('2026-06-01T00:00:00.000Z'), + eventId: '66'.repeat(32), + nostrEvent: { content: 'pending without hashtags' }, + }); expect((await store.listSignedMissingHashtags(10)).map((row) => row.id)).toEqual([ 'n', 'a', @@ -923,6 +934,7 @@ describe('PostgresMessageStore', () => { expect(missing[0]?.id).toBe('m1'); const listSql = sql.queries.at(-1)?.text ?? ''; expect(listSql).toMatch(/sats = 0/); + expect(listSql).toMatch(/nostr_publish_state = 'published'/); expect(listSql).toMatch(/jsonb_typeof\(nostr_event->'content'\) IS DISTINCT FROM 'string'/); expect(listSql).toMatch(/NOT LIKE '%#21gifts%'/); expect(listSql).toMatch(/NOT LIKE '%#bitcoin%'/); diff --git a/src/__tests__/lib/nostr/worker.test.ts b/src/__tests__/lib/nostr/worker.test.ts index bd3d0f95..2101a649 100644 --- a/src/__tests__/lib/nostr/worker.test.ts +++ b/src/__tests__/lib/nostr/worker.test.ts @@ -249,6 +249,54 @@ describe('runNostrWorkerTick', () => { }); await messages.updatePublishState('m-hashtag-zapped', 'published', 'space'); await messages.addSats('m-hashtag-zapped', 21); + const tick = deps({ + messages, + auth, + kek: KEK, + publisher: new RecordingPublisher(), + now: () => 1_700_000_000_000, + env: {}, + }); + await runNostrWorkerTick(tick); + expect((await messages.getById('m-hashtag'))?.eventId).toBeNull(); + await runNostrWorkerTick(tick); + const unpaid = await messages.getById('m-hashtag'); + expect(unpaid?.eventId).not.toBe('ab'.repeat(32)); + expect(String(unpaid?.nostrEvent?.['content'])).toContain('#bitcoin'); + expect(String(unpaid?.nostrEvent?.['content'])).toContain('#21gifts'); + expect(String(unpaid?.nostrEvent?.['content'])).toContain('ohne foto funktioniert es'); + const zapped = await messages.getById('m-hashtag-zapped'); + expect(zapped?.eventId).toBe('cd'.repeat(32)); + expect(zapped?.nostrEvent?.['content']).toBe('ohne foto funktioniert es'); + expect(zapped?.sats).toBe(21); + }); + + it('signs a new post before resetting published notes that lack Damus hashtags', async () => { + const { auth, messages } = await seed(); + const tags = [ + ['t', 'bitcoin'], + ['t', '21gifts'], + ['r', 'https://21.gifts'], + ]; + for (let i = 0; i < 20; i += 1) { + const id = `old-${String(i).padStart(2, '0')}`; + await messages.create({ + id, + accountId: 'acc', + name: 'Ada', + text: `old ${i}`, + createdAt: new Date(Date.UTC(2026, 0, 1, 0, 0, i)), + hasPhoto: false, + ...unsignedNostrDefaults(), + }); + await messages.updateSignedEvent(id, id.padEnd(64, 'a'), { + kind: 1, + content: `old ${i}`, + tags, + created_at: 1, + }); + await messages.updatePublishState(id, 'published', 'space'); + } await runNostrWorkerTick( deps({ messages, @@ -259,15 +307,39 @@ describe('runNostrWorkerTick', () => { env: {}, }), ); - const unpaid = await messages.getById('m-hashtag'); - expect(unpaid?.eventId).not.toBe('ab'.repeat(32)); - expect(String(unpaid?.nostrEvent?.['content'])).toContain('#bitcoin'); - expect(String(unpaid?.nostrEvent?.['content'])).toContain('#21gifts'); - expect(String(unpaid?.nostrEvent?.['content'])).toContain('ohne foto funktioniert es'); - const zapped = await messages.getById('m-hashtag-zapped'); - expect(zapped?.eventId).toBe('cd'.repeat(32)); - expect(zapped?.nostrEvent?.['content']).toBe('ohne foto funktioniert es'); - expect(zapped?.sats).toBe(21); + expect((await messages.getById('m1'))?.eventId).toMatch(/^[0-9a-f]{64}$/); + expect((await messages.getById('old-00'))?.eventId).toBeNull(); + }); + + it('EVENTs pending notes that lack Damus hashtags instead of resetting them', async () => { + const { auth, messages } = await seed(); + const tags = [ + ['t', 'bitcoin'], + ['t', '21gifts'], + ['r', 'https://21.gifts'], + ]; + const eventId = 'ab'.repeat(32); + await messages.updateSignedEvent('m1', eventId, { + kind: 1, + content: 'Das ist ein hashtag test v2', + tags, + created_at: 1, + }); + const publisher = new RecordingPublisher(); + const env = { NOSTR_PUBLISH: '1', NOSTR_RELAY_SPACE: 'wss://relay.nostr.space' }; + await runNostrWorkerTick( + deps({ + messages, + auth, + kek: KEK, + publisher, + now: () => 1_700_000_000_000, + env, + }), + ); + expect((await messages.getById('m1'))?.eventId).toBe(eventId); + expect((await messages.getById('m1'))?.nostrPublishState).toBe('published'); + expect(publisher.calls.some((call) => call.event['kind'] === 1)).toBe(true); }); it('re-signs pending rows whose stored event has no tag array', async () => { diff --git a/src/lib/message-store.ts b/src/lib/message-store.ts index 32442153..e8871c91 100644 --- a/src/lib/message-store.ts +++ b/src/lib/message-store.ts @@ -122,9 +122,11 @@ export interface MessageStore { listSignedMissingPhoto(limit: number): Promise; /** - * Signed rows whose kind:1 content lacks `#21gifts` or `#bitcoin` (case-insensitive). - * Any publish state, `sats = 0` only. Oldest `createdAt` then `id` first. - * Includes `nostrEvent === null` and non-string content. + * Published rows whose kind:1 content lacks `#21gifts` or `#bitcoin` (case-insensitive). + * `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. Includes `nostrEvent === null` and + * non-string content. * * @param limit - Max rows. */ @@ -489,7 +491,11 @@ export class InMemoryMessageStore implements MessageStore { listSignedMissingHashtags(limit: number): Promise { const rows = this.#rows .filter( - (row) => row.eventId !== null && row.sats === 0 && kind1MissingHashtags(row.nostrEvent), + (row) => + row.eventId !== null && + row.sats === 0 && + row.nostrPublishState === 'published' && + kind1MissingHashtags(row.nostrEvent), ) .sort((left, right) => { const byTime = left.createdAt.getTime() - right.createdAt.getTime(); @@ -858,6 +864,7 @@ export class PostgresMessageStore implements MessageStore { `SELECT ${MESSAGE_SELECT_COLUMNS} FROM message WHERE event_id IS NOT NULL AND sats = 0 + AND nostr_publish_state = 'published' AND ( nostr_event IS NULL OR jsonb_typeof(nostr_event->'content') IS DISTINCT FROM 'string' diff --git a/src/lib/nostr/worker.ts b/src/lib/nostr/worker.ts index d8750152..1273a5a1 100644 --- a/src/lib/nostr/worker.ts +++ b/src/lib/nostr/worker.ts @@ -1,5 +1,6 @@ import type { AuthStore } from '@/lib/auth/store'; import type { FetchFn } from '@/lib/lnurlp'; +import type { MessageRow } from '@/lib/message'; import type { MessageStore } from '@/lib/message-store'; import { logEvent } from '@/lib/log'; import { @@ -102,15 +103,13 @@ function reservedContent( * when `NOSTR_PUBLISH_PUBLIC=1`. Space ACK with public off is terminal * `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. Signed photo posts - * whose kind:1 lacks the public photo URL are reset and re-signed when - * they are already published, `PUBLIC_BASE_URL` is set, and `sats === 0`. - * Pending rows are EVENT'd even without the URL — resetting them first - * renews the sign lease and they never reach a relay. Zapped rows keep - * their event id so receipts still resolve. An empty API base leaves - * them alone so it cannot un-publish and loop. Signed unpaid notes whose - * kind:1 content lacks `#bitcoin` or `#21gifts` are reset and re-signed; - * zapped rows keep `eventId`. When publishing, also fans out a replaceable + * `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 @@ -126,9 +125,9 @@ export async function runNostrWorkerTick(deps: NostrWorkerDeps): Promise { const writeSet = resolveWriteSet(deps.env); const nowMs = deps.now(); await resignLegacyKind1Tags(deps); + await signBatch(deps, nowMs); await resignPhotoKind1(deps); await resignHashtagKind1(deps); - await signBatch(deps, nowMs); if (writeSet.publishEnabled) { await publishProfiles(deps, writeSet); await publishRelayLists(deps, writeSet); @@ -160,21 +159,21 @@ async function resignLegacyKind1Tags(deps: NostrWorkerDeps): Promise { } } +async function resetPublishedBatch(deps: NostrWorkerDeps, rows: MessageRow[]): Promise { + for (const row of rows) { + await deps.messages.resetSignedEvent(row.id, row.eventId); + } +} + async function resignPhotoKind1(deps: NostrWorkerDeps): Promise { if (resolvePublicApiBase(deps.env) === '') { return; } - const rows = await deps.messages.listSignedMissingPhoto(WORKER_BATCH); - for (const row of rows) { - await deps.messages.resetSignedEvent(row.id, row.eventId); - } + await resetPublishedBatch(deps, await deps.messages.listSignedMissingPhoto(WORKER_BATCH)); } async function resignHashtagKind1(deps: NostrWorkerDeps): Promise { - const rows = await deps.messages.listSignedMissingHashtags(WORKER_BATCH); - for (const row of rows) { - await deps.messages.resetSignedEvent(row.id, row.eventId); - } + await resetPublishedBatch(deps, await deps.messages.listSignedMissingHashtags(WORKER_BATCH)); } function kind1HasBitcoinTag(event: Record | null): boolean { From 2b81f885aadb613c34bd690c45d599a558393888 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Sun, 30 Aug 2026 17:38:35 +0200 Subject: [PATCH 12/12] Return author's-wallet copy when a forum zap invoice is impossible (#79) noZap and not_zap now 400 with a stable English string so the app can tell the payer the author's wallet cannot receive the payment, without showing a QR. Unreachable LNURL still uses the generic start-payment error. Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- SPEC.md | 21 +++++++++++++-------- docs/handbook/endpoints.md | 4 ++-- docs/handbook/functions.md | 4 ++-- src/__tests__/routes/messages.test.ts | 16 ++++++++++++++-- src/routes/messages.ts | 9 +++++++-- 5 files changed, 38 insertions(+), 16 deletions(-) diff --git a/SPEC.md b/SPEC.md index b0aab062..98ce2982 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1345,13 +1345,16 @@ Body `{ "sats": }`. The api signs a NIP-57 zap request with zap-request JSON (`isNip57Invoice`). LNURL success with a non-NIP-57 invoice (plaintext description, missing/mismatched `description_hash`, or malformed BOLT11) → persist `not_zap` (with rejected `pr` for debug) and **400** -`{ "error": "Could not start the Bitcoin payment" }` with **no** `pr` in the -body. It does **not** increment `sats` (that happens when a validated kind:9735 -receipt is indexed). After auth, every attempt with a valid UUID is persisted -best-effort to `message_invoice` (result, HTTP status, `pr`, description vs -`description_hash`, `isNip57Invoice`). Store failures log -`message.invoice.record_failed` and do not change the HTTP response. A -non-UUID `:id` is **404** without a persist row. +`{ "error": "The author's wallet cannot receive this Bitcoin payment" }` with +**no** `pr` in the body. LNURL `noZap` (author wallet does not advertise zap +receive) → same author's-wallet **400** (persist `noZap`, `pr` null). Other +LNURL/zap transport failures (`unreachable`) → **400** +`{ "error": "Could not start the Bitcoin payment" }`. It does **not** increment +`sats` (that happens when a validated kind:9735 receipt is indexed). After auth, +every attempt with a valid UUID is persisted best-effort to `message_invoice` +(result, HTTP status, `pr`, description vs `description_hash`, +`isNip57Invoice`). Store failures log `message.invoice.record_failed` and do +not change the HTTP response. A non-UUID `:id` is **404** without a persist row. Success → **Response** `200`: @@ -1367,7 +1370,9 @@ Unknown id → **404** `{ "error": "Not found" }`. Unsigned note, author without Over-limit → **429** `{ "error": "Too many payments" }` (`Retry-After: 10`) — checked only after auth, amount, payable, and KEK checks succeed, so early 400/404/401/503 do not consume quota. LNURL/zap or sign failure after the -limiter still counts. LNURL/zap failure (including non-NIP-57 invoice) → +limiter still counts. Author-wallet zap failure (`noZap` or `not_zap`) → +**400** `{ "error": "The author's wallet cannot receive this Bitcoin payment" }`. +Other LNURL/zap failure (`unreachable`) → **400** `{ "error": "Could not start the Bitcoin payment" }`. Keygen/sign failure → **503** `{ "error": "Messages are unavailable" }`. diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 87819c11..3b2ffad3 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -240,8 +240,8 @@ ## Endpoint: POST /messages/:id/invoice -- **Purpose:** Bearer required. `:id` is a UUID. Body `{ sats }` (integer 1..10_000_000). Builds a NIP-57 kind:9734 zap request for the note, signs it with the payer's custodial key (ensuring one exists when KEK is present), and returns `{ pr, amountSats }` only when the minted BOLT11 is a NIP-57 `description_hash` invoice (`isNip57Invoice`); otherwise persists `not_zap` (with rejected `pr` for debug) and responds 400 without `pr` in the body. After auth, valid-UUID attempts are persisted best-effort (`message_invoice`); persist failures do not change the HTTP response. The invoice rate limit is applied only after auth, amount, payable, and KEK checks (NIP-57 reject still counts, same as other LNURL failures). -- **Errors:** 401 Unauthorized; 400 bad body / note not yet payable / Could not start the Bitcoin payment (LNURL failure or non-NIP-57 invoice); 404 Not found (unknown id or non-UUID `:id`, the latter without a persist row); 429 Too many payments (`Retry-After: 10`, after payable checks); 503 Messages are unavailable (missing KEK before limiter, or keygen/sign failure after). +- **Purpose:** Bearer required. `:id` is a UUID. Body `{ sats }` (integer 1..10_000_000). Builds a NIP-57 kind:9734 zap request for the note, signs it with the payer's custodial key (ensuring one exists when KEK is present), and returns `{ pr, amountSats }` only when the minted BOLT11 is a NIP-57 `description_hash` invoice (`isNip57Invoice`); otherwise persists `not_zap` (with rejected `pr` for debug) and responds 400 `The author's wallet cannot receive this Bitcoin payment` without `pr` in the body. Same author's-wallet 400 for LNURL `noZap`; other LNURL transport failures (`unreachable`) keep `Could not start the Bitcoin payment`. After auth, valid-UUID attempts are persisted best-effort (`message_invoice`); persist failures do not change the HTTP response. The invoice rate limit is applied only after auth, amount, payable, and KEK checks (NIP-57 reject still counts, same as other LNURL failures). +- **Errors:** 401 Unauthorized; 400 bad body / This message cannot be paid yet / The author's wallet cannot receive this Bitcoin payment (`noZap`, `not_zap`) / Could not start the Bitcoin payment (`unreachable` and other LNURL transport failures); 404 Not found (unknown id or non-UUID `:id`, the latter without a persist row); 429 Too many payments (`Retry-After: 10`, after payable checks); 503 Messages are unavailable (missing KEK before limiter, or keygen/sign failure after). - **Used by:** App pay sheet for forum notes. - **Auth:** `Authorization: Bearer` session. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index be6a6031..57cca663 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -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 + persist `not_zap`; 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`, `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). - **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 / non-NIP-57 invoice; 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 / 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). - **Used by:** `createApp`. ## Function: contactRoutes diff --git a/src/__tests__/routes/messages.test.ts b/src/__tests__/routes/messages.test.ts index 86898223..056b12a6 100644 --- a/src/__tests__/routes/messages.test.ts +++ b/src/__tests__/routes/messages.test.ts @@ -1333,7 +1333,9 @@ describe('POST /messages/:id/invoice', () => { }); expect(res.status).toBe(400); const body = (await res.json()) as Record; - expect(body).toEqual({ error: 'Could not start the Bitcoin payment' }); + expect(body).toEqual({ + error: "The author's wallet cannot receive this Bitcoin payment", + }); expect(body).not.toHaveProperty('pr'); const attempts = await messageStore.listInvoiceAttempts(10); expect(attempts).toHaveLength(1); @@ -1412,7 +1414,11 @@ describe('POST /messages/:id/invoice', () => { body: JSON.stringify({ sats: 21 }), }); expect(res.status).toBe(400); - expect(await res.json()).toEqual({ error: 'Could not start the Bitcoin payment' }); + const body = (await res.json()) as Record; + expect(body).toEqual({ + error: "The author's wallet cannot receive this Bitcoin payment", + }); + expect(body).not.toHaveProperty('pr'); const attempts = await messageStore.listInvoiceAttempts(10); expect(attempts).toHaveLength(1); expect(attempts[0]?.result).toBe('not_zap'); @@ -1484,6 +1490,9 @@ describe('POST /messages/:id/invoice', () => { }, ); expect(noZapRes.status).toBe(400); + expect(await noZapRes.json()).toEqual({ + error: "The author's wallet cannot receive this Bitcoin payment", + }); expect((await messageStore.listInvoiceAttempts(1))[0]?.result).toBe('noZap'); expect((await messageStore.listInvoiceAttempts(1))[0]?.pr).toBeNull(); expect((await messageStore.listInvoiceAttempts(1))[0]?.httpStatus).toBe(400); @@ -1510,6 +1519,9 @@ describe('POST /messages/:id/invoice', () => { }, ); expect(unreachableRes.status).toBe(400); + expect(await unreachableRes.json()).toEqual({ + error: 'Could not start the Bitcoin payment', + }); const attempts = await messageStore.listInvoiceAttempts(2); expect(attempts.some((row) => row.result === 'unreachable')).toBe(true); expect(attempts.find((row) => row.result === 'unreachable')?.pr).toBeNull(); diff --git a/src/routes/messages.ts b/src/routes/messages.ts index b5647ae7..d7e073e3 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -33,6 +33,9 @@ import { bearerToken } from '@/routes/me'; /** Placeholder author id when the message/author is unknown at persist time. */ const UNKNOWN_ACCOUNT_ID = '00000000-0000-0000-0000-000000000000'; +/** 400 body when the author's LNURL cannot mint a forum-creditable zap (`noZap` / `not_zap`). */ +const AUTHOR_WALLET_CANNOT_RECEIVE = "The author's wallet cannot receive this Bitcoin payment"; + /** * Persist an invoice attempt without failing the HTTP payment response. * @@ -520,7 +523,6 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { zapRequestJson, fetchImpl, }); - /* v8 ignore next 3 -- LNURL/zap collapsed failure */ if (!zap.ok) { await persistInvoiceAttempt( deps.store, @@ -540,6 +542,9 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { isNip57Invoice: false, }), ); + if (zap.reason === 'noZap') { + return c.json({ error: AUTHOR_WALLET_CANNOT_RECEIVE }, 400); + } return c.json({ error: 'Could not start the Bitcoin payment' }, 400); } const inspected = inspectBolt11(zap.pr); @@ -565,7 +570,7 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { isNip57Invoice: false, }), ); - return c.json({ error: 'Could not start the Bitcoin payment' }, 400); + return c.json({ error: AUTHOR_WALLET_CANNOT_RECEIVE }, 400); } await persistInvoiceAttempt( deps.store,