diff --git a/CONCEPT.md b/CONCEPT.md index ca5f98fa..2575a949 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) @@ -236,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**; 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: @@ -680,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 5693ecf9..ad706435 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,8 +38,11 @@ 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) +│ │ ├── 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) @@ -53,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) @@ -60,14 +68,14 @@ 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 │ │ ├── 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 @@ -122,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 @@ -147,6 +160,9 @@ api/ │ ├── messages.test.ts │ ├── 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 @@ -155,8 +171,9 @@ 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 +│ ├── push.sql # push_subscription + push_outbox │ └── db_change.sql # append-only row-change log ├── scripts/ │ ├── check-handbook.mjs # CI gate: missing heading → exit 1 @@ -256,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`; @@ -269,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 @@ -292,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` @@ -300,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 @@ -348,23 +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`, `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 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_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). | +| 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 a0f8d949..fee1bf62 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. --- @@ -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). @@ -150,11 +151,12 @@ 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 -notes; the worker fans out when `NOSTR_PUBLISH=1`. Pay-on-note is +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. Private donor↔receiver DMs (NIP-17) are **out of v1** (CONCEPT deferred). Do @@ -173,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 e87d3a81..98ce2982 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 (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=`). --- @@ -55,39 +55,46 @@ 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` | Bearer | 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`) | +| 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 | `/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) | +| POST | `/invoices/proof` | Bearer `SPEND_API_TOKEN` | Accept payment preimage as proof | ### `GET /healthz` @@ -152,8 +159,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: @@ -175,7 +185,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` @@ -392,6 +403,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 @@ -611,6 +629,40 @@ 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, 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`: + +```json +{ + "accounts": [ + { + "name": "Ada", + "lightningAddress": "guest@walletofsatoshi.com", + "viewKey": "<64 lowercase hex>", + "created": true + } + ] +} +``` + +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` Operator assignment of the account's forum display role. Authenticated with @@ -707,6 +759,203 @@ 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" } +``` + +Store failure → **Response** `503`: + +```json +{ "error": "Messages are unavailable" } +``` + +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`, `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, except `not_zap` which stores the rejected BOLT11 (`pr` set, +`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" } +``` + +Store failure → **Response** `503`: + +```json +{ "error": "Messages are unavailable" } +``` + +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 /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`. @@ -1024,7 +1273,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`: @@ -1088,10 +1338,23 @@ 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 +**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": "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`: @@ -1100,28 +1363,25 @@ 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). 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. 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" }`. ### `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 +1397,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/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 fc774b04..3b2ffad3 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 **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. + ## 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. @@ -35,6 +42,48 @@ - **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. 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 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 + +- **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; 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. + +## 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. @@ -51,16 +100,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 @@ -149,10 +198,38 @@ ## 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`, `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 @@ -163,8 +240,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 }` (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. @@ -185,7 +262,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 1054479c..57cca663 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. @@ -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 @@ -149,9 +156,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; `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 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`. 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 @@ -170,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. `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. `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`. `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). `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. @@ -186,7 +193,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 @@ -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`. +- **Used by:** `debugRoutes`, `debugContactsRoutes`, `debugPaymentsRoutes`, `debugPushRoutes`. ## Function: compareAccountsForList @@ -219,9 +226,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, 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 @@ -231,16 +238,135 @@ - **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: InMemoryGiftStore +## Function: debugPaymentsRoutes -- **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:** 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). 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 + +- **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` 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` for the NIP-57 gate (reject before returning `pr`). + +## Function: resolveVapidConfig + +- **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 -- **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` (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`. @@ -259,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`. @@ -359,9 +492,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 @@ -387,8 +520,8 @@ ## 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. -- **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. @@ -422,7 +555,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`. @@ -436,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; `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). +- **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 ("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 @@ -604,9 +737,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 @@ -646,10 +779,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 @@ -742,18 +882,39 @@ - **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. +- **Inputs:** API origin, message id, optional MIME (default JPEG). +- **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. 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. ## 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 +929,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 +971,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 +1066,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, 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. -- **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 @@ -914,16 +1089,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. -- **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. +- **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`. ## Function: requestZapInvoice 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/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/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 4ed5517f..1e577098 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, }) => { @@ -347,11 +357,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 }) => { @@ -390,6 +400,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' }, @@ -423,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); @@ -502,6 +590,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); @@ -636,6 +733,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 }, @@ -742,9 +853,20 @@ 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); }); +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 +896,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..2c141836 100644 --- a/e2e/http.spec.ts +++ b/e2e/http.spec.ts @@ -116,14 +116,30 @@ 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('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 }) => { @@ -174,6 +190,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' }, @@ -290,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 cdcd89c6..ae53d953 100644 --- a/scripts/check-handbook.mjs +++ b/scripts/check-handbook.mjs @@ -133,8 +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/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..5bcb3481 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,82 @@ 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('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); + 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 }]; @@ -188,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 () => { @@ -513,6 +595,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 +660,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..038668a2 100644 --- a/src/__tests__/lib/auth/store.test.ts +++ b/src/__tests__/lib/auth/store.test.ts @@ -620,6 +620,198 @@ 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('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 = { + 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__/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/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/message-store.test.ts b/src/__tests__/lib/message-store.test.ts index 06772695..22d4e91e 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); }); }); @@ -230,6 +236,200 @@ 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, + ); + 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', + 'p', + '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', + '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.jpg', + }); + 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('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.updatePublishState('n', 'published', 'space'); + await store.create({ + ...EARLY, + id: 'z', + createdAt: new Date('2026-09-01T00:00:00.000Z'), + 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, + id: 'q', + text: 'only bitcoin', + createdAt: tiedAt, + eventId: '33'.repeat(32), + nostrEvent: { content: 'only bitcoin\n\n#bitcoin' }, + }); + await store.updatePublishState('q', 'published', 'space'); + await store.create({ + ...EARLY, + id: 'p', + text: 'only 21gifts', + createdAt: tiedAt, + eventId: '44'.repeat(32), + nostrEvent: { content: 'only 21gifts\n\n#21gifts' }, + }); + await store.updatePublishState('p', 'published', 'space'); + 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, + ); + 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', + '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); @@ -287,6 +487,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', () => { @@ -598,9 +885,310 @@ 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(/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'/); + expect(sql.executes.at(-1)?.text).toMatch(/event_id IS NOT DISTINCT FROM/); + 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(/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%'/); + 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'); 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/event.test.ts b/src/__tests__/lib/nostr/event.test.ts index e2ce91e8..ce74c58d 100644 --- a/src/__tests__/lib/nostr/event.test.ts +++ b/src/__tests__/lib/nostr/event.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from 'vitest'; import { + KIND0_PICTURE_URL, buildKind0Content, buildKind0Event, buildKind1Event, buildKind10002Event, + forumPhotoUrl, + kind1ContentWithHashtags, + kind1HasHashtag, kind1Tags, } from '@/lib/nostr/event'; @@ -11,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'], @@ -19,6 +23,47 @@ 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.jpg', + mime: 'image/jpeg', + }); + 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', + '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.png', + mime: 'image/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); + }); }); describe('kind0', () => { @@ -27,7 +72,17 @@ 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.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/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/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 b8f798e6..2101a649 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'); @@ -149,7 +151,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 +172,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 +192,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 +209,139 @@ 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); + 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, + auth, + kek: KEK, + publisher: new RecordingPublisher(), + now: () => 1_700_000_000_000, + env: {}, + }), + ); + 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 () => { const { auth, messages } = await seed(); await messages.updateSignedEvent('m1', 'ef'.repeat(32), { kind: 1, content: 'hello' }); @@ -314,10 +452,476 @@ 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.jpg\n\n#bitcoin #21gifts', + ); + 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.jpg', '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.png'); + }); + + 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: '#bitcoin #21gifts', + 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('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', + bytes: new Uint8Array([0xff, 0xd8, 0xff, 0xd9]), + }; + await messages.create( + { + id: 'm-stale', + accountId: 'acc', + name: 'Ada', + text: 'hallo', + createdAt: new Date('2026-08-28T00:05:00.000Z'), + hasPhoto: true, + ...unsignedNostrDefaults(), + }, + jpeg, + ); + await messages.updateSignedEvent('m-stale', 'ab'.repeat(32), { + kind: 1, + id: 'ab'.repeat(32), + content: 'hallo', + tags: [ + ['t', 'bitcoin'], + ['t', '21gifts'], + ['r', 'https://21.gifts'], + ], + }); + messages.listSignedMissingPhoto = async () => []; + messages.listSignedMissingHashtags = 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-missing-bytes', + accountId: 'acc', + name: 'Ada', + text: 'hallo', + createdAt: new Date('2026-08-28T00:10:00.000Z'), + hasPhoto: true, + ...unsignedNostrDefaults(), + }, + { contentType: 'image/jpeg', bytes: new Uint8Array([0xff, 0xd8, 0xff, 0xd9]) }, + ); + 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, + 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-missing-bytes'); + expect(row?.eventId).toMatch(/^[0-9a-f]{64}$/); + expect(row?.nostrEvent?.['content']).toBe('hallo\n\n#bitcoin #21gifts'); + expect(row?.nostrPublishState).toBe('published'); + }); + + 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 () => []; + messages.listSignedMissingHashtags = 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('#bitcoin #21gifts'); + expect(row?.nostrPublishState).toBe('published'); + expect( + publisher.calls.some( + (call) => + call.event['kind'] === 1 && + call.event['content'] === '#bitcoin #21gifts' && + 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 +950,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 () => { @@ -1398,6 +2007,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 e20ffeeb..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(), @@ -431,6 +432,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 +469,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 +514,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 +844,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 +872,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 +1036,90 @@ 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), + listSignedMissingHashtags: (limit: number) => base.listSignedMissingHashtags(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 +1266,159 @@ 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(); + }); + + 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/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-payments.test.ts b/src/__tests__/routes/debug-payments.test.ts new file mode 100644 index 00000000..0e0f44a2 --- /dev/null +++ b/src/__tests__/routes/debug-payments.test.ts @@ -0,0 +1,181 @@ +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); + }); + + 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/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/debug.test.ts b/src/__tests__/routes/debug.test.ts index e5d24a06..de412663 100644 --- a/src/__tests__/routes/debug.test.ts +++ b/src/__tests__/routes/debug.test.ts @@ -195,4 +195,541 @@ 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 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 { + 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 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; + 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: '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 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 returns 500 when the name-only update does not persist the request name', 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, + }; + } + 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', + 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(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 () => { + 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 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) { + this.#lookups += 1; + if (this.#lookups === 1) { + return undefined; + } + const acc = await super.getAccountByLightningAddress(address); + 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(); + 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); + 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(racedBody.accounts[0]?.created).toBe(false); + expect(racedBody.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/__tests__/routes/messages.test.ts b/src/__tests__/routes/messages.test.ts index 7dc65af6..056b12a6 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 @@ -13,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(() => { @@ -89,11 +101,18 @@ function throwingStore(overrides: Partial = {}): MessageStore { claimUnsigned: boom, claimUnpublished: boom, listPendingSigned: boom, + listSignedMissingPhoto: boom, + listSignedMissingHashtags: boom, clearSignedEvent: boom, + resetSignedEvent: boom, updateSignedEvent: boom, updatePublishState: boom, addSats: boom, recordZapReceipt: boom, + recordInvoiceAttempt: boom, + listInvoiceAttempts: boom, + recordZapIngest: boom, + listZapIngests: boom, ...overrides, }; } @@ -359,6 +378,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'); @@ -479,7 +571,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); }); @@ -619,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); }); @@ -724,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 () => { @@ -739,7 +835,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 }), @@ -747,6 +843,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', @@ -856,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']; @@ -949,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 () => { @@ -1047,50 +1173,769 @@ describe('POST /messages/:id/invoice', () => { }); expect(res.status).toBe(404); }); -}); - -describe('GET /messages/:id/photo', () => { - it('returns 401 without an Authorization header', 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' }); - }); - 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' }); + 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, + }); + const nip57Spy = vi.spyOn(bolt11, 'isNip57Invoice').mockReturnValue(true); + 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]?.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('returns 404 for a non-UUID id without calling the store', async () => { - const getPhoto = vi.fn(async () => { - throw new Error('boom'); + 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', }); - 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' }); - expect(getPhoto).not.toHaveBeenCalled(); - expect(parsedEvents(warn).some((e) => e['event'] === 'messages.photo.failed')).toBe(false); + 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: "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'); + 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('returns 503 and logs when getPhoto throws', async () => { - const res = await mount( - await seededStore(), - throwingStore({ - listLatest: async () => [], - create: async (row) => row, - }), - ).request('/messages/00000000-0000-0000-0000-000000000000/photo', { - headers: AUTH, - }); + 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); + 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'); + 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'); + 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 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); + + 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); + 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(); + }); + + 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), + listSignedMissingHashtags: (limit) => base.listSignedMissingHashtags(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(), + }), + ); + 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, + ); + }); + }); + + 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(), + }), + ); + 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(); + }); + } finally { + spy.mockRestore(); + } + }); +}); + +describe('GET /messages/:id/photo', () => { + 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(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(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', + ); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'Photo not found' }); + }); + + it('returns 404 for a non-UUID id without calling the store', async () => { + const getPhoto = vi.fn(async () => { + throw new Error('boom'); + }); + const res = await mount(await seededStore(), throwingStore({ getPhoto })).request( + '/messages/not-a-uuid/photo', + ); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'Photo not found' }); + expect(getPhoto).not.toHaveBeenCalled(); + expect(parsedEvents(warn).some((e) => e['event'] === 'messages.photo.failed')).toBe(false); + }); + + it('returns 503 and logs when getPhoto throws', async () => { + const res = await mount( + await seededStore(), + throwingStore({ + listLatest: async () => [], + create: async (row) => row, + }), + ).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/__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/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..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 @@ -170,6 +185,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 +311,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..c46feb45 100644 --- a/src/lib/auth/store.ts +++ b/src/lib/auth/store.ts @@ -122,12 +122,22 @@ 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; + /** + * 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; /** @@ -135,6 +145,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 +187,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 +255,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 +276,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 && @@ -264,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) { @@ -286,6 +336,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 +421,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/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/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/message-store.ts b/src/lib/message-store.ts index 8a5e7e1c..e8871c91 100644 --- a/src/lib/message-store.ts +++ b/src/lib/message-store.ts @@ -14,8 +14,25 @@ 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 { + if (event === null) { + return true; + } + const content = event['content']; + 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; @@ -94,6 +111,36 @@ export interface MessageStore { */ clearSignedEvent(id: string, expectedEventId: string | null): Promise; + /** + * 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. + */ + listSignedMissingPhoto(limit: number): Promise; + + /** + * 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. + */ + 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. + * + * @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, @@ -117,6 +164,65 @@ 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' + | 'not_zap' + | '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`). */ @@ -147,6 +253,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)`, ]; /** @@ -176,6 +319,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 @@ -185,6 +346,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 @@ -306,6 +469,55 @@ 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 && + row.nostrPublishState === 'published' && + 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); + } + + listSignedMissingHashtags(limit: number): Promise { + const rows = this.#rows + .filter( + (row) => + row.eventId !== null && + row.sats === 0 && + row.nostrPublishState === 'published' && + 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) { + row.eventId = null; + row.nostrEvent = null; + row.claimedUntil = null; + row.nostrPublishState = 'pending'; + row.nostrPublishEpoch = null; + } + return Promise.resolve(); + } + updateSignedEvent( id: string, eventId: string, @@ -353,6 +565,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, @@ -598,6 +842,51 @@ 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_publish_state = 'published' + 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 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_publish_state = 'published' + 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, + 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, @@ -651,6 +940,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. * @@ -675,3 +1041,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/event.ts b/src/lib/nostr/event.ts index 7a399508..2cdc4b85 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,16 +16,101 @@ 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'; + +/** 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'; +} + +/** + * 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. + * @param mime - Stored type (defaults to JPEG). + * @returns `GET /messages/:id/photo.jpg` (or `.png` / `.webp`) URL. + */ +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}). */ 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[][]; @@ -36,17 +121,30 @@ 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. + * @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}`]); + } + body = kind1ContentWithHashtags(body); return { kind: 1, - content, - tags: kind1Tags(), + content: body, + tags, created_at: createdAtUnix, }; } @@ -59,6 +157,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 +166,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 +178,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..1273a5a1 100644 --- a/src/lib/nostr/worker.ts +++ b/src/lib/nostr/worker.ts @@ -1,14 +1,28 @@ 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 { 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'; +import type { PushStore } from '@/lib/push-store'; /** Max rows claimed or keyed profile attempts per tick. */ export const WORKER_BATCH = 20; @@ -42,6 +56,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 = { @@ -87,10 +103,16 @@ 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. 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 * `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 @@ -104,8 +126,11 @@ export async function runNostrWorkerTick(deps: NostrWorkerDeps): Promise { const nowMs = deps.now(); await resignLegacyKind1Tags(deps); await signBatch(deps, nowMs); + await resignPhotoKind1(deps); + await resignHashtagKind1(deps); if (writeSet.publishEnabled) { await publishProfiles(deps, writeSet); + await publishRelayLists(deps, writeSet); await publishBatch(deps, writeSet, nowMs); } const urls = resolveZapRelays(deps.env); @@ -118,6 +143,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 }), }); } @@ -133,6 +159,23 @@ 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; + } + await resetPublishedBatch(deps, await deps.messages.listSignedMissingPhoto(WORKER_BATCH)); +} + +async function resignHashtagKind1(deps: NostrWorkerDeps): Promise { + await resetPublishedBatch(deps, await deps.messages.listSignedMissingHashtags(WORKER_BATCH)); +} + function kind1HasBitcoinTag(event: Record | null): boolean { if (event === null) { return false; @@ -159,8 +202,24 @@ 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 (apiBase !== '') { + const storedPhoto = await deps.messages.getPhoto(row.id); + if (storedPhoto !== null) { + photo = { + url: forumPhotoUrl(apiBase, row.id, storedPhoto.contentType), + 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) { - 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 +243,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 +308,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) { diff --git a/src/lib/nostr/zap-index.ts b/src/lib/nostr/zap-index.ts index f8006d37..b6aafd01 100644 --- a/src/lib/nostr/zap-index.ts +++ b/src/lib/nostr/zap-index.ts @@ -3,10 +3,12 @@ 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'; +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. */ @@ -57,6 +59,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 +135,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; } @@ -111,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; @@ -145,6 +273,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), + }), + ); + } } } } @@ -164,6 +308,7 @@ async function ingestOneReceipt( now: () => number; fetchImpl: FetchFn; verifyReceipt: (event: NostrEventFrame) => boolean; + pushStore?: PushStore; }, ): Promise { if (event.kind !== 9735) { @@ -173,10 +318,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 +359,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 +397,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 +453,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,16 +476,38 @@ 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; } - await indexZapReceipt({ + const indexed = await indexZapReceipt({ store: args.store, messageId: row.id, receipt: { id: event.id, pubkey: event.pubkey, tags: event.tags }, providerPubkey, amountSats, + 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/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-payments.ts b/src/routes/debug-payments.ts new file mode 100644 index 00000000..d8433338 --- /dev/null +++ b/src/routes/debug-payments.ts @@ -0,0 +1,108 @@ +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); + } + 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); + } + 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/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/debug.ts b/src/routes/debug.ts index 967fa7ac..2100eda7 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,93 @@ 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 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: named.name, + lightningAddress: named.lightningAddress ?? row.lightningAddress, + viewKey: named.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 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: named.name, + lightningAddress: named.lightningAddress ?? row.lightningAddress, + viewKey: named.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/routes/messages.ts b/src/routes/messages.ts index 97886cbd..d7e073e3 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -2,7 +2,8 @@ import { Hono } from 'hono'; import { z } from 'zod'; import { resolveSession } from '@/lib/auth/service'; import type { Account, AuthStore } from '@/lib/auth/store'; -import { GIFT_INVOICE_MAX_MSAT, GIFT_INVOICE_MIN_MSAT } from '@/lib/config'; +import { inspectBolt11, isNip57Invoice } from '@/lib/bolt11'; +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'; @@ -15,18 +16,87 @@ 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'; 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. */ +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. + * + * @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, 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. */ @@ -45,6 +115,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(); @@ -65,6 +137,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({ @@ -85,10 +195,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`. + * @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`. */ export function messagesRoutes(deps: MessagesRouteDeps): Hono { const postLimiter = deps.postLimiter ?? defaultPostLimiter; @@ -162,74 +274,208 @@ 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'); return c.json({ error: 'Messages are unavailable' }, 503); } }) - .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); - } - 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': 'private', - }, - }); - } 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) { 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 */ - if (amountMsat < GIFT_INVOICE_MIN_MSAT || amountMsat > GIFT_INVOICE_MAX_MSAT) { + if (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')); + 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); @@ -246,18 +492,104 @@ 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, + }), + ); + 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); + 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: AUTHOR_WALLET_CANNOT_RECEIVE }, 400); + } + 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: true, + }), + ); return c.json({ pr: zap.pr, amountSats: zap.amountSats }, 200); }); } 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 2f107a1b..ee63513d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -17,6 +17,9 @@ 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 { 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'; @@ -26,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'; @@ -68,8 +73,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`, `POST /debug/accounts`, + * `PATCH /debug/accounts/:id`, `GET /debug/contacts`, `GET /debug/invoices`, + * and `GET /debug/zap-ingests` return 503. */ debugToken?: string; /** @@ -116,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; } /** @@ -125,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 { @@ -147,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(); @@ -171,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( @@ -193,6 +214,17 @@ 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( @@ -202,6 +234,7 @@ export function createApp(deps: AppDeps = {}): Hono { authStore: store, now, fetchImpl, + pushStore, ...(nostrKek === undefined ? {} : { nostrKek }), }), );