From ec4aee82cd8314f2dc59df70a4cd44fd5cc313f0 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Wed, 9 Sep 2026 11:55:05 +0200 Subject: [PATCH] 01a083c7 - Require a Lightning Address before forum posts (#102) * Require a Lightning Address before forum posts POST /messages now 409s unless the author has a Lightning Address. Profile notes wait until that address is linked. * Align profile-note docs with the Lightning Address gate Identity copy and the worker handbook now require a Lightning Address before a profile forum note is created. --------- Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- CONCEPT.md | 1 + CONTRIBUTING.md | 2 +- FLOWS.md | 19 +++-- SPEC.md | 45 +++++----- docs/handbook/endpoints.md | 8 +- docs/handbook/functions.md | 12 +-- .../lib/auth/profile-message.test.ts | 24 +++++- src/__tests__/lib/auth/requirements.test.ts | 21 ++++- src/__tests__/lib/nostr/worker.test.ts | 2 +- src/__tests__/routes/me.test.ts | 72 ++++++++++++++-- src/__tests__/routes/messages.test.ts | 84 ++++++++++++------- src/lib/auth/profile-message.ts | 21 +++-- src/lib/auth/requirements.ts | 4 +- src/lib/nostr/worker.ts | 5 +- src/routes/me.ts | 16 +++- 15 files changed, 241 insertions(+), 95 deletions(-) diff --git a/CONCEPT.md b/CONCEPT.md index 76b9f61..b3e9f18 100644 --- a/CONCEPT.md +++ b/CONCEPT.md @@ -696,6 +696,7 @@ repository — they're intentionally not part of this project's scope. | 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 }`. | +| 2026-09-09 | `forum.post` requires a non-blank Lightning Address in addition to rules + name (skip timestamps still do not satisfy). `POST /messages` 409 `missing_requirements` includes `lightning-address` when it is factually missing. `ensureProfileMessage` no-ops without LN; linking LN after a name creates the profile note. `contact.post`, `forum.read`, and `forum.pay` unchanged; existing message rows are not deleted. | --- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4c4abaf..bfd4f5f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -96,7 +96,7 @@ api/ │ │ ├── account-json.ts # Public account JSON (no nsec) │ │ ├── account-setup.ts # Next owner setup step + factual missing fields │ │ ├── requirements.ts # Action→fields gates (`requireAction`) -│ │ ├── profile-message.ts # First-name profile forum note (`ensureProfileMessage`) +│ │ ├── profile-message.ts # Profile forum note when name + LN set (`ensureProfileMessage`) │ │ ├── hex.ts # CSPRNG hex tokens │ │ ├── passkey.ts # WebAuthn register/authenticate domain logic │ │ ├── service.ts # Session issuance and bearer resolution diff --git a/FLOWS.md b/FLOWS.md index a31aa08..d3b04c2 100644 --- a/FLOWS.md +++ b/FLOWS.md @@ -94,13 +94,14 @@ any pending verification (`SPEC.md`). ### Identity copy — **Shipped** (name) + **Sketch** (photo / story) -Receiver name is stored on the account (`POST /me/name`). The first persisted -non-empty name also creates exactly one top-level profile forum note; rename -does not create a second note or change its text. Other members read live -identity plus that note via `GET /members/:accountId` (Bearer; rules required). -Photo and story beyond that note stay custodial `kind:0` metadata signed -server-side (`about` is the profile-note text when present, else `21.gifts`). -**Do not invent** `POST /me/profile`. +Receiver name is stored on the account (`POST /me/name`). The profile forum +note is created when a non-blank name and a non-blank Lightning Address are +present (`POST /me/name` no-ops without LN; `POST /me/lightning-address` +creates it). Rename does not create a second note or change its text. Other +members read live identity plus that note via `GET /members/:accountId` +(Bearer; rules required). Photo and story beyond that note stay custodial +`kind:0` metadata signed server-side (`about` is the profile-note text when +present, else `21.gifts`). **Do not invent** `POST /me/profile`. ### View-key link — **Shipped** @@ -156,8 +157,8 @@ HTTP that exists today is only the spend-worker invoice pair above (`SPEC.md`). Public comment / encouragement is a v1 surface. The composer POSTs `{ text }` and/or `{ photo: { contentType, data } }` to `POST /messages` -(requires rules + name; Lightning Address is not required to post — missing -requirements are **409** `missing_requirements`); +(requires rules + name + Lightning Address — missing requirements are +**409** `missing_requirements`); the public thread is listed via `GET /messages` (requires rules; newest first, name snapshotted at post, `sats`, `payable`, `hasPhoto`, and live author `role` — never photo bytes). Bytes are public `GET /messages/:id/photo` (Nostr `imeta`). The shipped UI diff --git a/SPEC.md b/SPEC.md index 88ad55e..26b6a7b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -71,7 +71,7 @@ Public base URLs used in examples: | GET | `/me` | `Authorization: Bearer` | Account (`setup` + factual `missing`) | | GET | `/view/:viewKey` | none | Public profile card by view key | | POST | `/me/setup/skip` | Bearer | Skip name or Lightning Address wizard step | -| POST | `/me/name` | Bearer | Set/replace display name (first name creates profile note) | +| POST | `/me/name` | Bearer | Set/replace display name (profile note when name + LN are both set) | | 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 + NIP-57 mint probe | @@ -80,7 +80,7 @@ Public base URLs used in examples: | POST | `/me/lightning-address/verification/confirm` | Bearer | Confirm nonce from wallet history | | GET | `/members/:accountId` | Bearer | Live member identity + profile note | | GET | `/messages` | Bearer | List top-level forum notes (+ `replyCount`); 409 if rules missing | -| POST | `/messages` | Bearer | Post text/photo; 409 if rules/name missing; LN not required to post | +| POST | `/messages` | Bearer | Post text/photo; 409 if rules/name/Lightning Address missing | | GET | `/messages/:id` | none | Public single-note JSON | | GET | `/messages/:id/replies` | Bearer | Oldest-first replies for a parent note | | GET | `/messages/:id/photo` | none | Fetch forum message photo bytes | @@ -388,10 +388,13 @@ control / DEL character (`charCode < 32` or `=== 127`) → **Response** `400`: ``` Success → **Response** `200` with the updated account (same shape as -`GET /me`). The stored value is trimmed. Names are not unique. The first -persisted non-empty name also creates exactly one top-level profile forum -note and stores `profileMessageId` (not on owner JSON). Rename does not -create a second note and does not change the note text. +`GET /me`). The stored value is trimmed. Names are not unique. When a +non-blank Lightning Address is already linked, the first persisted +non-empty name also creates exactly one top-level profile forum note and +stores `profileMessageId` (not on owner JSON). Without a Lightning +Address the name is stored and no profile note is inserted (linking the +address later creates it). Rename does not create a second note and does +not change the note text. ### `POST /me/forum-laws-dismissed` @@ -481,8 +484,10 @@ Another account already owns the address (including a unique-index race) 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 -in this step — use `POST /me/lightning-address/verification` for that. +pending verification for the account is cleared. After the address is +stored, `ensureProfileMessage` runs so a non-blank display name that was +set earlier gets its profile forum note. There is no proof-of-control in +this step — use `POST /me/lightning-address/verification` for that. ### `DELETE /me/lightning-address` @@ -1485,15 +1490,15 @@ is not in the store, or a parent that is itself a reply (`parentId` not null) → **404** `{ "error": "Not found" }`. Multipart video posts do not accept `inReplyTo` (they are always top-level). -After auth, `requireAction(account, 'forum.post')` requires rules agreement -and a non-blank display name (Lightning Address is **not** required to post). -The api stores a **name snapshot** (trimmed account name at post time), -normalised text (possibly `""` for photo-only), optional JPEG/PNG/WebP bytes -(≤ 1 MiB; MIME from magic bytes), `parentId` (null for top-level notes), and a -timestamp. Text longer than **500** after trim, or with disallowed C0/DEL -controls, is rejected. Newlines (`\n`, `\r`) are allowed. The **200** body -is the public message object itself (not wrapped in `{ messages }`), -including `sats`, `payable`, `hasPhoto`, `hasVideo`, and +After auth, `requireAction(account, 'forum.post')` requires rules agreement, +a non-blank display name, and a non-blank Lightning Address (skip timestamps +do not satisfy). The api stores a **name snapshot** (trimmed account name at +post time), normalised text (possibly `""` for photo-only), optional +JPEG/PNG/WebP bytes (≤ 1 MiB; MIME from magic bytes), `parentId` (null for +top-level notes), and a timestamp. Text longer than **500** after trim, or +with disallowed C0/DEL controls, is rejected. Newlines (`\n`, `\r`) are +allowed. The **200** body is the public message object itself (not wrapped +in `{ messages }`), including `sats`, `payable`, `hasPhoto`, `hasVideo`, and `videoContentType`. May include `accountId` (21gifts author id). No `replyCount`, and no photo or video bytes in the JSON. `sats` is 0 and `payable` is false until the worker signs the note (and stays false without @@ -1513,10 +1518,12 @@ Missing/invalid/expired bearer → **Response** `401`: Missing required fields → **Response** `409`: ```json -{ "error": "missing_requirements", "missing": ["rules", "name"] } +{ "error": "missing_requirements", "missing": ["rules", "name", "lightning-address"] } ``` -(`missing` is never empty; order is `rules`, then `name`.) +(`missing` is never empty; order is `rules`, then `name`, then +`lightning-address`. A named, rules-agreed account with null LN yields +`["lightning-address"]` only.) Body is not JSON with `text` and/or `photo` → **Response** `400`: diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 8494990..c2b6877 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -303,8 +303,8 @@ ## Endpoint: POST /messages -- **Purpose:** Bearer required. After auth, `requireAction(account, 'forum.post')` (needs rules + name; Lightning Address is not required). JSON `{ text?, photo?: { contentType, data }, inReplyTo? }` (base64 JPEG/PNG/WebP ≤ 1 MiB) or `multipart/form-data` with `text`, `video` (MP4/WebM/MOV ≤ 32 MiB), and optional JPEG/PNG/WebP `poster`. Optional `inReplyTo` is a **top-level** parent message UUID (sets `parentId` for a one-level NIP-10 reply; JSON only). Text-only stays valid; photo-only or video-only allowed; at least one of non-empty trimmed text, photo, or video required. Name snapshot. 200 is the public message including `sats`, `payable`, `hasPhoto`, `hasVideo`, `videoContentType`, the session account's live `role`, and `accountId` (not wrapped). New notes have `sats` 0 and `payable` false until signed (and stay `payable` false without author LN). Top-level creates may enqueue push; replies do not. -- **Errors:** 401 Unauthorized; 409 `{ error: 'missing_requirements', missing: [...] }` when rules and/or name are missing (order `rules`, then `name`); 400 Expected a JSON body with text and/or photo; 400 Text must be 1–500 characters; 400 Text must be 1–500 characters or include a photo; 400 Text must be 1–500 characters or include a photo or video; 400 Photo must be a JPEG, PNG, or WebP under 1 MiB; 400 Poster must be a JPEG, PNG, or WebP under 1 MiB; 400 Video must be an MP4, WebM, or MOV under 32 MiB; 404 `{ error: 'Not found' }` when `inReplyTo` is present but not a UUID, the parent is missing, or the parent is itself a reply (`parentId !== null`); 429 Too many messages (`Retry-After: 10`); 503 Messages are unavailable (`messages.create.failed`). +- **Purpose:** Bearer required. After auth, `requireAction(account, 'forum.post')` (needs rules + name + Lightning Address; skip timestamps do not satisfy). JSON `{ text?, photo?: { contentType, data }, inReplyTo? }` (base64 JPEG/PNG/WebP ≤ 1 MiB) or `multipart/form-data` with `text`, `video` (MP4/WebM/MOV ≤ 32 MiB), and optional JPEG/PNG/WebP `poster`. Optional `inReplyTo` is a **top-level** parent message UUID (sets `parentId` for a one-level NIP-10 reply; JSON only). Text-only stays valid; photo-only or video-only allowed; at least one of non-empty trimmed text, photo, or video required. Name snapshot. 200 is the public message including `sats`, `payable`, `hasPhoto`, `hasVideo`, `videoContentType`, the session account's live `role`, and `accountId` (not wrapped). New notes have `sats` 0 and `payable` false until signed (and stay `payable` false without author LN). Top-level creates may enqueue push; replies do not. +- **Errors:** 401 Unauthorized; 409 `{ error: 'missing_requirements', missing: [...] }` when rules, name, and/or Lightning Address are missing (order `rules`, then `name`, then `lightning-address`); 400 Expected a JSON body with text and/or photo; 400 Text must be 1–500 characters; 400 Text must be 1–500 characters or include a photo; 400 Text must be 1–500 characters or include a photo or video; 400 Photo must be a JPEG, PNG, or WebP under 1 MiB; 400 Poster must be a JPEG, PNG, or WebP under 1 MiB; 400 Video must be an MP4, WebM, or MOV under 32 MiB; 404 `{ error: 'Not found' }` when `inReplyTo` is present but not a UUID, the parent is missing, or the parent is itself a reply (`parentId !== null`); 429 Too many messages (`Retry-After: 10`); 503 Messages are unavailable (`messages.create.failed`). - **Used by:** App forum composer and reply composer. - **Auth:** `Authorization: Bearer` session. @@ -359,7 +359,7 @@ ## Endpoint: POST /me/lightning-address -- **Purpose:** Body `{ address }`. Live-resolves LUD-16 well-known metadata, requires zap support (`allowsNostr` + non-empty `nostrPubkey`), then runs a NIP-57 mint probe (`probeNip57Mint` with the account's custodial key). On `ok`, stores the address unverified on the account. +- **Purpose:** Body `{ address }`. Live-resolves LUD-16 well-known metadata, requires zap support (`allowsNostr` + non-empty `nostrPubkey`), then runs a NIP-57 mint probe (`probeNip57Mint` with the account's custodial key). On `ok`, stores the address unverified on the account, then runs `ensureProfileMessage` so a non-blank display name already set gets its profile forum note. - **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 well-known / missing zap metadata / unreachable probe; account unchanged); 400 `{ error: LIGHTNING_ADDRESS_NOT_ZAP }` when the mint probe returns `not_zap` (account unchanged); 503 `{ error: 'Lightning Address could not be resolved' }` when `NOSTR_NSEC_KEK` / `nostrKek` is missing or key ensure fails; 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. @@ -387,7 +387,7 @@ ## Endpoint: POST /me/name -- **Purpose:** Bearer required. Body `{ name }`. Stores the trimmed display name on the account (1–80 characters, no C0/DEL control characters). The first persisted non-empty name also creates exactly one top-level profile forum note (`ensureProfileMessage`) and stores `profileMessageId` (not exposed on owner JSON). Rename does not create a second note and does not change the note text. +- **Purpose:** Bearer required. Body `{ name }`. Stores the trimmed display name on the account (1–80 characters, no C0/DEL control characters). When a non-blank Lightning Address is already linked, the first persisted non-empty name also creates exactly one top-level profile forum note (`ensureProfileMessage`) and stores `profileMessageId` (not exposed on owner JSON); without LN the name is stored and no note is inserted. Rename does not create a second note and does not change the note text. - **Errors:** 401 without session; 400 if the body is not `{ name: string }` or the name fails validation. - **Used by:** App `setName`. - **Auth:** See Purpose — Bearer where stated, else public. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index c1123de..f71a6d1 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -583,7 +583,7 @@ ## Function: meRoutes -- **Purpose:** Authenticated account routes (`GET /`, `POST /setup/skip`, name with `ensureProfileMessage`, forum-laws dismiss, living-room rules agreement, Lightning Address link with live LNURL resolve + zap metadata check then NIP-57 mint probe `probeNip57Mint`, verification). Unlink clears `lightningAddressSkippedAt`. `POST /lightning-address` returns 409 `{ error: 'Lightning Address is already in use' }` when another account owns the address. +- **Purpose:** Authenticated account routes (`GET /`, `POST /setup/skip`, name with `ensureProfileMessage` (no-op without LN), forum-laws dismiss, living-room rules agreement, Lightning Address link with live LNURL resolve + zap metadata check then NIP-57 mint probe `probeNip57Mint` then `ensureProfileMessage`, verification). Unlink clears `lightningAddressSkippedAt`. `POST /lightning-address` returns 409 `{ error: 'Lightning Address is already in use' }` when another account owns the address. - **Inputs:** `MeRouteDeps` store, `messages`, now, payer, fetchImpl, optional `pushStore`, optional `nostrKek` (required to sign the mint probe). - **Returns / side effects:** Hono at `/me`. Owner JSON includes `setup` + `missing`. Successful `POST /lightning-address` needs zap metadata (`allowsNostr` + non-empty `nostrPubkey`) plus KEK + `ensureAccountNostrKey` + probe `ok`. Probe `not_zap` → 400 `{ error: LIGHTNING_ADDRESS_NOT_ZAP }`; probe `unreachable` (and missing zap metadata) → 400 `{ error: 'Lightning Address could not be resolved' }`; missing/malformed KEK or key ensure failure → 503 with the same resolve string (account unchanged). Logs `account.setup.skipped` with `{ accountId, step }`. - **Used by:** `createApp`. @@ -597,7 +597,7 @@ ## Function: messagesRoutes -- **Purpose:** Hono sub-app for the public member forum. After Bearer auth, `requireAction` gates `GET /` (`forum.read` → rules), `POST /` (`forum.post` → rules + name; LN not required), and `POST /:id/invoice` (`forum.pay` → payer rules only). Bearer `GET /` lists **top-level** notes only newest-first (cap 200, `hasPhoto`, `hasVideo`, `videoContentType`, `sats`, `payable`, live `role`, `replyCount`); missing-file `hasVideo` rows are deleted (`messages.video.dropped`); `POST /` creates text/photo/video; public `GET /:id` stays unauthenticated without `accountId`; Bearer `GET /:id/replies`; photo/video byte routes; invoice returns `{ pr, amountSats }` only for NIP-57 invoices (author LN / unsigned stay 400 resource errors, never 409 `lightning-address` for the payer). Optional `pushStore` enqueues on top-level create. +- **Purpose:** Hono sub-app for the public member forum. After Bearer auth, `requireAction` gates `GET /` (`forum.read` → rules), `POST /` (`forum.post` → rules + name + Lightning Address), and `POST /:id/invoice` (`forum.pay` → payer rules only). Bearer `GET /` lists **top-level** notes only newest-first (cap 200, `hasPhoto`, `hasVideo`, `videoContentType`, `sats`, `payable`, live `role`, `replyCount`); missing-file `hasVideo` rows are deleted (`messages.video.dropped`); `POST /` creates text/photo/video; public `GET /:id` stays unauthenticated without `accountId`; Bearer `GET /:id/replies`; photo/video byte routes; invoice returns `{ pr, amountSats }` only for NIP-57 invoices (author LN / unsigned stay 400 resource errors, never 409 `lightning-address` for the payer). Optional `pushStore` enqueues on top-level create. - **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/replies/invoice; 409 `{ error: 'missing_requirements', missing }` when action gates fail; 400 on bad body / invalid text / bad media / unpaid note / author's-wallet / LNURL failures; 404 for bad `inReplyTo` / missing rows; 429 rate limits; 503 on store/KEK/sign failure. Signed-in list/replies/create may include `accountId`; public `GET /:id` never includes it. - **Used by:** `createApp`. @@ -893,7 +893,7 @@ - **Purpose:** Declare which account fields an action needs before it may proceed. - **Inputs:** `AccountAction` (`forum.read` \| `forum.post` \| `contact.post` \| `forum.pay`). -- **Returns / side effects:** Readonly list in 409 order: `forum.read` → `rules`; `forum.post` / `contact.post` → `rules`, `name`; `forum.pay` → `rules`. No I/O. +- **Returns / side effects:** Readonly list in 409 order: `forum.read` → `rules`; `forum.post` → `rules`, `name`, `lightning-address`; `contact.post` → `rules`, `name`; `forum.pay` → `rules`. No I/O. - **Used by:** `requireAction`. ## Function: requireAction @@ -905,10 +905,10 @@ ## Function: ensureProfileMessage -- **Purpose:** Ensure a named account has exactly one top-level profile forum note. First non-blank name inserts one message (kind:1 pipeline defaults, frozen tags only) and stores `profileMessageId`. Rename is idempotent and does not change note text. Recreates when the stored id is missing. Rolls back the insert if `updateAccount` fails or a later write wins the live pointer. Optional `pushStore` enqueues forum pushes for a new note. +- **Purpose:** Ensure a named account with a non-blank Lightning Address has exactly one top-level profile forum note. No-ops when name or Lightning Address is null/blank after trim. When both are set, the first insert creates one message (kind:1 pipeline defaults, frozen tags only) and stores `profileMessageId`. Rename is idempotent and does not change note text. Recreates when the stored id is missing. Rolls back the insert if `updateAccount` fails or a later write wins the live pointer. Optional `pushStore` enqueues forum pushes for a new note. - **Inputs:** `{ auth, messages, account, now, pushStore? }`. - **Returns / side effects:** The account (possibly with `profileMessageId` set). May insert a message and update the account; may delete an orphaned insert on update failure, a vanished row, or a later `profileMessageId` winner. -- **Used by:** `meRoutes` (`POST /me/name`), `debugRoutes` provision, Nostr worker backfill. +- **Used by:** `meRoutes` (`POST /me/name`, `POST /me/lightning-address`), `debugRoutes` provision, Nostr worker backfill. ## Function: serializeAccount @@ -1220,7 +1220,7 @@ ## Function: runNostrWorkerTick -- **Purpose:** Sign unsigned rows; fan out when `NOSTR_PUBLISH=1`. Space-only ACK is terminal `published`/`space`. With `NOSTR_PUBLISH_PUBLIC=1`, space-only parks `pending` until a public ACK. Pending kind:1 JSON without `t=bitcoin` is dropped and re-signed, then unsigned rows are signed. After that, published unpaid notes missing a photo URL, a video URL, or Damus `#bitcoin`/`#21gifts` in content are reset for the next tick (`PUBLIC_BASE_URL` set for media URLs; video posters are not treated as missing photos; `profileMessageId` rows are skipped so a name note is not rewritten with those hashtags). Pending rows EVENT as-is so a reset cannot renew the 60s sign lease. Zapped rows keep `eventId`. An empty API base skips photo/video-URL resign. Sign looks up photo bytes even when `hasPhoto` is stale. Each tick runs `backfillProfileMessages` for named accounts missing a profile note. When publishing, also fans out kind:0 profiles (`name` / `display_name` / `picture` / optional `nip05`, `about` from the profile-note text or `21.gifts`) and NIP-65 kind:10002 relay lists. Kind:1 photo/video posts include the public media URL and `imeta`. Each tick queries zap relays (space plus the public list, even when `NOSTR_PUBLISH_PUBLIC` is off) for kind:9735 and indexes validated receipts onto `sats`, even when `NOSTR_PUBLISH` is off. Each tick also runs `signConversationBatch` (NIP-17 wraps when a conversation store is present) and, when `NOSTR_PUBLISH=1`, `publishConversationBatch`. After zap ingest, `indexInboundForumReplies` (REQ kind:1 `#e` our published note ids; persist Damus/member replies even when publish is off) and `indexInboundDirectMessages` (REQ kind:1059 / kind:4 to member and platform pubkeys when a conversation store is present). +- **Purpose:** Sign unsigned rows; fan out when `NOSTR_PUBLISH=1`. Space-only ACK is terminal `published`/`space`. With `NOSTR_PUBLISH_PUBLIC=1`, space-only parks `pending` until a public ACK. Pending kind:1 JSON without `t=bitcoin` is dropped and re-signed, then unsigned rows are signed. After that, published unpaid notes missing a photo URL, a video URL, or Damus `#bitcoin`/`#21gifts` in content are reset for the next tick (`PUBLIC_BASE_URL` set for media URLs; video posters are not treated as missing photos; `profileMessageId` rows are skipped so a name note is not rewritten with those hashtags). Pending rows EVENT as-is so a reset cannot renew the 60s sign lease. Zapped rows keep `eventId`. An empty API base skips photo/video-URL resign. Sign looks up photo bytes even when `hasPhoto` is stale. Each tick runs `backfillProfileMessages` for named accounts with a non-blank Lightning Address missing a profile note. When publishing, also fans out kind:0 profiles (`name` / `display_name` / `picture` / optional `nip05`, `about` from the profile-note text or `21.gifts`) and NIP-65 kind:10002 relay lists. Kind:1 photo/video posts include the public media URL and `imeta`. Each tick queries zap relays (space plus the public list, even when `NOSTR_PUBLISH_PUBLIC` is off) for kind:9735 and indexes validated receipts onto `sats`, even when `NOSTR_PUBLISH` is off. Each tick also runs `signConversationBatch` (NIP-17 wraps when a conversation store is present) and, when `NOSTR_PUBLISH=1`, `publishConversationBatch`. After zap ingest, `indexInboundForumReplies` (REQ kind:1 `#e` our published note ids; persist Damus/member replies even when publish is off) and `indexInboundDirectMessages` (REQ kind:1059 / kind:4 to member and platform pubkeys when a conversation store is present). - **Kind:0 cache:** Unchanged content is not resent for the life of the AuthStore instance. After the live account row is read, the worker stores a reservation object and treats only that object as owner after each await. A nack or throw deletes the reservation only when it is still that object; the last issued `created_at` watermark is kept so a retry in the same second still increments. Kind:0 `created_at` is `max(wall clock, last issued + 1)` so an in-flight older profile cannot win a same-second replaceable-event tie. - **Kind:0 batch:** At most `WORKER_BATCH` keyed attempts run per tick, including nacks. With public fan-out on, a space-only ACK is a nack and the profile is retried. - **Inputs:** worker deps. diff --git a/src/__tests__/lib/auth/profile-message.test.ts b/src/__tests__/lib/auth/profile-message.test.ts index dddc9a1..82117d6 100644 --- a/src/__tests__/lib/auth/profile-message.test.ts +++ b/src/__tests__/lib/auth/profile-message.test.ts @@ -7,6 +7,8 @@ import { InMemoryPushStore } from '@/lib/push-store'; const now = (): number => 1_700_000_000_000; +const TEST_LN = 'ada@walletofsatoshi.com'; + async function seededAccount( overrides: Partial = {}, ): Promise<{ auth: InMemoryAuthStore; account: Account }> { @@ -16,7 +18,7 @@ async function seededAccount( linkingKey: null, role: 'basis', name: 'Ada', - lightningAddress: null, + lightningAddress: TEST_LN, lightningAddressVerified: false, forumLawsDismissed: false, viewKey: 'a'.repeat(64), @@ -39,6 +41,26 @@ describe('ensureProfileMessage', () => { expect(await messages.listLatest(10)).toHaveLength(0); }); + it('returns the account without inserting when Lightning Address is blank', async () => { + const { auth, account } = await seededAccount({ lightningAddress: null }); + const messages = new InMemoryMessageStore(); + const create = vi.spyOn(messages, 'create'); + const result = await ensureProfileMessage({ auth, messages, account, now }); + expect(result.profileMessageId).toBeUndefined(); + expect(create).not.toHaveBeenCalled(); + expect(await messages.listLatest(10)).toHaveLength(0); + }); + + it('returns the account without inserting when Lightning Address is whitespace', async () => { + const { auth, account } = await seededAccount({ lightningAddress: ' ' }); + const messages = new InMemoryMessageStore(); + const create = vi.spyOn(messages, 'create'); + const result = await ensureProfileMessage({ auth, messages, account, now }); + expect(result.profileMessageId).toBeUndefined(); + expect(create).not.toHaveBeenCalled(); + expect(await messages.listLatest(10)).toHaveLength(0); + }); + it('inserts one profile note and is idempotent on rename', async () => { const { auth, account } = await seededAccount(); const messages = new InMemoryMessageStore(); diff --git a/src/__tests__/lib/auth/requirements.test.ts b/src/__tests__/lib/auth/requirements.test.ts index 9f42ed1..95464b8 100644 --- a/src/__tests__/lib/auth/requirements.test.ts +++ b/src/__tests__/lib/auth/requirements.test.ts @@ -23,7 +23,7 @@ const base: Account = { describe('actionRequirements', () => { it('lists fields for each action', () => { expect(actionRequirements('forum.read')).toEqual(['rules']); - expect(actionRequirements('forum.post')).toEqual(['rules', 'name']); + expect(actionRequirements('forum.post')).toEqual(['rules', 'name', 'lightning-address']); expect(actionRequirements('contact.post')).toEqual(['rules', 'name']); expect(actionRequirements('forum.pay')).toEqual(['rules']); }); @@ -34,6 +34,7 @@ describe('requireAction', () => { const account: Account = { ...base, name: 'Ada', + lightningAddress: 'ada@walletofsatoshi.com', rulesAgreedAt: 2, }; expect(requireAction(account, 'forum.post')).toEqual({ ok: true }); @@ -51,19 +52,31 @@ describe('requireAction', () => { expect(accountMissing(account)).toEqual(['name', 'lightning-address']); expect(requireAction(account, 'forum.post')).toEqual({ ok: false, - missing: ['name'], + missing: ['name', 'lightning-address'], }); }); - it('orders 409 missing as rules then name', () => { + it('orders 409 missing as rules, name, then lightning-address', () => { const account: Account = { ...base }; expect(requireAction(account, 'forum.post')).toEqual({ ok: false, - missing: ['rules', 'name'], + missing: ['rules', 'name', 'lightning-address'], }); expect(MISSING_REQUIREMENTS_ERROR).toBe('missing_requirements'); }); + it('returns only lightning-address when name and rules are set', () => { + const account: Account = { + ...base, + name: 'Ada', + rulesAgreedAt: 2, + }; + expect(requireAction(account, 'forum.post')).toEqual({ + ok: false, + missing: ['lightning-address'], + }); + }); + it('forum.pay only requires rules for the payer', () => { const account: Account = { ...base, diff --git a/src/__tests__/lib/nostr/worker.test.ts b/src/__tests__/lib/nostr/worker.test.ts index f5a108b..4a329e6 100644 --- a/src/__tests__/lib/nostr/worker.test.ts +++ b/src/__tests__/lib/nostr/worker.test.ts @@ -587,7 +587,7 @@ describe('runNostrWorkerTick', () => { linkingKey: null, role: 'basis', name: 'Bob', - lightningAddress: null, + lightningAddress: 'bob@walletofsatoshi.com', lightningAddressVerified: false, forumLawsDismissed: false, viewKey: 'c'.repeat(64), diff --git a/src/__tests__/routes/me.test.ts b/src/__tests__/routes/me.test.ts index 855011d..979518c 100644 --- a/src/__tests__/routes/me.test.ts +++ b/src/__tests__/routes/me.test.ts @@ -459,7 +459,7 @@ describe('POST /me/name', () => { expect(await res.json()).toEqual({ error: 'Name must be 1–80 characters' }); }); - it('trims, stores, and returns the name', async () => { + it('trims, stores, and returns the name without a profile note when LN is missing', async () => { const store = await seededStore(); const messages = new InMemoryMessageStore(); const res = await mount(store, { messages }).request('/me/name', { @@ -474,17 +474,31 @@ describe('POST /me/name', () => { expect(body).not.toHaveProperty('profileMessageId'); const stored = await store.getAccount('acc'); expect(stored?.name).toBe('Ada'); + expect(stored?.profileMessageId).toBeUndefined(); + expect(await messages.listLatest(10)).toHaveLength(0); + expect( + parsedEvents(warn).some((e) => e['event'] === 'account.name.set' && e['accountId'] === 'acc'), + ).toBe(true); + }); + + it('creates a profile note when setting a name with Lightning Address already linked', async () => { + const store = await seededStore({ lightningAddress: ADDRESS }); + const messages = new InMemoryMessageStore(); + const res = await mount(store, { messages }).request('/me/name', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Ada' }), + }); + expect(res.status).toBe(200); + const stored = await store.getAccount('acc'); expect(typeof stored?.profileMessageId).toBe('string'); const note = await messages.getById(stored!.profileMessageId!); expect(note?.text).toBe('Ada'); expect(note?.parentId).toBeNull(); - expect( - parsedEvents(warn).some((e) => e['event'] === 'account.name.set' && e['accountId'] === 'acc'), - ).toBe(true); }); - it('enqueues forum pushes when a push store is configured', async () => { - const store = await seededStore(); + it('enqueues forum pushes when a push store is configured and LN is linked', async () => { + const store = await seededStore({ lightningAddress: ADDRESS }); const messages = new InMemoryMessageStore(); const pushStore = new InMemoryPushStore(); await pushStore.upsertSubscription({ @@ -505,7 +519,7 @@ describe('POST /me/name', () => { }); it('does not create a second profile note or change its text on rename', async () => { - const store = await seededStore(); + const store = await seededStore({ lightningAddress: ADDRESS }); const messages = new InMemoryMessageStore(); const first = await mount(store, { messages }).request('/me/name', { method: 'POST', @@ -593,6 +607,50 @@ describe('POST /me/lightning-address', () => { ).toBe(true); }); + it('creates a profile note when linking Lightning Address after a name is set', async () => { + const store = await seededStore(); + const messages = new InMemoryMessageStore(); + const pushStore = new InMemoryPushStore(); + const named = await mount(store, { messages }).request('/me/name', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Ada' }), + }); + expect(named.status).toBe(200); + expect((await store.getAccount('acc'))?.profileMessageId).toBeUndefined(); + expect(await messages.listLatest(10)).toHaveLength(0); + const res = await mount(store, { messages, pushStore, fetchImpl: happyFetch() }).request( + '/me/lightning-address', + { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ address: ADDRESS }), + }, + ); + expect(res.status).toBe(200); + const stored = await store.getAccount('acc'); + expect(typeof stored?.profileMessageId).toBe('string'); + const note = await messages.getById(stored!.profileMessageId!); + expect(note?.text).toBe('Ada'); + expect(note?.parentId).toBeNull(); + }); + + it('does not create a profile note when linking Lightning Address without a name', async () => { + const store = await seededStore(); + const messages = new InMemoryMessageStore(); + const res = await mount(store, { messages, fetchImpl: happyFetch() }).request( + '/me/lightning-address', + { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ address: ADDRESS }), + }, + ); + expect(res.status).toBe(200); + expect((await store.getAccount('acc'))?.profileMessageId).toBeUndefined(); + expect(await messages.listLatest(10)).toHaveLength(0); + }); + it('returns 409 when the Lightning Address belongs to another account', async () => { const store = await seededStore(); await store.createAccount({ diff --git a/src/__tests__/routes/messages.test.ts b/src/__tests__/routes/messages.test.ts index a1e3d0b..f704677 100644 --- a/src/__tests__/routes/messages.test.ts +++ b/src/__tests__/routes/messages.test.ts @@ -88,7 +88,12 @@ async function namedStore(name: string): Promise { if (existing === undefined) { throw new Error('expected account'); } - await store.updateAccount({ ...existing, name, rulesAgreedAt: now() }); + await store.updateAccount({ + ...existing, + name, + rulesAgreedAt: now(), + lightningAddress: 'ada@walletofsatoshi.com', + }); return store; } @@ -383,7 +388,7 @@ describe('GET /messages', () => { }); it('marks a signed note without a Lightning Address as not payable', async () => { - const authStore = await namedStore('Ada'); + const authStore = await rulesStore({ name: 'Ada' }); const messageStore = new InMemoryMessageStore(); await messageStore.create({ id: 'nopay', @@ -650,14 +655,21 @@ describe('POST /messages', () => { }); it('returns 409 when posting without a name after rules and name skip', async () => { - const res = await mount(await rulesStore({ name: null, nameSkippedAt: now() })).request( - '/messages', - { - method: 'POST', - headers: { ...AUTH, 'content-type': 'application/json' }, - body: JSON.stringify({ text: 'hi' }), - }, - ); + const store = await rulesStore({ name: null, nameSkippedAt: now() }); + const existing = await store.getAccount('acc'); + expect(existing).toBeDefined(); + if (existing === undefined) { + throw new Error('expected account'); + } + await store.updateAccount({ + ...existing, + lightningAddress: 'ada@walletofsatoshi.com', + }); + const res = await mount(store).request('/messages', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'hi' }), + }); expect(res.status).toBe(409); expect(await res.json()).toEqual({ error: 'missing_requirements', @@ -678,16 +690,18 @@ describe('POST /messages', () => { }); }); - it('posts without a Lightning Address and returns payable false', async () => { - const res = await mount(await namedStore('Ada')).request('/messages', { + it('returns 409 when posting with name and rules but no Lightning Address', async () => { + const store = await rulesStore({ name: 'Ada' }); + const res = await mount(store).request('/messages', { method: 'POST', headers: { ...AUTH, 'content-type': 'application/json' }, body: JSON.stringify({ text: 'hi' }), }); - expect(res.status).toBe(200); - const body = (await res.json()) as { payable: boolean; name: string }; - expect(body.payable).toBe(false); - expect(body.name).toBe('Ada'); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ + error: 'missing_requirements', + missing: ['lightning-address'], + }); }); it('rejects invalid JSON', async () => { @@ -1449,9 +1463,8 @@ describe('POST /messages/:id/invoice', () => { expect(res.status).toBe(400); }); - it('returns 400 when the author has no Lightning Address', async () => { - const kek = new Uint8Array(32).fill(2); - const authStore = await namedStore('Ada'); + it('returns 400 no_author when the live author has no Lightning Address', async () => { + const authStore = await rulesStore({ name: 'Ada' }); const messageStore = new InMemoryMessageStore(); await messageStore.create({ id: '33333333-3333-4333-8333-333333333333', @@ -1469,7 +1482,7 @@ describe('POST /messages/:id/invoice', () => { store: messageStore, authStore, now, - nostrKek: kek, + nostrKek: new Uint8Array(32).fill(1), postLimiter: new PostRateLimiter(), invoiceLimiter: new InvoiceRateLimiter(), }), @@ -1480,6 +1493,12 @@ describe('POST /messages/:id/invoice', () => { body: JSON.stringify({ sats: 21 }), }); expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'This message cannot be paid yet' }); + const attempts = await messageStore.listInvoiceAttempts(10); + expect(attempts).toHaveLength(1); + expect(attempts[0]?.result).toBe('no_author'); + expect(attempts[0]?.httpStatus).toBe(400); + expect(attempts[0]?.pr).toBeNull(); }); it('returns 404 for an unknown message', async () => { @@ -2317,7 +2336,7 @@ describe('GET /messages/:id', () => { }); it('includes the live author role for a 21gifts note', async () => { - const authStore = await namedStore('Ada'); + const authStore = await rulesStore({ name: 'Ada' }); const messageStore = new InMemoryMessageStore(); await messageStore.create({ id: '16161616-1616-4161-8161-161616161616', @@ -2986,14 +3005,21 @@ describe('forum video', () => { it('rejects multipart when the account has no name', async () => { const form = new FormData(); form.set('text', 'clip'); - const res = await mount(await rulesStore({ name: null, nameSkippedAt: now() })).request( - '/messages', - { - method: 'POST', - headers: AUTH, - body: form, - }, - ); + const store = await rulesStore({ name: null, nameSkippedAt: now() }); + const existing = await store.getAccount('acc'); + expect(existing).toBeDefined(); + if (existing === undefined) { + throw new Error('expected account'); + } + await store.updateAccount({ + ...existing, + lightningAddress: 'ada@walletofsatoshi.com', + }); + const res = await mount(store).request('/messages', { + method: 'POST', + headers: AUTH, + body: form, + }); expect(res.status).toBe(409); expect(await res.json()).toEqual({ error: 'missing_requirements', diff --git a/src/lib/auth/profile-message.ts b/src/lib/auth/profile-message.ts index 386fc28..c470752 100644 --- a/src/lib/auth/profile-message.ts +++ b/src/lib/auth/profile-message.ts @@ -7,15 +7,16 @@ import { enqueueForumPushes } from '@/lib/push-worker'; /** * Ensure the account has exactly one top-level profile forum note when a - * non-blank display name is present. + * non-blank display name and a non-blank Lightning Address are present. * - * First persisted non-empty name inserts one kind:1-pipeline message and - * stores `profileMessageId`. Rename does not insert a second note and does - * not change the note text. A successful insert updates the account here, - * then re-reads the live row so a later writer’s `profileMessageId` wins and - * this insert is deleted. A failed insert returns the input account (name - * may still be persisted by the caller; worker backfill creates the missing - * note). + * No-ops (returns the input account, no `messages.create`) when the name or + * Lightning Address is null/blank after trim. When both are set, the first + * insert creates one kind:1-pipeline message and stores `profileMessageId`. + * Rename does not insert a second note and does not change the note text. A + * successful insert updates the account here, then re-reads the live row so a + * later writer’s `profileMessageId` wins and this insert is deleted. A failed + * insert returns the input account (name may still be persisted by the + * caller; worker backfill creates the missing note once LN is linked). * * @param args - Auth store, message store, account snapshot, clock, optional push. * @returns The account (unchanged, or with `profileMessageId` set after insert). @@ -31,6 +32,10 @@ export async function ensureProfileMessage(args: { if (trimmed === '') { return args.account; } + const ln = args.account.lightningAddress === null ? '' : args.account.lightningAddress.trim(); + if (ln === '') { + return args.account; + } const existingId = args.account.profileMessageId; if (typeof existingId === 'string' && existingId.trim() !== '') { diff --git a/src/lib/auth/requirements.ts b/src/lib/auth/requirements.ts index 54c3dc9..de2ebdd 100644 --- a/src/lib/auth/requirements.ts +++ b/src/lib/auth/requirements.ts @@ -13,14 +13,14 @@ export const MISSING_REQUIREMENTS_ERROR = 'missing_requirements'; export interface MissingRequirementsBody { /** Always {@link MISSING_REQUIREMENTS_ERROR}. */ error: typeof MISSING_REQUIREMENTS_ERROR; - /** Non-empty list in action order: `rules`, then `name`. */ + /** Non-empty list in action order (e.g. `rules`, `name`, `lightning-address`). */ missing: AccountMissingField[]; } /** Action → required fields. Order is the 409 `missing` order. */ const ACTION_NEEDS: Record = { 'forum.read': ['rules'], - 'forum.post': ['rules', 'name'], + 'forum.post': ['rules', 'name', 'lightning-address'], 'contact.post': ['rules', 'name'], 'forum.pay': ['rules'], }; diff --git a/src/lib/nostr/worker.ts b/src/lib/nostr/worker.ts index 913a20a..70f5e0d 100644 --- a/src/lib/nostr/worker.ts +++ b/src/lib/nostr/worker.ts @@ -531,8 +531,9 @@ async function signBatch(deps: NostrWorkerDeps, nowMs: number): Promise { } /** - * Create a profile forum note for named accounts that lack one (or whose - * stored id no longer points at a message row). + * Create a profile forum note for named accounts with a non-blank Lightning + * Address that lack one (or whose stored id no longer points at a message + * row). `ensureProfileMessage` no-ops without LN. * * @param deps - Auth and message stores (and optional push). */ diff --git a/src/routes/me.ts b/src/routes/me.ts index 0df57ff..61b3034 100644 --- a/src/routes/me.ts +++ b/src/routes/me.ts @@ -27,7 +27,7 @@ import { confirmVerification, startVerification } from '@/lib/verification'; export interface MeRouteDeps { /** Shared auth persistence port. */ store: AuthStore; - /** Forum persistence (profile notes on first name). */ + /** Forum persistence (profile notes when name + Lightning Address are set). */ messages: MessageStore; /** Clock returning epoch milliseconds (injected for testability). */ now: () => number; @@ -296,11 +296,23 @@ export function meRoutes(deps: MeRouteDeps): Hono { return c.json({ error: 'Lightning Address is already in use' }, 409); } await deps.store.deleteVerification(current.id); + await ensureProfileMessage({ + auth: deps.store, + messages: deps.messages, + account: stored, + now: deps.now, + ...(deps.pushStore === undefined ? {} : { pushStore: deps.pushStore }), + }); + const live = await deps.store.getAccount(current.id); + /* v8 ignore next 3 -- the account row cannot vanish mid-request after auth */ + if (live === null || live === undefined) { + return c.json({ error: 'Unauthorized' }, 401); + } logEvent('account.lightning_address.linked', { accountId: account.id, address, }); - return c.json(serializeOwnerAccount(stored), 200); + return c.json(serializeOwnerAccount(live), 200); }) .delete('/lightning-address', async (c) => { const account = await authedAccount(deps, c.req.header('authorization'));