diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 867d682e..2283bf8e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,7 +35,8 @@ api/ │ │ ├── info.ts # GET /info │ │ ├── brand.ts # GET /favicon.ico, /favicon.svg, /apple-touch-icon.png │ │ ├── auth.ts # Passkey: /auth/passkey/register|authenticate begin/finish -│ │ ├── me.ts # GET /me; POST /me/name; POST /me/forum-laws-dismissed; POST /me/rules-agreement; link/unlink + address verification +│ │ ├── me.ts # GET /me; POST /me/setup/skip; POST /me/name; POST /me/forum-laws-dismissed; POST /me/rules-agreement; link/unlink + address verification +│ │ ├── members.ts # GET /members/:accountId (Bearer; live identity + profile note) │ │ ├── view.ts # GET /view/:viewKey (public profile card) │ │ ├── lightning-address.ts # GET /lightning-address (public LUD-16 resolve) │ │ ├── debug.ts # GET/POST /debug/accounts; PATCH /debug/accounts/:id; POST /debug/accounts/:id/session (DEBUG_TOKEN) @@ -93,7 +94,9 @@ api/ │ │ ├── nostr/ # Custodial nsec, kind:0/1/10002 worker, NIP-17/kind:4 DMs, NIP-57 zap, write-set relays │ │ └── auth/ │ │ ├── account-json.ts # Public account JSON (no nsec) -│ │ ├── account-setup.ts # Next owner setup step (name, Lightning Address, rules) +│ │ ├── 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`) │ │ ├── hex.ts # CSPRNG hex tokens │ │ ├── passkey.ts # WebAuthn register/authenticate domain logic │ │ ├── service.ts # Session issuance and bearer resolution @@ -152,6 +155,8 @@ api/ │ │ └── auth/ │ │ ├── account-json.test.ts │ │ ├── account-setup.test.ts +│ │ ├── requirements.test.ts +│ │ ├── profile-message.test.ts │ │ ├── hex.test.ts │ │ ├── passkey.test.ts │ │ ├── service.test.ts @@ -167,6 +172,7 @@ api/ │ ├── brand.test.ts │ ├── auth.test.ts │ ├── me.test.ts +│ ├── members.test.ts │ ├── lightning-address.test.ts │ ├── debug.test.ts │ ├── stats.test.ts diff --git a/FLOWS.md b/FLOWS.md index f6d90248..a31aa086 100644 --- a/FLOWS.md +++ b/FLOWS.md @@ -47,10 +47,12 @@ Login is passkey-only. LNURL-auth has been removed. The signed-in view currently lives on `/login` — there is no separate `/profile` route yet. It shows a name form, a Lightning Address form, and -**Sign out**. +**Sign out**. Name and Lightning Address are each skippable via +`POST /me/setup/skip`; living-room rules stay required. -After name and address, the app records living-room rules agreement via -`POST /me/rules-agreement`. `GET /me` carries `rulesAgreedAt` (epoch ms of +After name/skip and address/skip, the app records living-room rules agreement +via `POST /me/rules-agreement`. `GET /me` carries `setup` (wizard; skip counts +as done), `missing` (facts; skip does not), and `rulesAgreedAt` (epoch ms of the first agreement, or `null`). No email, no password. Losing the passkey (and platform sync) loses the @@ -58,7 +60,7 @@ account. HTTP cited: `/auth/passkey/register/begin`, `/auth/passkey/register/finish`, `/auth/passkey/authenticate/begin`, `/auth/passkey/authenticate/finish`, -`/me`, `/me/name`, `/me/rules-agreement`. +`/me`, `/me/setup/skip`, `/me/name`, `/me/rules-agreement`. --- @@ -73,7 +75,8 @@ or unlink a LUD-16 Lightning Address: resolve that requires zap metadata (`allowsNostr` + `nostrPubkey`). Always leaves the address **unverified**. Unreachable or non-zap addresses are rejected and not stored. -- `DELETE /me/lightning-address` — unlink +- `DELETE /me/lightning-address` — unlink (also clears the LN skip timestamp + so `setup` returns to `lightning-address` when a name is set or name-skipped) Proof-of-control of the linked Lightning Address is the flag `lightningAddressVerified` (not the forum role **Verified**): @@ -91,9 +94,13 @@ any pending verification (`SPEC.md`). ### Identity copy — **Shipped** (name) + **Sketch** (photo / story) -Receiver name is stored on the account (`POST /me/name`). Photo and story -will become custodial `kind:0` metadata signed server-side. **No HTTP for -photo/story yet**. Do not invent `POST /me/profile`. +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`. ### View-key link — **Shipped** @@ -148,8 +155,10 @@ HTTP that exists today is only the spend-worker invoice pair above (`SPEC.md`). ## 5. Message — **Shipped** 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 +`{ 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`); +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 is a messenger-group thread: oldest notes at the top, newest at the bottom, diff --git a/SPEC.md b/SPEC.md index e6a4a79e..ae74f5fd 100644 --- a/SPEC.md +++ b/SPEC.md @@ -66,17 +66,19 @@ Public base URLs used in examples: | POST | `/auth/passkey/register/finish` | none | Verify attestation, issue session | | POST | `/auth/passkey/authenticate/begin` | none | Issue WebAuthn request options | | POST | `/auth/passkey/authenticate/finish` | none | Verify assertion, issue session | -| GET | `/me` | `Authorization: Bearer` | Account (`setup` next onboarding step) | +| GET | `/me` | `Authorization: Bearer` | Account (`setup` + factual `missing`) | | GET | `/view/:viewKey` | none | Public profile card by view key | -| POST | `/me/name` | Bearer | Set/replace display name | +| 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/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 | -| DELETE | `/me/lightning-address` | Bearer | Unlink address | +| DELETE | `/me/lightning-address` | Bearer | Unlink address (clears LN skip) | | 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 top-level forum notes (+ `replyCount`) | -| POST | `/messages` | Bearer | Post text/photo; optional one-level `inReplyTo` parent UUID | +| 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 | | 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 | @@ -239,12 +241,13 @@ ID). "viewKey": "<64-hex>", "createdAt": 0, "rulesAgreedAt": null, - "setup": "name" + "setup": "name", + "missing": ["name", "lightning-address", "rules"] } } ``` -The `account` object is the same owner JSON as `GET /me` (includes `viewKey` and `setup`). +The `account` object is the same owner JSON as `GET /me` (includes `viewKey`, `setup`, and `missing`). ### `POST /auth/passkey/authenticate/begin` @@ -288,23 +291,47 @@ Missing or invalid bearer → **Response** `401`: "viewKey": "<64-hex>", "createdAt": 0, "rulesAgreedAt": null, - "setup": "name" + "setup": "name", + "missing": ["name", "lightning-address", "rules"] } ``` -| Field | Type | Meaning | -| -------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | string | Opaque account id | -| `linkingKey` | string \| null | Historical LNURL-auth linking key (hex), or `null` for passkey accounts | -| `role` | string | `basis`, `verified`, `moderator`, or `founder` | -| `name` | string \| null | Display name, or `null` until set | -| `lightningAddress` | string \| null | Linked LUD-16 address, or `null` | -| `lightningAddressVerified` | boolean | Proof-of-control flag (`true` only after confirm) | -| `forumLawsDismissed` | boolean | `true` after the welcome-forum living-room laws hint was dismissed | -| `viewKey` | string | Durable 64 lowercase hex capability secret for GET /view/:viewKey. Owner-only. Not a session. | -| `createdAt` | number | Creation time (epoch ms) | -| `rulesAgreedAt` | number \| null | Epoch ms of first living-room rules agreement, or `null` | -| `setup` | string \| null | Next owner step: `name`, `lightning-address`, `rules`, or `null` when complete. Computed here; clients must not invent a parallel sequence. | +| Field | Type | Meaning | +| -------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `id` | string | Opaque account id | +| `linkingKey` | string \| null | Historical LNURL-auth linking key (hex), or `null` for passkey accounts | +| `role` | string | `basis`, `verified`, `moderator`, or `founder` | +| `name` | string \| null | Display name, or `null` until set | +| `lightningAddress` | string \| null | Linked LUD-16 address, or `null` | +| `lightningAddressVerified` | boolean | Proof-of-control flag (`true` only after confirm) | +| `forumLawsDismissed` | boolean | `true` after the welcome-forum living-room laws hint was dismissed | +| `viewKey` | string | Durable 64 lowercase hex capability secret for GET /view/:viewKey. Owner-only. Not a session. | +| `createdAt` | number | Creation time (epoch ms) | +| `rulesAgreedAt` | number \| null | Epoch ms of first living-room rules agreement, or `null` | +| `setup` | string \| null | Next wizard step: `name`, `lightning-address`, `rules`, or `null` when complete. Skip timestamps count as done. Clients must not invent a parallel sequence. | +| `missing` | string[] | Factually unset fields (`name`, `lightning-address`, `rules`) even when skipped. Does not include `profileMessageId`. | + +### `POST /me/setup/skip` + +Skip a skippable wizard step. Body: + +```json +{ "step": "name" } +``` + +or `{ "step": "lightning-address" }`. Sets the matching skip timestamp to now; +does not clear `name` / `lightningAddress`. `step: "rules"` and unknown steps +are **400**. Success → **200** owner JSON. + +### `GET /members/:accountId` + +Bearer required. `:accountId` must be a UUID. After auth, +`requireAction(caller, 'forum.read')` — missing rules → **409** +`{ "error": "missing_requirements", "missing": ["rules"] }`. Unknown id → +**404**. Store throw → **503** `{ "error": "Messages are unavailable" }`. +Success → live `id` / `name` / `role` / `lightningAddress` / ISO +`createdAt` plus `profileMessage` (`serializeMessage` with `accountId` / +`replyCount`, or `null`). Never `viewKey` / `eventId`. ### `GET /view/:viewKey` @@ -358,7 +385,10 @@ 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. +`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. ### `POST /me/forum-laws-dismissed` @@ -1333,7 +1363,8 @@ Success → **Response** `200`: ### `GET /messages` -Public member forum thread. Bearer session required. Returns **only +Public member forum thread. Bearer session required. After auth, +`requireAction(account, 'forum.read')` (rules). Returns **only top-level notes** (`parent_id IS NULL`) newest first (`createdAt` descending, then `id`), capped at **200**. Replies are never listed here — use `GET /messages/:id/replies`. This is the latest-200 **window** on the @@ -1357,6 +1388,12 @@ Missing/invalid/expired bearer → **Response** `401`: { "error": "Unauthorized" } ``` +Missing rules → **Response** `409`: + +```json +{ "error": "missing_requirements", "missing": ["rules"] } +``` + Store failure → **Response** `503`: ```json @@ -1419,21 +1456,22 @@ 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). -The account must already have a non-blank display name. 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 +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 `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. `role` is the posting -session account's live `account.role`. Web Push is enqueued **only** when -`parentId` is null (top-level notes); replies do not push. 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 +`payable` is false until the worker signs the note (and stays false without +author LN). `role` is the posting session account's live `account.role`. Web +Push is enqueued **only** when `parentId` is null (top-level notes); replies +do not push. 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 (content includes Damus-visible `#bitcoin` and `#21gifts`; forum `text` stays the member's words) and fans out when `NOSTR_PUBLISH=1`. @@ -1443,16 +1481,18 @@ Missing/invalid/expired bearer → **Response** `401`: { "error": "Unauthorized" } ``` -Body is not JSON with `text` and/or `photo` → **Response** `400`: +Missing required fields → **Response** `409`: ```json -{ "error": "Expected a JSON body with text and/or photo" } +{ "error": "missing_requirements", "missing": ["rules", "name"] } ``` -Account has no display name (null or blank after trim) → **Response** `400`: +(`missing` is never empty; order is `rules`, then `name`.) + +Body is not JSON with `text` and/or `photo` → **Response** `400`: ```json -{ "error": "Set a name before posting" } +{ "error": "Expected a JSON body with text and/or photo" } ``` Text longer than 500 after trim, or contains a disallowed control → @@ -1533,6 +1573,7 @@ Success → **Response** `200`: ``` Missing Bearer → **401** `{ "error": "Unauthorized" }`. +Payer missing living-room rules → **409** `{ "error": "missing_requirements", "missing": ["rules"] }`. 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 → @@ -1731,10 +1772,10 @@ Body is not JSON with a `text` string → **Response** `400`: { "error": "Expected a JSON body with a \"text\" string" } ``` -Account has no display name (null or blank after trim) → **Response** `400`: +Missing required fields (`requireAction` `contact.post`) → **Response** `409`: ```json -{ "error": "Set a name before posting" } +{ "error": "missing_requirements", "missing": ["rules", "name"] } ``` Text empty, longer than 500 after trim, or contains a disallowed control → diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index d80360de..512b1177 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -2,7 +2,7 @@ ## Endpoint: DELETE /me/lightning-address -- **Purpose:** Bearer required. Clears the account Lightning Address. +- **Purpose:** Bearer required. Clears the account Lightning Address, resets `lightningAddressVerified` to false, and clears `lightningAddressSkippedAt` so owner `setup` returns to `lightning-address` when a name is set or name-skipped. - **Errors:** 401 without session. - **Used by:** `unlinkLightningAddress` in the app. - **Auth:** See Purpose — Bearer where stated, else public. @@ -219,11 +219,18 @@ ## Endpoint: GET /me -- **Purpose:** Bearer session. Current account JSON (id, linkingKey, role, name, lightning address, verified flag, forumLawsDismissed, `createdAt`, `rulesAgreedAt`, owner `viewKey`, `setup`). `setup` is the next owner step (`name` \| `lightning-address` \| `rules`) or `null` when complete; computed here so clients do not invent a parallel sequence. +- **Purpose:** Bearer session. Current owner account JSON (id, linkingKey, role, name, lightning address, verified flag, forumLawsDismissed, `createdAt`, `rulesAgreedAt`, owner `viewKey`, `setup`, `missing`). `setup` is the next wizard step (`name` \| `lightning-address` \| `rules`) or `null` when complete; skip timestamps count as done for the wizard. `missing` lists factually unset fields (`name`, `lightning-address`, `rules`) even when skipped. Does not expose `profileMessageId`. - **Errors:** 401 if missing/expired. - **Used by:** App `fetchMe`. - **Auth:** See Purpose — Bearer where stated, else public. +## Endpoint: GET /members/:accountId + +- **Purpose:** Bearer required. Live member profile card for `:accountId` (UUID): `id`, `name`, `role`, `lightningAddress`, ISO `createdAt`, and `profileMessage` (`serializeMessage` with `accountId` / `replyCount` like the signed-in forum list, or `null` when no note). Never includes `viewKey`, linkingKey, npub, nsec, or `eventId`. +- **Errors:** 401 without session; 409 `{ error: 'missing_requirements', missing: [...] }` when `requireAction(caller, 'forum.read')` fails; 404 `{ error: 'Not found' }` for a non-UUID id or unknown account; 503 `{ error: 'Messages are unavailable' }` when a store throws (`members.get.failed`). +- **Used by:** App member profile surfaces. +- **Auth:** `Authorization: Bearer` session. + ## Endpoint: GET /view/:viewKey - **Purpose:** Public capability URL. Read-only profile card (`name`, `lightningAddress`, `lightningAddressVerified`, `createdAt`, `hasPasskey`). `hasPasskey` is true when the account already has a passkey credential. No auth. Not a session. @@ -233,8 +240,8 @@ ## Endpoint: GET /messages -- **Purpose:** Bearer required. Lists **top-level** forum notes only (`parent_id` null) newest-first (author name snapshotted at post, `text`, ISO `createdAt`, `sats`, `payable`, `hasPhoto`, `hasVideo`, `videoContentType`, live author `role`, and `replyCount`), capped at 200 (latest-200 window). A `hasVideo` row whose file is missing or empty is deleted and omitted. For each kept top-level note, missing-file `hasVideo` direct replies in the replies window (cap 200) are deleted (`messages.video.dropped`); `replyCount` is the stored direct-reply count minus those dropped. Replies are never listed here. Clients render chronological messenger-group order (oldest top, newest bottom above the composer). Empty list is 200 `{ messages: [] }`. No photo/video bytes in JSON; signed-in list may include `accountId` (21gifts author id; omitted for Damus-only); `payable` is true when the note has an `eventId` and the author has a Lightning Address; missing author → `role` `"basis"` and `payable` false. `videoContentType` is `null` when `hasVideo` is false. -- **Errors:** 401 `{ error: 'Unauthorized' }` missing/invalid/expired bearer; 503 `{ error: 'Messages are unavailable' }` if the store throws (`messages.list.failed`). +- **Purpose:** Bearer required. After auth, `requireAction(account, 'forum.read')` (needs rules). Lists **top-level** forum notes only (`parent_id` null) newest-first (author name snapshotted at post, `text`, ISO `createdAt`, `sats`, `payable`, `hasPhoto`, `hasVideo`, `videoContentType`, live author `role`, and `replyCount`), capped at 200 (latest-200 window). A `hasVideo` row whose file is missing or empty is deleted and omitted. For each kept top-level note, missing-file `hasVideo` direct replies in the replies window (cap 200) are deleted (`messages.video.dropped`); `replyCount` is the stored direct-reply count minus those dropped. Replies are never listed here. Clients render chronological messenger-group order (oldest top, newest bottom above the composer). Empty list is 200 `{ messages: [] }`. No photo/video bytes in JSON; signed-in list may include `accountId` (21gifts author id; omitted for Damus-only); `payable` is true when the note has an `eventId` and the author has a Lightning Address; missing author → `role` `"basis"` and `payable` false. `videoContentType` is `null` when `hasVideo` is false. +- **Errors:** 401 `{ error: 'Unauthorized' }` missing/invalid/expired bearer; 409 `{ error: 'missing_requirements', missing: ['rules'] }` when rules are not agreed; 503 `{ error: 'Messages are unavailable' }` if the store throws (`messages.list.failed`). - **Used by:** App public comment thread. - **Auth:** `Authorization: Bearer` session. @@ -289,22 +296,22 @@ ## Endpoint: POST /messages -- **Purpose:** Bearer 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. Top-level creates may enqueue push; replies do not. -- **Errors:** 401 Unauthorized; 400 Expected a JSON body with text and/or photo; 400 Set a name before posting; 400 Text must be 1–500 characters; 400 Text must be 1–500 characters or include a photo; 400 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 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`). - **Used by:** App forum composer and reply composer. - **Auth:** `Authorization: Bearer` session. ## Endpoint: POST /messages/:id/invoice -- **Purpose:** Bearer required. `:id` is a UUID. Body `{ sats }` (integer 1..10_000_000). Builds a NIP-57 kind:9734 zap request for the note, signs it with the payer's custodial key (ensuring one exists when KEK is present), and returns `{ pr, amountSats }` only when the minted BOLT11 is a NIP-57 `description_hash` invoice (`isNip57Invoice`); otherwise persists `not_zap` (with rejected `pr` for debug) and responds 400 `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). +- **Purpose:** Bearer required. After auth, `requireAction(payer, 'forum.pay')` (payer needs rules only — never 409 `lightning-address` for the payer). `: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`. Author LN / unsigned note stay 400 `This message cannot be paid yet` (resource state, not payer `missing`). 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; 409 `{ error: 'missing_requirements', missing: ['rules'] }` when the payer has not agreed to rules; 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. ## Endpoint: POST /contact -- **Purpose:** Bearer required. Body `{ text }`. Private mailbox to 21.gifts — never listed publicly. Name snapshot as forum messages; text uses `normalizeForumText` then still requires 1–500 characters (forum photo-only empty text does not apply). After the platform account exists, persists the contact row first, then opens/appends the member→platform conversation thread so the message is readable via `GET /conversations`. Conversation append failure logs `conversations.contact_sync.failed` and still returns 200 (contact is the product surface). 200 is the public contact object (no `accountId`). -- **Errors:** 401 Unauthorized; 400 Expected a JSON body with a "text" string; 400 Set a name before posting; 400 Text must be 1–500 characters; 503 `{ error: 'Platform account is not configured' }` when no `isPlatform` account exists (neither contact nor thread is written); 503 Contact is unavailable (`contact.create.failed`). +- **Purpose:** Bearer required. After auth, `requireAction(account, 'contact.post')` (needs rules + name). Body `{ text }`. Private mailbox to 21.gifts — never listed publicly. Name snapshot as forum messages; text uses `normalizeForumText` then still requires 1–500 characters (forum photo-only empty text does not apply). After the platform account exists, persists the contact row first, then opens/appends the member→platform conversation thread so the message is readable via `GET /conversations`. Conversation append failure logs `conversations.contact_sync.failed` and still returns 200 (contact is the product surface). 200 is the public contact object (no `accountId`). +- **Errors:** 401 Unauthorized; 409 `{ error: 'missing_requirements', missing: [...] }` when rules and/or name are missing; 400 Expected a JSON body with a "text" string; 400 Text must be 1–500 characters; 503 `{ error: 'Platform account is not configured' }` when no `isPlatform` account exists (neither contact nor thread is written); 503 Contact is unavailable (`contact.create.failed`). - **Used by:** App in-app contact composer. - **Auth:** `Authorization: Bearer` session. @@ -373,7 +380,14 @@ ## 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). +- **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. - **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. + +## Endpoint: POST /me/setup/skip + +- **Purpose:** Bearer required. Body `{ step: "name" | "lightning-address" }`. Sets `nameSkippedAt` or `lightningAddressSkippedAt` to now so owner `setup` advances; does not clear or change `name` / `lightningAddress`. Skipping an already-set field is allowed (writes the skip timestamp). Rules cannot be skipped. +- **Errors:** 401 without session; 400 for unknown step, `step: "rules"`, or bad JSON. +- **Used by:** App onboarding skip controls (api-first; app proxy may follow later). +- **Auth:** `Authorization: Bearer` session. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index eabb2c59..ffc103e0 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -107,7 +107,7 @@ ## Function: migrateMessageSchema -- **Purpose:** Applies `MESSAGE_SCHEMA_SQL` in order (`CREATE TABLE IF NOT EXISTS message` with nullable `photo`/`photo_content_type`, newest-first index, additive `ALTER … ADD COLUMN IF NOT EXISTS` for existing databases including `video_content_type` (MIME in Postgres; video bytes on disk under `MEDIA_DIR`, not bytea), `parent_id uuid REFERENCES message (id)`, `author_pubkey text`, then `ALTER TABLE message ALTER COLUMN account_id DROP NOT NULL` and `CREATE INDEX IF NOT EXISTS message_parent_id_idx ON message (parent_id, created_at ASC, id ASC)`, then `message_invoice` and `nostr_zap_ingest` without FKs plus `ALTER TABLE message_invoice ADD COLUMN IF NOT EXISTS lnurl_response jsonb` and their `created_at`/`message_id` and `receipt_id` indexes). +- **Purpose:** Applies `MESSAGE_SCHEMA_SQL` in order (`CREATE TABLE IF NOT EXISTS message` with nullable `photo`/`photo_content_type`, newest-first index, additive `ALTER … ADD COLUMN IF NOT EXISTS` for existing databases including `video_content_type` (MIME in Postgres; video bytes on disk under `MEDIA_DIR`, not bytea), `parent_id uuid REFERENCES message (id)`, `author_pubkey text`, then `ALTER TABLE message ALTER COLUMN account_id DROP NOT NULL` and `CREATE INDEX IF NOT EXISTS message_parent_id_idx ON message (parent_id, created_at ASC, id ASC)`, then `message_invoice` and `nostr_zap_ingest` without FKs plus `ALTER TABLE message_invoice ADD COLUMN IF NOT EXISTS lnurl_response jsonb` and their `created_at`/`message_id` and `receipt_id` indexes). After `message` exists, adds `account_profile_message_id_fkey` (`ON DELETE SET NULL`) and unique partial index `account_profile_message_uidx`. - **Inputs:** `SqlClient`. - **Returns / side effects:** Void; idempotent DDL execute matching `docs/schema/message.sql`. - **Used by:** `openBootStores` when SQL opens. @@ -207,7 +207,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`; unique index `account_lightning_address_uidx` on `lower(trim(lightning_address))` where not null; unique index `passkey_credential_account_uidx` on `account_id`; adds `is_platform boolean NOT NULL DEFAULT false` and unique index `account_is_platform_uidx` on `(is_platform) WHERE is_platform`. +- **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`; adds `is_platform boolean NOT NULL DEFAULT false` and unique index `account_is_platform_uidx` on `(is_platform) WHERE is_platform`; adds nullable `name_skipped_at`, `lightning_address_skipped_at`, and `profile_message_id uuid` (**no** FK to `message` here — message migrates later). - **Used by:** `openAuthStore`. ## Function: openAuthStore @@ -241,8 +241,8 @@ ## Function: debugRoutes - **Purpose:** Operator listing, provisioning, role assignment, Lightning Address unlink, official platform-flag retarget, and minting a member bearer via `POST /:id/session`. -- **Inputs:** `DebugRouteDeps`: store, optional debugToken, required `fetchImpl` (NIP-57 mint probe on new POST addresses), optional `conversationStore` (`PATCH platform: true` calls `retargetMemberPlatform`), optional `now` for minted debug sessions. -- **Returns / side effects:** Hono app (`GET /`, `POST /`, `PATCH /:id`, `POST /:id/session`). Shared 503 if token unset; 401 if bearer mismatches. GET 200 `{ accounts }` via `serializeDebugAccount` (includes `isPlatform`; 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); probes **all** new addresses first (`probeNip57Mint`) unless `NIP57_PROBE=0` (Playwright e2e skip; production must not set this); any `not_zap` / `unreachable` is 400 and no new address in that request is saved; name-only updates run only after every probe has passed; 500 `{ error: 'Could not save the account' }` when create does not persist the address, the name-only update matches no row, or the name-only update returns a row whose `name` is not the requested name; creates by Lightning Address, or for an existing address updates **only** `name` via `updateAccountNameByLightningAddress` (keeps `viewKey` / `role` / other columns); returns `{ accounts: [{ name, lightningAddress, viewKey, created }] }`; logs `debug.accounts.provisioned` with created/updated counts (never viewKeys or the token). PATCH body `{ role }` and/or `{ lightningAddress: null }` and/or `{ platform: true|false }` → 400 unknown/missing; 404 missing account; 200 `serializeDebugAccount` of the updated row (includes `isPlatform`; no `viewKey`); unlink also `deleteVerification` and logs `debug.accounts.lightning_address.cleared`; role changes log `debug.accounts.role_set` with account id and role; `platform: true` uniquely retargets (store clears any other `isPlatform`), points every member→platform thread at the new account via `retargetMemberPlatform` when `conversationStore` is set, and logs `debug.accounts.platform_set`. Never logs the token or the previous address. +- **Inputs:** `DebugRouteDeps`: store, optional debugToken, required `fetchImpl` (NIP-57 mint probe on new POST addresses), optional `conversationStore` (`PATCH platform: true` calls `retargetMemberPlatform`), optional `messageStore` and `pushStore` (POST provision calls `ensureProfileMessage` when `messageStore` is set), optional `now` for minted debug sessions. +- **Returns / side effects:** Hono app (`GET /`, `POST /`, `PATCH /:id`, `POST /:id/session`). Shared 503 if token unset; 401 if bearer mismatches. GET 200 `{ accounts }` via `serializeDebugAccount` (includes `isPlatform`; 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); probes **all** new addresses first (`probeNip57Mint`) unless `NIP57_PROBE=0` (Playwright e2e skip; production must not set this); any `not_zap` / `unreachable` is 400 and no new address in that request is saved; name-only updates run only after every probe has passed; 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`; when `messageStore` is set, POST then calls `ensureProfileMessage` (optional `pushStore`) (keeps `viewKey` / `role` / other columns); returns `{ accounts: [{ name, lightningAddress, viewKey, created }] }`; logs `debug.accounts.provisioned` with created/updated counts (never viewKeys or the token). PATCH body `{ role }` and/or `{ lightningAddress: null }` and/or `{ platform: true|false }` → 400 unknown/missing; 404 missing account; 200 `serializeDebugAccount` of the updated row (includes `isPlatform`; no `viewKey`); unlink also `deleteVerification` and logs `debug.accounts.lightning_address.cleared`; role changes log `debug.accounts.role_set` with account id and role; `platform: true` uniquely retargets (store clears any other `isPlatform`), points every member→platform thread at the new account via `retargetMemberPlatform` when `conversationStore` is set, and logs `debug.accounts.platform_set`. Never logs the token or the previous address. - **Used by:** `createApp` at `/debug/accounts`. ## Function: debugContactsRoutes @@ -548,7 +548,7 @@ ## Function: createApp -- **Purpose:** Wires CORS, requestLog, brand, health, info, auth, me, `/view`, lightning-address, `/debug/accounts`, `/debug/contacts`, `/debug/messages`, `/debug/invoices`, `/debug/zap-ingests`, `/debug/push-ping`, Web Push subscription routes, `/gifts`, `/gifts/stats`, `/messages` (incl. invoice), `/.well-known` NIP-05 `nostr.json` (CORS `*`), `/contact`, `/conversations`, and invoices. +- **Purpose:** Wires CORS, requestLog, brand, health, info, auth, me, `/view`, lightning-address, `/debug/accounts`, `/debug/contacts`, `/debug/messages`, `/debug/invoices`, `/debug/zap-ingests`, `/debug/push-ping`, Web Push subscription routes, `/gifts`, `/gifts/stats`, `/messages` (incl. invoice), `/members/:accountId`, `/.well-known` NIP-05 `nostr.json` (CORS `*`), `/contact`, `/conversations`, and invoices. - **Inputs:** Optional `AppDeps` (store, clock, payer, fetch, cache, readBrand, origins, `debugToken`, giftStore, `giftRecorder`, `btcUsdRates`, `messageStore`, `contactStore`, optional `conversationStore` (default `InMemoryConversationStore`), `pushStore`, `vapidPublicKey`, `nostrKek`, spendApiToken, invoiceStore, `webAuthnRpId`, `webAuthnRpName`, `passkeyCeremony`). Omitted `giftRecorder` → `invoiceRoutes` uses `NoopGiftRecorder`; omitted `messageStore` → `InMemoryMessageStore`; omitted `contactStore` → `InMemoryContactStore`; omitted `conversationStore` → `InMemoryConversationStore`; omitted `pushStore` → `InMemoryPushStore`; omitted/blank `vapidPublicKey` → push HTTP 503 after session; omitted `nostrKek` → unsigned forum + invoice 503; SQL boot injects `SqlGiftRecorder`, `PostgresMessageStore`, `PostgresContactStore`, `PostgresConversationStore`, `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. @@ -583,9 +583,9 @@ ## Function: meRoutes -- **Purpose:** Authenticated account routes (name, forum-laws dismiss, living-room rules agreement, Lightning Address link with live LNURL resolve + zap metadata check then NIP-57 mint probe `probeNip57Mint`, 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, optional `nostrKek` (required to sign the mint probe). -- **Returns / side effects:** Hono at `/me`. 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). +- **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. +- **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`. ## Function: viewRoutes @@ -597,16 +597,16 @@ ## Function: messagesRoutes -- **Purpose:** Hono sub-app for the public member forum: Bearer `GET /` lists **top-level** notes only (`parent_id` null) newest-first (cap 200, `hasPhoto`, `hasVideo`, `videoContentType`, `sats`, `payable`, live `role`, `replyCount`); a `hasVideo` row whose file is missing or empty is **deleted** (`messages.video.dropped`) and omitted; for each kept top-level note, missing-file `hasVideo` direct replies are dropped via `dropMissingVideoRow` / `deleteById` + `messages.video.dropped`, and `replyCount` is the stored `replyCount` minus how many missing-file video children were dropped in the replies window; `POST /` creates text and/or one photo (JSON, optional `inReplyTo` UUID of a **top-level** parent) or one video (multipart `video` + optional JPEG/PNG/WebP `poster`) when the account has a non-blank display name; public `GET /:id` (no Bearer) returns one note (404 after deleting a `hasVideo` row whose file is gone); Bearer `GET /:id/replies` lists direct replies oldest-first and likewise drops missing-file `hasVideo` replies via `dropMissingVideoRow` / `deleteById` + `messages.video.dropped`; `GET /:id/photo` serves raw bytes without auth (Nostr `imeta`); `GET /:id/video.mp4|.webm|.mov` serves sized video bytes (`Content-Length`, `Accept-Ranges` / 206 / 416, heal-on-read faststart); `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 **top-level** create (`parentId` null), optional `pushStore` enqueues forum pushes for other subscribed accounts (`push.enqueue.failed` is swallowed; POST still 200); replies do not enqueue. Product UX is a messenger group — clients reverse the newest-first list for display (oldest top, newest bottom). +- **Purpose:** Hono sub-app for the public member forum. 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. - **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 (public `GET /:id` and photo/video do not require Bearer); 400 on bad body / missing name / invalid text / bad photo / bad poster / bad video / unpaid note ("This message cannot be paid yet") / author's wallet cannot receive this Bitcoin payment (`noZap`, `not_zap`) / Could not start the Bitcoin payment (`unreachable` and other LNURL transport failures); 404 `{ error: 'Not found' }` when JSON `inReplyTo` is present but not a UUID, the parent is missing, or the parent is itself a reply (`parentId !== null`); 404 photo/video/`GET /:id`/`GET /:id/replies` missing; 416 unsatisfiable video Range; 429 on post or invoice rate limits (invoice only after payable checks; NIP-57 reject still counts like other LNURL failures); 503 on store/KEK/sign failure (`messages.list.failed` / `messages.create.failed` / `messages.get.failed` / `messages.replies.failed` / `messages.photo.failed` / `messages.video.failed`). Public JSON includes `sats`/`payable`/`hasPhoto`/`hasVideo`/`videoContentType`/live `role` and omits media bytes (list `replyCount` is stored `replyCount` minus missing-file video children dropped in the replies window; missing author → `role` `"basis"` on list; Damus-only omits `role`). Signed-in list/replies/create may include `accountId` (21gifts author id; omitted for Damus-only); public `GET /:id` never includes it. +- **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`. ## Function: contactRoutes -- **Purpose:** Hono sub-app for the private in-app contact mailbox: `POST /` only (no member GET). Creates when the account has a non-blank display name. After the platform account exists, persists the contact row first, then opens/appends the member→platform conversation thread (working inbox). Conversation append failure logs `conversations.contact_sync.failed` and still 200. +- **Purpose:** Hono sub-app for the private in-app contact mailbox: `POST /` only (no member GET). After auth, `requireAction(account, 'contact.post')` (rules + name). After the platform account exists, persists the contact row first, then opens/appends the member→platform conversation thread. Conversation append failure logs `conversations.contact_sync.failed` and still 200. - **Inputs:** `ContactRouteDeps`: contact `store`, `conversationStore`, shared `authStore`, `now`. -- **Returns / side effects:** Hono app mounted at `/contact`. 401 without session; 400 on bad body / missing name / invalid text; 503 `{ error: 'Platform account is not configured' }` when no `isPlatform` account (no writes); 503 Contact is unavailable on contact-store failure (`contact.create.failed`). Public JSON omits `accountId`. +- **Returns / side effects:** Hono app mounted at `/contact`. 401 without session; 409 `{ error: 'missing_requirements', missing }` when rules/name are missing; 400 on bad body / invalid text; 503 `{ error: 'Platform account is not configured' }` when no `isPlatform` account (no writes); 503 Contact is unavailable on contact-store failure (`contact.create.failed`). Public JSON omits `accountId`. - **Used by:** `createApp`. ## Function: conversationRoutes @@ -877,11 +877,39 @@ ## Function: accountSetup -- **Purpose:** Next owner setup step from stored account fields. The api is the source of truth; clients only route. +- **Purpose:** Next owner wizard step from stored account fields. Skip timestamps count as completing that step. The api is the source of truth; clients only route. - **Inputs:** `Account`. -- **Returns / side effects:** `'name'` when name is null/blank, else `'lightning-address'` when Lightning Address is null/blank, else `'rules'` when `rulesAgreedAt` is null, else `null`. No I/O. +- **Returns / side effects:** `'name'` when name is null/blank and `nameSkippedAt` is unset, else `'lightning-address'` when Lightning Address is null/blank and `lightningAddressSkippedAt` is unset, else `'rules'` when `rulesAgreedAt` is null, else `null`. No I/O. - **Used by:** `serializeOwnerAccount`. +## Function: accountMissing + +- **Purpose:** Factually unset account fields for action gates. Skip timestamps do not clear a field from this list. +- **Inputs:** `Account`. +- **Returns / side effects:** `AccountMissingField[]` in order `name`, `lightning-address`, `rules` (only those that are null/blank or rules unset). No I/O. +- **Used by:** `serializeOwnerAccount`, `requireAction`. + +## Function: actionRequirements + +- **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. +- **Used by:** `requireAction`. + +## Function: requireAction + +- **Purpose:** Gate a signed-in action on factual account fields (skip does not satisfy). Filters `accountMissing` to the action's needs, preserving `actionRequirements` order. +- **Inputs:** `Account`, `AccountAction`. +- **Returns / side effects:** `{ ok: true }` or `{ ok: false, missing }` (never empty). No I/O. Routes respond 409 `{ error: 'missing_requirements', missing }` when `ok` is false. +- **Used by:** `messagesRoutes`, `contactRoutes`, `membersRoutes`. + +## 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. +- **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. + ## Function: serializeAccount - **Purpose:** Project an account to the nine-field dump without `viewKey` or `isPlatform` (no Nostr fields). @@ -898,11 +926,18 @@ ## Function: serializeOwnerAccount -- **Purpose:** Owner JSON for authenticated account responses: the nine public fields plus `viewKey` and `setup`, so the owner can copy the capability URL and the client can route onboarding. Used by `GET /me`, `/me` writes including `POST /me/rules-agreement`, and passkey finish — never by the debug listing. +- **Purpose:** Owner JSON for authenticated account responses: the nine public fields plus `viewKey`, `setup`, and `missing`, so the owner can copy the capability URL and the client can route onboarding and action gates. Used by `GET /me`, `/me` writes including `POST /me/rules-agreement` and `POST /me/setup/skip`, and passkey finish — never by the debug listing. Does not expose `profileMessageId`. - **Inputs:** `Account`. -- **Returns / side effects:** `OwnerAccountResponse` (eleven fields including `setup`). No I/O. +- **Returns / side effects:** `OwnerAccountResponse` (twelve fields including `setup` and `missing`). No I/O. - **Used by:** `meRoutes`, `authRoutes`. +## Function: membersRoutes + +- **Purpose:** Hono sub-app for `GET /members/:accountId`. Bearer + `requireAction(forum.read)`; UUID path; live identity plus optional `profileMessage` via `serializeMessage`. +- **Inputs:** `MembersRouteDeps` (`authStore`, `messageStore`, `now`). +- **Returns / side effects:** Hono app mounted at `/members`. Logs `members.get.failed` on 503. +- **Used by:** `createApp`. + ## Function: serializeViewProfile - **Purpose:** Public profile card for the capability URL. Five fields (`name`, `lightningAddress`, `lightningAddressVerified`, `createdAt`, `hasPasskey`). Omits `id`, `linkingKey`, `role`, and `viewKey`. @@ -1010,15 +1045,15 @@ ## Function: buildKind0Content -- **Purpose:** Kind:0 JSON without extra whitespace (`name`, `display_name`, `website`, `picture`, `about: '21.gifts'`, optional `lud16`, optional `nip05`). -- **Inputs:** name, lightningAddress or null, optional nip05 or null. -- **Returns / side effects:** JSON string; `picture` is always the 21.gifts icon; `about` is always `21.gifts`; `lud16` only when address set; `nip05` only when a public identifier is passed. +- **Purpose:** Kind:0 JSON without extra whitespace (`name`, `display_name`, `website`, `picture`, `about`, optional `lud16`, optional `nip05`). +- **Inputs:** name, lightningAddress or null, optional nip05 or null, optional `about` (default `'21.gifts'`; worker passes profile-note text when present). +- **Returns / side effects:** JSON string; `picture` is always the 21.gifts icon; `about` is the fourth argument; `lud16` only when address set; `nip05` only when a public identifier is passed. - **Used by:** `buildKind0Event`, worker `publishProfiles`. ## Function: buildKind0Event -- **Purpose:** Unsigned replaceable kind:0, including optional `nip05`. -- **Inputs:** name, lightningAddress, unix created_at, optional nip05. +- **Purpose:** Unsigned replaceable kind:0, including optional `nip05` and optional `about`. +- **Inputs:** name, lightningAddress, unix created_at, optional nip05, optional about (default `'21.gifts'`). - **Returns / side effects:** Unsigned fields. - **Used by:** Worker `publishProfiles`. @@ -1185,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). Pending rows EVENT as-is so a reset cannot renew the 60s sign lease. Zapped rows keep `eventId`. An empty API base skips photo/video-URL resign. Sign looks up photo bytes even when `hasPhoto` is stale. When publishing, also fans out kind:0 profiles (`name` / `display_name` / `picture` / optional `nip05`) and NIP-65 kind:10002 relay lists. Kind:1 photo/video posts include the public media URL and `imeta`. Each tick queries zap relays (space plus the public list, even when `NOSTR_PUBLISH_PUBLIC` is off) for kind:9735 and indexes validated receipts onto `sats`, even when `NOSTR_PUBLISH` is off. 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 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/docs/schema/message.sql b/docs/schema/message.sql index 119deaca..b226d8cf 100644 --- a/docs/schema/message.sql +++ b/docs/schema/message.sql @@ -82,3 +82,10 @@ 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); + +-- Profile note FK (account.profile_message_id is added in AUTH_SCHEMA_SQL without FK). +ALTER TABLE account DROP CONSTRAINT IF EXISTS account_profile_message_id_fkey; +ALTER TABLE account ADD CONSTRAINT account_profile_message_id_fkey + FOREIGN KEY (profile_message_id) REFERENCES message (id) ON DELETE SET NULL; +CREATE UNIQUE INDEX IF NOT EXISTS account_profile_message_uidx + ON account (profile_message_id) WHERE profile_message_id IS NOT NULL; diff --git a/e2e/forum-replies.spec.ts b/e2e/forum-replies.spec.ts index 31f6660a..3241c68e 100644 --- a/e2e/forum-replies.spec.ts +++ b/e2e/forum-replies.spec.ts @@ -33,6 +33,8 @@ test('e2e: forum note, public read, reply, and replyCount against the booted API expect(session.status()).toBe(200); const token = ((await session.json()) as { token: string }).token; const auth = { authorization: `Bearer ${token}` }; + const agreed = await request.post('/me/rules-agreement', { headers: auth }); + expect(agreed.status()).toBe(200); const posted = await request.post('/messages', { headers: { ...auth, 'content-type': 'application/json' }, diff --git a/e2e/functions.spec.ts b/e2e/functions.spec.ts index 3e872d40..a09eb307 100644 --- a/e2e/functions.spec.ts +++ b/e2e/functions.spec.ts @@ -108,6 +108,35 @@ test('Function: meRoutes — GET /me without bearer is 401', async ({ request }) expect(me.status()).toBe(401); }); +test('Function: membersRoutes — GET /members/:accountId without bearer is 401', async ({ + request, +}) => { + const res = await request.get('/members/:accountId'); + expect(res.status()).toBe(401); +}); + +test('Function: requireAction — GET /messages without bearer is 401', async ({ request }) => { + const res = await request.get('/messages'); + expect(res.status()).toBe(401); +}); + +test('Function: actionRequirements — GET /messages without bearer is 401', async ({ request }) => { + const res = await request.get('/messages'); + expect(res.status()).toBe(401); +}); + +test('Function: accountMissing — GET /me without bearer is 401', async ({ request }) => { + const me = await request.get('/me'); + expect(me.status()).toBe(401); +}); + +test('Function: ensureProfileMessage — POST /me/name without bearer is 401', async ({ + request, +}) => { + const res = await request.post('/me/name', { data: { name: 'Ada' } }); + expect(res.status()).toBe(401); +}); + test('Function: probeNip57Mint — POST /me/lightning-address without bearer is 401', async ({ request, }) => { diff --git a/e2e/http.spec.ts b/e2e/http.spec.ts index d98d8c60..cef0a9ac 100644 --- a/e2e/http.spec.ts +++ b/e2e/http.spec.ts @@ -67,6 +67,16 @@ test('GET /me without bearer is 401', async ({ request }) => { expect(res.status()).toBe(401); }); +test('POST /me/setup/skip without bearer is 401', async ({ request }) => { + const res = await request.post('/me/setup/skip', { data: { step: 'name' } }); + expect(res.status()).toBe(401); +}); + +test('GET /members/:accountId without bearer is 401', async ({ request }) => { + const res = await request.get('/members/:accountId'); + expect(res.status()).toBe(401); +}); + test('GET /view/not-a-key is 404', async ({ request }) => { const res = await request.get('/view/not-a-key'); expect(res.status()).toBe(404); diff --git a/src/__tests__/lib/auth/account-json.test.ts b/src/__tests__/lib/auth/account-json.test.ts index 307825df..e87fa9ba 100644 --- a/src/__tests__/lib/auth/account-json.test.ts +++ b/src/__tests__/lib/auth/account-json.test.ts @@ -50,7 +50,7 @@ describe('serializeDebugAccount', () => { }); describe('serializeOwnerAccount', () => { - it('includes viewKey alongside the nine public fields', () => { + it('includes viewKey, setup, and missing alongside the nine public fields', () => { const json = serializeOwnerAccount(account); expect(json).toEqual({ id: 'acc', @@ -64,10 +64,13 @@ describe('serializeOwnerAccount', () => { rulesAgreedAt: null, viewKey: 'a'.repeat(64), setup: 'rules', + missing: ['rules'], }); expect(json.viewKey).toBe(account.viewKey); expect(json.setup).toBe('rules'); + expect(json.missing).toEqual(['rules']); expect(json).not.toHaveProperty('isPlatform'); + expect(json).not.toHaveProperty('profileMessageId'); }); }); diff --git a/src/__tests__/lib/auth/account-setup.test.ts b/src/__tests__/lib/auth/account-setup.test.ts index 0085e9d4..820ad01d 100644 --- a/src/__tests__/lib/auth/account-setup.test.ts +++ b/src/__tests__/lib/auth/account-setup.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { accountSetup } from '@/lib/auth/account-setup'; +import { accountMissing, accountSetup } from '@/lib/auth/account-setup'; import type { Account } from '@/lib/auth/store'; const base: Account = { @@ -54,4 +54,64 @@ describe('accountSetup', () => { }), ).toBeNull(); }); + + it('treats a skipped name as done and asks for Lightning Address', () => { + expect(accountSetup({ ...base, nameSkippedAt: 10 })).toBe('lightning-address'); + }); + + it('asks for rules when name and Lightning Address are skipped', () => { + expect( + accountSetup({ + ...base, + nameSkippedAt: 10, + lightningAddressSkippedAt: 11, + }), + ).toBe('rules'); + }); + + it('is complete when both steps are skipped and rules are agreed', () => { + expect( + accountSetup({ + ...base, + nameSkippedAt: 10, + lightningAddressSkippedAt: 11, + rulesAgreedAt: 12, + }), + ).toBeNull(); + }); +}); + +describe('accountMissing', () => { + it('lists skipped fields as still missing', () => { + expect( + accountMissing({ + ...base, + nameSkippedAt: 10, + lightningAddressSkippedAt: 11, + }), + ).toEqual(['name', 'lightning-address', 'rules']); + }); + + it('omits set fields', () => { + expect( + accountMissing({ + ...base, + name: 'Ada', + lightningAddress: 'ada@walletofsatoshi.com', + rulesAgreedAt: 2, + }), + ).toEqual([]); + }); + + it('lists lightning-address again after unlink clears the skip', () => { + const afterUnlink: Account = { + ...base, + name: 'Ada', + lightningAddress: null, + lightningAddressSkippedAt: null, + rulesAgreedAt: 2, + }; + expect(accountSetup(afterUnlink)).toBe('lightning-address'); + expect(accountMissing(afterUnlink)).toEqual(['lightning-address']); + }); }); diff --git a/src/__tests__/lib/auth/postgres-store.test.ts b/src/__tests__/lib/auth/postgres-store.test.ts index 3e2a9ca3..a0cfe143 100644 --- a/src/__tests__/lib/auth/postgres-store.test.ts +++ b/src/__tests__/lib/auth/postgres-store.test.ts @@ -40,6 +40,9 @@ const ACCOUNT_ROW = { view_key: VIEW_KEY, created_at: new Date(1_000), rules_agreed_at: null as Date | string | null, + name_skipped_at: null as Date | string | null, + lightning_address_skipped_at: null as Date | string | null, + profile_message_id: null as string | null, }; describe('PostgresAuthStore nostr keys', () => { @@ -95,17 +98,58 @@ describe('PostgresAuthStore', () => { expect(mapped?.forumLawsDismissed).toBe(false); expect(mapped?.rulesAgreedAt).toBeNull(); expect(mapped?.isPlatform).toBe(false); + expect(mapped?.nameSkippedAt).toBeNull(); + expect(mapped?.lightningAddressSkippedAt).toBeNull(); + expect(mapped?.profileMessageId).toBeNull(); const account = await store.getAccount('acc'); expect(account?.linkingKey).toBe(ACCOUNT_ROW.linking_key); expect(account?.viewKey).toBe(VIEW_KEY); expect(sql.queries[0]?.text).toMatch(/forum_laws_dismissed/); expect(sql.queries[0]?.text).toMatch(/rules_agreed_at/); + expect(sql.queries[0]?.text).toMatch(/name_skipped_at/); + expect(sql.queries[0]?.text).toMatch(/profile_message_id/); const listed = await store.listAccounts(); expect(listed).toHaveLength(1); expect(sql.queries[2]?.text).toMatch(/ORDER BY created_at ASC, id ASC/); expect(sql.queries[2]?.text).toMatch(/rules_agreed_at/); }); + it('maps omitted skip timestamps to null', async () => { + const sql = new MockSql(); + sql.nextRows = [ + { + id: ACCOUNT_ROW.id, + linking_key: ACCOUNT_ROW.linking_key, + role: ACCOUNT_ROW.role, + name: ACCOUNT_ROW.name, + lightning_address: ACCOUNT_ROW.lightning_address, + lightning_address_verified: ACCOUNT_ROW.lightning_address_verified, + forum_laws_dismissed: ACCOUNT_ROW.forum_laws_dismissed, + view_key: ACCOUNT_ROW.view_key, + created_at: ACCOUNT_ROW.created_at, + rules_agreed_at: ACCOUNT_ROW.rules_agreed_at, + profile_message_id: ACCOUNT_ROW.profile_message_id, + }, + ]; + const mapped = await new PostgresAuthStore(sql).getAccount('acc'); + expect(mapped?.nameSkippedAt).toBeNull(); + expect(mapped?.lightningAddressSkippedAt).toBeNull(); + }); + + it('maps non-null skip timestamps', async () => { + const sql = new MockSql(); + sql.nextRows = [ + { + ...ACCOUNT_ROW, + name_skipped_at: new Date(2_000), + lightning_address_skipped_at: new Date(3_000), + }, + ]; + const mapped = await new PostgresAuthStore(sql).getAccount('acc'); + expect(mapped?.nameSkippedAt).toBe(2_000); + expect(mapped?.lightningAddressSkippedAt).toBe(3_000); + }); + it('maps a non-null rules_agreed_at timestamp', async () => { const sql = new MockSql(); sql.nextRows = [{ ...ACCOUNT_ROW, rules_agreed_at: new Date(5_000) }]; @@ -152,11 +196,18 @@ describe('PostgresAuthStore', () => { expect(sql.executes[0]?.params[8]).toBe(account.viewKey); expect(sql.executes[0]?.params[9]).toBeNull(); expect(sql.executes[0]?.params[10]).toBe(false); + expect(sql.executes[0]?.params[11]).toBeNull(); + expect(sql.executes[0]?.params[12]).toBeNull(); + expect(sql.executes[0]?.params[13]).toBeNull(); + expect(sql.executes[0]?.text).toMatch(/name_skipped_at/); + expect(sql.executes[0]?.text).toMatch(/profile_message_id/); expect(sql.executes[1]?.text).toMatch(/UPDATE account/); expect(sql.executes[1]?.text).toMatch(/forum_laws_dismissed/); expect(sql.executes[1]?.text).toMatch(/view_key = \$9/); expect(sql.executes[1]?.text).toMatch(/rules_agreed_at/); expect(sql.executes[1]?.text).toMatch(/is_platform = \$11/); + expect(sql.executes[1]?.text).toMatch(/name_skipped_at/); + expect(sql.executes[1]?.text).toMatch(/profile_message_id = \$14/); expect(sql.executes[1]?.text).toMatch(/NOT EXISTS/); expect(sql.executes[1]?.params).toEqual([ 'acc', @@ -170,6 +221,9 @@ describe('PostgresAuthStore', () => { VIEW_KEY, 9_000, false, + null, + null, + null, ]); }); @@ -191,6 +245,7 @@ describe('PostgresAuthStore', () => { }); expect(sql.executes[0]?.text).toMatch(/is_platform = false WHERE is_platform/); expect(sql.executes[1]?.params[10]).toBe(true); + expect(sql.executes[1]?.params[13]).toBeNull(); await store.updateAccount({ id: 'plat', linkingKey: null, diff --git a/src/__tests__/lib/auth/profile-message.test.ts b/src/__tests__/lib/auth/profile-message.test.ts new file mode 100644 index 00000000..dddc9a1f --- /dev/null +++ b/src/__tests__/lib/auth/profile-message.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ensureProfileMessage } from '@/lib/auth/profile-message'; +import { InMemoryAuthStore, type Account } from '@/lib/auth/store'; +import { unsignedNostrDefaults } from '@/lib/message'; +import { InMemoryMessageStore } from '@/lib/message-store'; +import { InMemoryPushStore } from '@/lib/push-store'; + +const now = (): number => 1_700_000_000_000; + +async function seededAccount( + overrides: Partial = {}, +): Promise<{ auth: InMemoryAuthStore; account: Account }> { + const auth = new InMemoryAuthStore(); + const account: Account = { + id: 'acc', + linkingKey: null, + role: 'basis', + name: 'Ada', + lightningAddress: null, + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: 'a'.repeat(64), + createdAt: 1, + rulesAgreedAt: null, + ...overrides, + }; + await auth.createAccount(account); + return { auth, account }; +} + +describe('ensureProfileMessage', () => { + it('returns the account without inserting when name is blank', async () => { + const { auth, account } = await seededAccount({ name: 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('inserts one profile note and is idempotent on rename', async () => { + const { auth, account } = await seededAccount(); + const messages = new InMemoryMessageStore(); + const first = await ensureProfileMessage({ auth, messages, account, now }); + expect(typeof first.profileMessageId).toBe('string'); + const note = await messages.getById(first.profileMessageId!); + expect(note?.text).toBe('Ada'); + const renamed: Account = { ...first, name: 'Ada Lovelace' }; + const second = await ensureProfileMessage({ + auth, + messages, + account: renamed, + now, + }); + expect(second.profileMessageId).toBe(first.profileMessageId); + expect((await messages.listLatest(10)).filter((row) => row.parentId === null)).toHaveLength(1); + expect((await messages.getById(first.profileMessageId!))?.text).toBe('Ada'); + }); + + it('recreates when profileMessageId points at a missing row', async () => { + const { auth, account } = await seededAccount({ + profileMessageId: '00000000-0000-4000-8000-000000000099', + }); + const messages = new InMemoryMessageStore(); + const result = await ensureProfileMessage({ auth, messages, account, now }); + expect(result.profileMessageId).not.toBe('00000000-0000-4000-8000-000000000099'); + expect(await messages.getById(result.profileMessageId!)).toBeDefined(); + }); + + it('enqueues forum pushes when pushStore is passed', async () => { + const { auth, account } = await seededAccount(); + const messages = new InMemoryMessageStore(); + const pushStore = new InMemoryPushStore(); + await pushStore.upsertSubscription({ + accountId: 'other', + endpoint: 'https://push.example/1', + p256dh: 'p', + auth: 'a', + createdAt: new Date(now()), + }); + const result = await ensureProfileMessage({ + auth, + messages, + account, + now, + pushStore, + }); + expect(result.profileMessageId).toBeTruthy(); + const pending = await pushStore.claimPending(10, now(), 60_000); + expect(pending.some((row) => row.type === 'forum')).toBe(true); + }); + + it('deletes the insert when the account disappears before update', async () => { + const { auth, account } = await seededAccount(); + const messages = new InMemoryMessageStore(); + vi.spyOn(auth, 'getAccount').mockResolvedValueOnce(undefined); + const result = await ensureProfileMessage({ auth, messages, account, now }); + expect(result.profileMessageId).toBeUndefined(); + expect(await messages.listLatest(10)).toHaveLength(0); + }); + + it('deletes a raced insert when another profile note already won', async () => { + const { auth, account } = await seededAccount(); + const messages = new InMemoryMessageStore(); + const winnerId = '22222222-2222-4222-8222-222222222222'; + await messages.create({ + id: winnerId, + accountId: 'acc', + name: 'Ada', + text: 'Ada', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + }); + vi.spyOn(auth, 'getAccount').mockResolvedValueOnce({ + ...account, + profileMessageId: winnerId, + }); + const result = await ensureProfileMessage({ auth, messages, account, now }); + expect(result.profileMessageId).toBe(winnerId); + expect((await messages.listLatest(10)).filter((row) => row.parentId === null)).toHaveLength(1); + expect(await messages.getById(winnerId)).toBeDefined(); + }); + + it('keeps the note when forum push enqueue throws', async () => { + const { auth, account } = await seededAccount(); + const messages = new InMemoryMessageStore(); + const pushStore = new InMemoryPushStore(); + await pushStore.upsertSubscription({ + accountId: 'other', + endpoint: 'https://push.example/1', + p256dh: 'p', + auth: 'a', + createdAt: new Date(now()), + }); + vi.spyOn(pushStore, 'enqueue').mockRejectedValueOnce(new Error('fail')); + const result = await ensureProfileMessage({ + auth, + messages, + account, + now, + pushStore, + }); + expect(typeof result.profileMessageId).toBe('string'); + expect(await messages.getById(result.profileMessageId!)).toBeDefined(); + }); + + it('deletes the insert when a later write wins the profile pointer', async () => { + const { auth, account } = await seededAccount(); + const messages = new InMemoryMessageStore(); + const winnerId = '22222222-2222-4222-8222-222222222222'; + await messages.create({ + id: winnerId, + accountId: 'acc', + name: 'Ada', + text: 'Ada', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + }); + const get = vi.spyOn(auth, 'getAccount'); + get.mockResolvedValueOnce({ ...account }); + get.mockResolvedValueOnce({ ...account, profileMessageId: winnerId }); + const result = await ensureProfileMessage({ auth, messages, account, now }); + expect(result.profileMessageId).toBe(winnerId); + expect(await messages.getById(winnerId)).toBeDefined(); + expect((await messages.listLatest(10)).filter((row) => row.parentId === null)).toHaveLength(1); + }); + + it('deletes the insert when the account disappears after update', async () => { + const { auth, account } = await seededAccount(); + const messages = new InMemoryMessageStore(); + const get = vi.spyOn(auth, 'getAccount'); + get.mockResolvedValueOnce({ ...account }); + get.mockResolvedValueOnce(undefined); + const result = await ensureProfileMessage({ auth, messages, account, now }); + expect(result.profileMessageId).toBeUndefined(); + expect(await messages.listLatest(10)).toHaveLength(0); + }); + + it('deletes the note when updateAccount fails after insert', async () => { + const { auth, account } = await seededAccount(); + const messages = new InMemoryMessageStore(); + vi.spyOn(auth, 'updateAccount').mockRejectedValueOnce(new Error('fail')); + const result = await ensureProfileMessage({ auth, messages, account, now }); + expect(result.profileMessageId).toBeUndefined(); + expect(await messages.listLatest(10)).toHaveLength(0); + }); + + it('does not set profileMessageId when create throws', async () => { + const { auth, account } = await seededAccount(); + const messages = new InMemoryMessageStore(); + vi.spyOn(messages, 'create').mockRejectedValueOnce(new Error('fail')); + const result = await ensureProfileMessage({ auth, messages, account, now }); + expect(result.profileMessageId).toBeUndefined(); + expect(result.name).toBe('Ada'); + }); + + it('accepts an existing note without rewriting text', async () => { + const messages = new InMemoryMessageStore(); + const noteId = '11111111-1111-4111-8111-111111111111'; + await messages.create({ + id: noteId, + accountId: 'acc', + name: 'Ada', + text: 'Original note', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + }); + const { auth, account } = await seededAccount({ + name: 'Renamed', + profileMessageId: noteId, + }); + const result = await ensureProfileMessage({ auth, messages, account, now }); + expect(result.profileMessageId).toBe(noteId); + expect((await messages.getById(noteId))?.text).toBe('Original note'); + }); +}); diff --git a/src/__tests__/lib/auth/requirements.test.ts b/src/__tests__/lib/auth/requirements.test.ts new file mode 100644 index 00000000..9f42ed1e --- /dev/null +++ b/src/__tests__/lib/auth/requirements.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { accountMissing } from '@/lib/auth/account-setup'; +import { + actionRequirements, + MISSING_REQUIREMENTS_ERROR, + requireAction, +} from '@/lib/auth/requirements'; +import type { Account } from '@/lib/auth/store'; + +const base: Account = { + id: 'acc', + linkingKey: null, + role: 'basis', + name: null, + lightningAddress: null, + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: 'a'.repeat(64), + createdAt: 1, + rulesAgreedAt: null, +}; + +describe('actionRequirements', () => { + it('lists fields for each action', () => { + expect(actionRequirements('forum.read')).toEqual(['rules']); + expect(actionRequirements('forum.post')).toEqual(['rules', 'name']); + expect(actionRequirements('contact.post')).toEqual(['rules', 'name']); + expect(actionRequirements('forum.pay')).toEqual(['rules']); + }); +}); + +describe('requireAction', () => { + it('ok when all required fields are present', () => { + const account: Account = { + ...base, + name: 'Ada', + rulesAgreedAt: 2, + }; + expect(requireAction(account, 'forum.post')).toEqual({ ok: true }); + expect(requireAction(account, 'forum.read')).toEqual({ ok: true }); + expect(requireAction(account, 'forum.pay')).toEqual({ ok: true }); + }); + + it('does not treat skip as satisfying missing', () => { + const account: Account = { + ...base, + nameSkippedAt: 10, + lightningAddressSkippedAt: 11, + rulesAgreedAt: 12, + }; + expect(accountMissing(account)).toEqual(['name', 'lightning-address']); + expect(requireAction(account, 'forum.post')).toEqual({ + ok: false, + missing: ['name'], + }); + }); + + it('orders 409 missing as rules then name', () => { + const account: Account = { ...base }; + expect(requireAction(account, 'forum.post')).toEqual({ + ok: false, + missing: ['rules', 'name'], + }); + expect(MISSING_REQUIREMENTS_ERROR).toBe('missing_requirements'); + }); + + it('forum.pay only requires rules for the payer', () => { + const account: Account = { + ...base, + name: null, + lightningAddress: null, + rulesAgreedAt: 2, + }; + expect(requireAction(account, 'forum.pay')).toEqual({ ok: true }); + }); +}); diff --git a/src/__tests__/lib/auth/schema.test.ts b/src/__tests__/lib/auth/schema.test.ts index 97b48333..84f52464 100644 --- a/src/__tests__/lib/auth/schema.test.ts +++ b/src/__tests__/lib/auth/schema.test.ts @@ -44,5 +44,15 @@ describe('AUTH_SCHEMA_SQL', () => { expect(AUTH_SCHEMA_SQL.join('\n')).toMatch( /CREATE UNIQUE INDEX IF NOT EXISTS account_is_platform_uidx ON account \(is_platform\) WHERE is_platform/i, ); + expect(AUTH_SCHEMA_SQL.join('\n')).toMatch( + /ALTER TABLE account ADD COLUMN IF NOT EXISTS name_skipped_at timestamptz/i, + ); + expect(AUTH_SCHEMA_SQL.join('\n')).toMatch( + /ALTER TABLE account ADD COLUMN IF NOT EXISTS lightning_address_skipped_at timestamptz/i, + ); + expect(AUTH_SCHEMA_SQL.join('\n')).toMatch( + /ALTER TABLE account ADD COLUMN IF NOT EXISTS profile_message_id uuid/i, + ); + expect(AUTH_SCHEMA_SQL.join('\n')).not.toMatch(/account_profile_message_id_fkey/); }); }); diff --git a/src/__tests__/lib/message-store.test.ts b/src/__tests__/lib/message-store.test.ts index 1a924d36..e59cc842 100644 --- a/src/__tests__/lib/message-store.test.ts +++ b/src/__tests__/lib/message-store.test.ts @@ -106,6 +106,9 @@ describe('MESSAGE_SCHEMA_SQL', () => { ); expect(MESSAGE_SCHEMA_SQL.join('\n')).toMatch(/message_parent_id_idx/); expect(MESSAGE_SCHEMA_SQL.join('\n')).toMatch(/lnurl_response/); + expect(MESSAGE_SCHEMA_SQL.join('\n')).toMatch(/account_profile_message_id_fkey/); + expect(MESSAGE_SCHEMA_SQL.join('\n')).toMatch(/ON DELETE SET NULL/); + expect(MESSAGE_SCHEMA_SQL.join('\n')).toMatch(/account_profile_message_uidx/); }); }); diff --git a/src/__tests__/lib/nostr/event.test.ts b/src/__tests__/lib/nostr/event.test.ts index 3aaa98c0..d75cf3cc 100644 --- a/src/__tests__/lib/nostr/event.test.ts +++ b/src/__tests__/lib/nostr/event.test.ts @@ -160,6 +160,13 @@ describe('kind0', () => { 'ada@walletofsatoshi.com', ); }); + + it('uses the optional about argument for kind:0 content', () => { + expect(JSON.parse(buildKind0Content('Ada', null, null, 'Hello from Ada')).about).toBe( + 'Hello from Ada', + ); + expect(JSON.parse(buildKind0Event('Ada', null, 1, null, 'Bio').content).about).toBe('Bio'); + }); }); describe('kind10002', () => { diff --git a/src/__tests__/lib/nostr/worker.test.ts b/src/__tests__/lib/nostr/worker.test.ts index 2b247c64..f5a108b5 100644 --- a/src/__tests__/lib/nostr/worker.test.ts +++ b/src/__tests__/lib/nostr/worker.test.ts @@ -50,6 +50,8 @@ async function seed(): Promise<{ messages: InMemoryMessageStore; }> { const auth = new InMemoryAuthStore(); + const messages = new InMemoryMessageStore(); + const profileId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; await auth.createAccount({ id: 'acc', linkingKey: null, @@ -61,9 +63,23 @@ async function seed(): Promise<{ viewKey: 'a'.repeat(64), createdAt: 1, rulesAgreedAt: null, + profileMessageId: profileId, }); await ensureAccountNostrKey(auth, 'acc', KEK); - const messages = new InMemoryMessageStore(); + await messages.create({ + id: profileId, + accountId: 'acc', + name: 'Ada', + text: 'Ada', + createdAt: new Date('2026-08-27T00:00:00.000Z'), + hasPhoto: false, + ...unsignedNostrDefaults(), + // Already published so worker ticks under test do not claim this note. + // Distinct from inbound-test event ids (`aa`/`ab`/…). + eventId: 'f1'.repeat(32), + nostrPublishState: 'published', + nostrEvent: { ...BITCOIN_KIND1, id: 'f1'.repeat(32) }, + }); await messages.create({ id: 'm1', accountId: 'acc', @@ -312,6 +328,70 @@ describe('runNostrWorkerTick', () => { expect(zapped?.sats).toBe(21); }); + it('does not reset a published profile note that lacks Damus hashtags', async () => { + const { auth, messages } = await seed(); + await auth.createAccount({ + id: 'acc-null-profile', + linkingKey: null, + role: 'basis', + name: null, + lightningAddress: null, + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: 'b'.repeat(64), + createdAt: 2, + rulesAgreedAt: null, + profileMessageId: null, + }); + await auth.createAccount({ + id: 'acc-empty-profile', + linkingKey: null, + role: 'basis', + name: null, + lightningAddress: null, + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: 'c'.repeat(64), + createdAt: 3, + rulesAgreedAt: null, + profileMessageId: '', + }); + 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'); + const profileId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + await runNostrWorkerTick( + deps({ + messages, + auth, + kek: KEK, + publisher: new RecordingPublisher(), + now: () => 1_700_000_000_000, + env: {}, + }), + ); + expect((await messages.getById(profileId))?.eventId).toBe('f1'.repeat(32)); + expect((await messages.getById('m-hashtag'))?.eventId).toBeNull(); + }); + it('signs a new post before resetting published notes that lack Damus hashtags', async () => { const { auth, messages } = await seed(); const tags = [ @@ -494,12 +574,61 @@ describe('runNostrWorkerTick', () => { display_name: 'Ada', website: 'https://21.gifts', picture: 'https://21.gifts/apple-touch-icon.png', - about: '21.gifts', + about: 'Ada', }); expect(kinds).toContain(10002); expect(kinds).toContain(1); }); + it('backfills a profile note for named accounts missing one', async () => { + const auth = new InMemoryAuthStore(); + await auth.createAccount({ + id: 'acc2', + linkingKey: null, + role: 'basis', + name: 'Bob', + lightningAddress: null, + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: 'c'.repeat(64), + createdAt: 2, + rulesAgreedAt: null, + }); + await ensureAccountNostrKey(auth, 'acc2', KEK); + const messages = new InMemoryMessageStore(); + await runNostrWorkerTick( + deps({ + messages, + auth, + kek: KEK, + publisher: new RecordingPublisher(), + now: () => 1_700_000_000_000, + env: {}, + }), + ); + const stored = await auth.getAccount('acc2'); + expect(typeof stored?.profileMessageId).toBe('string'); + expect((await messages.getById(stored!.profileMessageId!))?.text).toBe('Bob'); + }); + + it('uses profile note text as kind:0 about', async () => { + const { auth, messages } = await seed(); + 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, + }), + ); + const kind0 = publisher.calls.find((call) => call.event['kind'] === 0); + expect(JSON.parse(String(kind0?.event['content'])).about).toBe('Ada'); + }); + it('publishes kind:10002 with the write-set relays', async () => { const { auth, messages } = await seed(); const publisher = new RecordingPublisher(); diff --git a/src/__tests__/routes/auth.test.ts b/src/__tests__/routes/auth.test.ts index 2e2b6206..c35a9a24 100644 --- a/src/__tests__/routes/auth.test.ts +++ b/src/__tests__/routes/auth.test.ts @@ -236,10 +236,12 @@ describe('auth routes', () => { ), ).toBe(true); + const { InMemoryMessageStore } = await import('@/lib/message-store'); const meApp = new Hono().route( '/me', meRoutes({ store, + messages: new InMemoryMessageStore(), now, payer: new UnconfiguredInvoicePayer(), fetchImpl: globalThis.fetch, diff --git a/src/__tests__/routes/contact.test.ts b/src/__tests__/routes/contact.test.ts index e20dbea1..6c0d9f36 100644 --- a/src/__tests__/routes/contact.test.ts +++ b/src/__tests__/routes/contact.test.ts @@ -61,7 +61,7 @@ async function namedStore(name: string): Promise { if (existing === undefined) { throw new Error('expected account'); } - await store.updateAccount({ ...existing, name }); + await store.updateAccount({ ...existing, name, rulesAgreedAt: now() }); return store; } @@ -152,24 +152,41 @@ describe('POST /contact', () => { expect((await conversations.listMessages(threads[0]!.id, 10))[0]?.text).toBe('hello world'); }); - it('rejects posting without a name', async () => { - const res = await mount(await seededStore()).request('/contact', { + it('returns 409 when posting without a name', async () => { + const store = await seededStore(); + const existing = await store.getAccount('acc'); + expect(existing).toBeDefined(); + if (existing === undefined) { + throw new Error('expected account'); + } + await store.updateAccount({ + ...existing, + rulesAgreedAt: now(), + nameSkippedAt: now(), + }); + const res = await mount(store).request('/contact', { method: 'POST', headers: { ...AUTH, 'content-type': 'application/json' }, body: JSON.stringify({ text: 'hi' }), }); - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ error: 'Set a name before posting' }); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ + error: 'missing_requirements', + missing: ['name'], + }); }); - it('rejects posting with a whitespace-only name', async () => { + it('returns 409 when posting with a whitespace-only name', async () => { const res = await mount(await namedStore(' ')).request('/contact', { method: 'POST', headers: { ...AUTH, 'content-type': 'application/json' }, body: JSON.stringify({ text: 'hi' }), }); - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ error: 'Set a name before posting' }); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ + error: 'missing_requirements', + missing: ['name'], + }); }); it('rejects invalid JSON', async () => { diff --git a/src/__tests__/routes/debug.test.ts b/src/__tests__/routes/debug.test.ts index c937601d..8bcf3309 100644 --- a/src/__tests__/routes/debug.test.ts +++ b/src/__tests__/routes/debug.test.ts @@ -4,6 +4,8 @@ import { InMemoryAuthStore } from '@/lib/auth/store'; import type { FetchFn } from '@/lib/lnurlp'; import { LIGHTNING_ADDRESS_NOT_ZAP } from '@/lib/nip57-probe'; import { InMemoryConversationStore } from '@/lib/conversation-store'; +import { InMemoryMessageStore } from '@/lib/message-store'; +import { InMemoryPushStore } from '@/lib/push-store'; import { debugRoutes } from '@/routes/debug'; const unusedFetch: FetchFn = async () => new Response(null, { status: 500 }); @@ -678,9 +680,16 @@ describe('debugRoutes', () => { it('POST provisions a new account without a passkey', async () => { const store = new InMemoryAuthStore(); + const messageStore = new InMemoryMessageStore(); const app = new Hono().route( '/debug/accounts', - debugRoutes({ store, debugToken: 'secret', fetchImpl: zapCapableFetch() }), + debugRoutes({ + store, + debugToken: 'secret', + fetchImpl: zapCapableFetch(), + messageStore, + pushStore: new InMemoryPushStore(), + }), ); const res = await app.request('/debug/accounts', { method: 'POST', @@ -713,6 +722,10 @@ describe('debugRoutes', () => { viewKey: body.accounts[0]?.viewKey, }); expect(await store.accountHasPasskey(stored!.id)).toBe(false); + expect(stored?.profileMessageId).toEqual(expect.any(String)); + const profileNote = await messageStore.getById(stored!.profileMessageId as string); + expect(profileNote?.text).toBe('Ada'); + expect(profileNote?.parentId).toBeNull(); expect( parsedEvents(warn).some( (e) => @@ -721,6 +734,95 @@ describe('debugRoutes', () => { ).toBe(true); }); + it('POST backfills a profile note when an existing named account lacks one', async () => { + const store = new InMemoryAuthStore(); + const messageStore = new InMemoryMessageStore(); + await store.createAccount({ + id: 'existing', + linkingKey: null, + role: 'basis', + name: 'Ada', + lightningAddress: 'guest@walletofsatoshi.com', + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: 'c'.repeat(64), + createdAt: 1, + rulesAgreedAt: null, + profileMessageId: null, + }); + const app = new Hono().route( + '/debug/accounts', + debugRoutes({ + store, + debugToken: 'secret', + fetchImpl: unusedFetch, + messageStore, + }), + ); + const res = 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(res.status).toBe(200); + const stored = await store.getAccountByLightningAddress('guest@walletofsatoshi.com'); + expect(typeof stored?.profileMessageId).toBe('string'); + expect((await messageStore.getById(stored!.profileMessageId as string))?.text).toBe( + 'Ada Lovelace', + ); + }); + + it('POST backfills a profile note after a create race', 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(); + const messageStore = new InMemoryMessageStore(); + 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, + profileMessageId: null, + }); + const app = new Hono().route( + '/debug/accounts', + debugRoutes({ + store, + debugToken: 'secret', + fetchImpl: zapCapableFetch(), + messageStore, + }), + ); + 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 stored = await store.getAccountByLightningAddress('guest@walletofsatoshi.com'); + expect(stored?.name).toBe('Ada'); + expect(typeof stored?.profileMessageId).toBe('string'); + expect(await messageStore.getById(stored!.profileMessageId as string)).toBeDefined(); + }); + it('POST updates name idempotently for the same address ignoring case', async () => { const store = new InMemoryAuthStore(); const app = new Hono().route( diff --git a/src/__tests__/routes/me.test.ts b/src/__tests__/routes/me.test.ts index 6df301c3..855011d1 100644 --- a/src/__tests__/routes/me.test.ts +++ b/src/__tests__/routes/me.test.ts @@ -5,8 +5,10 @@ import type { InvoicePayer, PayInvoiceResult } from '@/lib/invoice-payer'; import { UnconfiguredInvoicePayer } from '@/lib/invoice-payer'; import { VERIFICATION_TTL_MS } from '@/lib/config'; import type { FetchFn } from '@/lib/lnurlp'; +import { InMemoryMessageStore } from '@/lib/message-store'; import { LIGHTNING_ADDRESS_NOT_ZAP } from '@/lib/nip57-probe'; import { parseNostrKek } from '@/lib/nostr/kek'; +import { InMemoryPushStore } from '@/lib/push-store'; import { bearerToken, meRoutes } from '@/routes/me'; function parsedEvents(warn: ReturnType): Array> { @@ -42,6 +44,8 @@ interface MountOpts { payer?: InvoicePayer; fetchImpl?: FetchFn; clock?: () => number; + messages?: InMemoryMessageStore; + pushStore?: InMemoryPushStore; } function mount(store: InMemoryAuthStore, opts: MountOpts = {}): Hono { @@ -49,10 +53,12 @@ function mount(store: InMemoryAuthStore, opts: MountOpts = {}): Hono { '/me', meRoutes({ store, + messages: opts.messages ?? new InMemoryMessageStore(), now: opts.clock ?? now, payer: opts.payer ?? new UnconfiguredInvoicePayer(), fetchImpl: opts.fetchImpl ?? globalThis.fetch, nostrKek: NOSTR_KEK, + ...(opts.pushStore === undefined ? {} : { pushStore: opts.pushStore }), }), ); } @@ -152,6 +158,7 @@ describe('GET /me', () => { viewKey: string; rulesAgreedAt: number | null; setup: 'name' | 'lightning-address' | 'rules' | null; + missing: string[]; }; expect(body.id).toBe('acc'); expect(body.role).toBe('basis'); @@ -161,6 +168,81 @@ describe('GET /me', () => { expect(body.viewKey).toBe(VIEW_KEY); expect(body.rulesAgreedAt).toBeNull(); expect(body.setup).toBe('name'); + expect(body.missing).toEqual(['name', 'lightning-address', 'rules']); + }); +}); + +describe('POST /me/setup/skip', () => { + it('returns 401 without a session', async () => { + const res = await mount(new InMemoryAuthStore()).request('/me/setup/skip', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ step: 'name' }), + }); + expect(res.status).toBe(401); + }); + + it('rejects step rules and unknown steps', async () => { + const store = await seededStore(); + const rules = await mount(store).request('/me/setup/skip', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ step: 'rules' }), + }); + expect(rules.status).toBe(400); + const bad = await mount(store).request('/me/setup/skip', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ step: 'nope' }), + }); + expect(bad.status).toBe(400); + }); + + it('skips name then GET /me advances setup to lightning-address', async () => { + const store = await seededStore(); + const res = await mount(store).request('/me/setup/skip', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ step: 'name' }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + setup: string | null; + missing: string[]; + name: string | null; + }; + expect(body.setup).toBe('lightning-address'); + expect(body.name).toBeNull(); + expect(body.missing).toContain('name'); + expect((await store.getAccount('acc'))?.nameSkippedAt).toBe(now()); + expect( + parsedEvents(warn).some( + (e) => + e['event'] === 'account.setup.skipped' && + e['accountId'] === 'acc' && + e['step'] === 'name', + ), + ).toBe(true); + const me = await mount(store).request('/me', { headers: AUTH }); + expect(((await me.json()) as { setup: string }).setup).toBe('lightning-address'); + }); + + it('skips lightning-address', async () => { + const store = await seededStore(); + const account = await store.getAccount('acc'); + expect(account).toBeDefined(); + if (account === undefined) { + throw new Error('expected account'); + } + await store.updateAccount({ ...account, name: 'Ada' }); + const res = await mount(store).request('/me/setup/skip', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ step: 'lightning-address' }), + }); + expect(res.status).toBe(200); + expect(((await res.json()) as { setup: string }).setup).toBe('rules'); + expect((await store.getAccount('acc'))?.lightningAddressSkippedAt).toBe(now()); }); }); @@ -379,7 +461,8 @@ describe('POST /me/name', () => { it('trims, stores, and returns the name', async () => { const store = await seededStore(); - const res = await mount(store).request('/me/name', { + 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 ' }), @@ -388,12 +471,61 @@ describe('POST /me/name', () => { const body = (await res.json()) as { name: string | null; viewKey: string }; expect(body.name).toBe('Ada'); expect(body.viewKey).toBe(VIEW_KEY); - expect((await store.getAccount('acc'))?.name).toBe('Ada'); + expect(body).not.toHaveProperty('profileMessageId'); + const stored = await store.getAccount('acc'); + expect(stored?.name).toBe('Ada'); + 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(); + const messages = new InMemoryMessageStore(); + const pushStore = new InMemoryPushStore(); + await pushStore.upsertSubscription({ + accountId: 'other', + endpoint: 'https://push.example/1', + p256dh: 'p', + auth: 'a', + createdAt: new Date(now()), + }); + const res = await mount(store, { messages, pushStore }).request('/me/name', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Ada' }), + }); + expect(res.status).toBe(200); + const pending = await pushStore.claimPending(10, now(), 60_000); + expect(pending.some((row) => row.type === 'forum')).toBe(true); + }); + + it('does not create a second profile note or change its text on rename', async () => { + const store = await seededStore(); + const messages = new InMemoryMessageStore(); + const first = await mount(store, { messages }).request('/me/name', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Ada' }), + }); + expect(first.status).toBe(200); + const profileId = (await store.getAccount('acc'))?.profileMessageId; + expect(typeof profileId).toBe('string'); + const second = await mount(store, { messages }).request('/me/name', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Ada Lovelace' }), + }); + expect(second.status).toBe(200); + expect((await store.getAccount('acc'))?.profileMessageId).toBe(profileId); + expect((await messages.getById(profileId!))?.text).toBe('Ada'); + expect((await messages.listLatest(10)).filter((row) => row.parentId === null)).toHaveLength(1); + }); + it('keeps a previously stored lightning address when setting a name', async () => { const store = await seededStore({ lightningAddress: ADDRESS }); const res = await mount(store).request('/me/name', { @@ -712,6 +844,7 @@ describe('POST /me/lightning-address', () => { '/me', meRoutes({ store, + messages: new InMemoryMessageStore(), now, payer: new UnconfiguredInvoicePayer(), fetchImpl: happyFetch(), @@ -820,7 +953,11 @@ describe('DELETE /me/lightning-address', () => { it('unlinks the address and clears pending verification', async () => { const store = await seededStore({ lightningAddress: ADDRESS }); const existing = await store.getAccount('acc'); - await store.updateAccount({ ...existing!, name: 'Ada' }); + await store.updateAccount({ + ...existing!, + name: 'Ada', + lightningAddressSkippedAt: 99, + }); await store.putVerification({ accountId: 'acc', address: ADDRESS, @@ -838,7 +975,9 @@ describe('DELETE /me/lightning-address', () => { }; expect(body.lightningAddress).toBeNull(); expect(body.setup).toBe('lightning-address'); - expect((await store.getAccount('acc'))?.lightningAddress).toBeNull(); + const stored = await store.getAccount('acc'); + expect(stored?.lightningAddress).toBeNull(); + expect(stored?.lightningAddressSkippedAt).toBeNull(); expect(await store.getVerification('acc')).toBeUndefined(); expect( parsedEvents(warn).some( diff --git a/src/__tests__/routes/members.test.ts b/src/__tests__/routes/members.test.ts new file mode 100644 index 00000000..6439e23c --- /dev/null +++ b/src/__tests__/routes/members.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Hono } from 'hono'; +import { InMemoryAuthStore } from '@/lib/auth/store'; +import { unsignedNostrDefaults } from '@/lib/message'; +import { InMemoryMessageStore } from '@/lib/message-store'; +import { membersRoutes } from '@/routes/members'; + +const now = (): number => 1_700_000_000_000; +const AUTH = { authorization: 'Bearer tok' }; +const ACCOUNT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + +function mount( + authStore: InMemoryAuthStore, + messageStore: InMemoryMessageStore = new InMemoryMessageStore(), +): Hono { + return new Hono().route('/members', membersRoutes({ authStore, messageStore, now })); +} + +async function seededCaller( + overrides: { rulesAgreedAt?: number | null } = {}, +): Promise { + const store = new InMemoryAuthStore(); + await store.createAccount({ + id: 'caller', + linkingKey: null, + role: 'basis', + name: 'Caller', + lightningAddress: null, + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: 'a'.repeat(64), + createdAt: 1, + rulesAgreedAt: overrides.rulesAgreedAt === undefined ? now() : overrides.rulesAgreedAt, + }); + await store.createSession({ token: 'tok', accountId: 'caller', createdAt: now() }); + return store; +} + +describe('GET /members/:accountId', () => { + it('returns 401 without a bearer', async () => { + const res = await mount(new InMemoryAuthStore()).request(`/members/${ACCOUNT_ID}`); + expect(res.status).toBe(401); + }); + + it('returns 409 when the caller lacks rules agreement', async () => { + const res = await mount(await seededCaller({ rulesAgreedAt: null })).request( + `/members/${ACCOUNT_ID}`, + { headers: AUTH }, + ); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ + error: 'missing_requirements', + missing: ['rules'], + }); + }); + + it('returns 404 for a non-uuid id', async () => { + const res = await mount(await seededCaller()).request('/members/not-a-uuid', { + headers: AUTH, + }); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'Not found' }); + }); + + it('returns 404 when the account is unknown', async () => { + const res = await mount(await seededCaller()).request(`/members/${ACCOUNT_ID}`, { + headers: AUTH, + }); + expect(res.status).toBe(404); + }); + + it('returns live identity with a profileMessage', async () => { + const authStore = await seededCaller(); + const messageStore = new InMemoryMessageStore(); + const noteId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + await authStore.createAccount({ + id: ACCOUNT_ID, + linkingKey: null, + role: 'verified', + name: 'Ada', + lightningAddress: 'ada@walletofsatoshi.com', + lightningAddressVerified: true, + forumLawsDismissed: false, + viewKey: 'b'.repeat(64), + createdAt: 1_700_000_000_000, + rulesAgreedAt: now(), + profileMessageId: noteId, + }); + await messageStore.create({ + id: noteId, + accountId: ACCOUNT_ID, + name: 'Ada', + text: 'Ada', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + eventId: 'ee'.repeat(32), + }); + const res = await mount(authStore, messageStore).request(`/members/${ACCOUNT_ID}`, { + headers: AUTH, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).toMatchObject({ + id: ACCOUNT_ID, + name: 'Ada', + role: 'verified', + lightningAddress: 'ada@walletofsatoshi.com', + createdAt: new Date(1_700_000_000_000).toISOString(), + }); + expect(body).not.toHaveProperty('viewKey'); + expect(body).not.toHaveProperty('eventId'); + expect(body).not.toHaveProperty('linkingKey'); + const profile = body['profileMessage'] as Record; + expect(profile['text']).toBe('Ada'); + expect(profile['accountId']).toBe(ACCOUNT_ID); + expect(profile['payable']).toBe(true); + expect(profile).not.toHaveProperty('eventId'); + }); + + it('returns profileMessage null when no note exists', async () => { + const authStore = await seededCaller(); + await authStore.createAccount({ + id: ACCOUNT_ID, + linkingKey: null, + role: 'basis', + name: 'Ada', + lightningAddress: null, + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: 'b'.repeat(64), + createdAt: 1, + rulesAgreedAt: now(), + }); + const res = await mount(authStore).request(`/members/${ACCOUNT_ID}`, { headers: AUTH }); + expect(res.status).toBe(200); + const body = (await res.json()) as { profileMessage: null }; + expect(body.profileMessage).toBeNull(); + }); + + it('returns 503 when getAccount throws', async () => { + const authStore = await seededCaller(); + const original = authStore.getAccount.bind(authStore); + vi.spyOn(authStore, 'getAccount').mockImplementation(async (id: string) => { + if (id === ACCOUNT_ID) { + throw new Error('store down'); + } + return original(id); + }); + const res = await mount(authStore).request(`/members/${ACCOUNT_ID}`, { headers: AUTH }); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ error: 'Messages are unavailable' }); + }); +}); diff --git a/src/__tests__/routes/messages.test.ts b/src/__tests__/routes/messages.test.ts index b6c90163..a1e3d0b3 100644 --- a/src/__tests__/routes/messages.test.ts +++ b/src/__tests__/routes/messages.test.ts @@ -88,7 +88,26 @@ async function namedStore(name: string): Promise { if (existing === undefined) { throw new Error('expected account'); } - await store.updateAccount({ ...existing, name }); + await store.updateAccount({ ...existing, name, rulesAgreedAt: now() }); + return store; +} + +/** Signed-in account with rules agreed (name may still be missing). */ +async function rulesStore( + overrides: { name?: string | null; nameSkippedAt?: number | null } = {}, +): Promise { + const store = await seededStore(); + const existing = await store.getAccount('acc'); + expect(existing).toBeDefined(); + if (existing === undefined) { + throw new Error('expected account'); + } + await store.updateAccount({ + ...existing, + name: overrides.name === undefined ? existing.name : overrides.name, + rulesAgreedAt: now(), + ...(overrides.nameSkippedAt === undefined ? {} : { nameSkippedAt: overrides.nameSkippedAt }), + }); return store; } @@ -153,8 +172,17 @@ describe('GET /messages', () => { expect(res.status).toBe(401); }); - it('returns an empty list', async () => { + it('returns 409 when rules are not agreed', async () => { const res = await mount(await seededStore()).request('/messages', { headers: AUTH }); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ + error: 'missing_requirements', + missing: ['rules'], + }); + }); + + it('returns an empty list', async () => { + const res = await mount(await rulesStore()).request('/messages', { headers: AUTH }); expect(res.status).toBe(200); expect(await res.json()).toEqual({ messages: [] }); }); @@ -375,7 +403,7 @@ describe('GET /messages', () => { }); it('defaults role to basis and payable to false when the author is missing', async () => { - const authStore = await seededStore(); + const authStore = await rulesStore(); const messageStore = new InMemoryMessageStore(); await messageStore.create({ id: 'orphan', @@ -395,7 +423,7 @@ describe('GET /messages', () => { }); it('returns 503 and logs when listLatest throws', async () => { - const res = await mount(await seededStore(), throwingStore()).request('/messages', { + const res = await mount(await rulesStore(), throwingStore()).request('/messages', { headers: AUTH, }); expect(res.status).toBe(503); @@ -404,7 +432,7 @@ describe('GET /messages', () => { }); it('lists a Damus-only note as not payable with role omitted', async () => { - const authStore = await seededStore(); + const authStore = await rulesStore(); const messageStore = new InMemoryMessageStore(); await messageStore.create({ id: 'damus-list', @@ -621,24 +649,45 @@ describe('POST /messages', () => { expect(body.role).toBe('moderator'); }); - it('rejects posting without a name', async () => { - const res = await mount(await seededStore()).request('/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' }), + }, + ); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ + error: 'missing_requirements', + missing: ['name'], + }); + }); + + it('returns 409 when posting with a whitespace-only name', async () => { + const res = await mount(await namedStore(' ')).request('/messages', { method: 'POST', headers: { ...AUTH, 'content-type': 'application/json' }, body: JSON.stringify({ text: 'hi' }), }); - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ error: 'Set a name before posting' }); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ + error: 'missing_requirements', + missing: ['name'], + }); }); - it('rejects posting with a whitespace-only name', async () => { - const res = await mount(await namedStore(' ')).request('/messages', { + it('posts without a Lightning Address and returns payable false', async () => { + const res = await mount(await namedStore('Ada')).request('/messages', { method: 'POST', headers: { ...AUTH, 'content-type': 'application/json' }, body: JSON.stringify({ text: 'hi' }), }); - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ error: 'Set a name before posting' }); + 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'); }); it('rejects invalid JSON', async () => { @@ -886,6 +935,46 @@ describe('POST /messages', () => { }); describe('POST /messages/:id/invoice', () => { + it('returns 409 when the payer has not agreed to rules', async () => { + const res = await mount(await seededStore()).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(409); + expect(await res.json()).toEqual({ + error: 'missing_requirements', + missing: ['rules'], + }); + }); + + it('returns 400 not 409 lightning-address when the note is unsigned', async () => { + const authStore = await namedStore('Ada'); + const messageStore = new InMemoryMessageStore(); + await messageStore.create({ + id: '11111111-1111-4111-8111-111111111111', + accountId: 'acc', + name: 'Ada', + text: 'hi', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + }); + const res = await mount(authStore, messageStore).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(400); + expect(await res.json()).toEqual({ error: 'This message cannot be paid yet' }); + }); + it('returns 429 on a burst of invoice requests', async () => { const { parseNostrKek } = await import('@/lib/nostr/kek'); const { ensureAccountNostrKey } = await import('@/lib/nostr/keys'); @@ -1268,7 +1357,7 @@ describe('POST /messages/:id/invoice', () => { forumLawsDismissed: false, viewKey: 'b'.repeat(64), createdAt: 1_000_001, - rulesAgreedAt: null, + rulesAgreedAt: now(), }); await authStore.createSession({ token: 'payer-tok', accountId: 'payer', createdAt: now() }); expect(await authStore.getNostrPublicKey('payer')).toBeUndefined(); @@ -2897,12 +2986,19 @@ 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 seededStore()).request('/messages', { - method: 'POST', - headers: AUTH, - body: form, + const res = await mount(await rulesStore({ name: null, nameSkippedAt: now() })).request( + '/messages', + { + method: 'POST', + headers: AUTH, + body: form, + }, + ); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ + error: 'missing_requirements', + missing: ['name'], }); - expect(res.status).toBe(400); }); it('returns 503 when video create throws', async () => { diff --git a/src/lib/auth/account-json.ts b/src/lib/auth/account-json.ts index ae2f4a27..f390aecf 100644 --- a/src/lib/auth/account-json.ts +++ b/src/lib/auth/account-json.ts @@ -1,4 +1,9 @@ -import { accountSetup, type AccountSetup } from '@/lib/auth/account-setup'; +import { + accountMissing, + accountSetup, + type AccountMissingField, + type AccountSetup, +} from '@/lib/auth/account-setup'; import type { Account } from '@/lib/auth/store'; /** @@ -29,17 +34,23 @@ export interface AccountResponse { /** * Owner-facing account JSON: the nine public fields plus the durable - * view-key capability secret and the next `setup` step. + * view-key capability secret, the next `setup` step, and factual `missing`. */ export interface OwnerAccountResponse extends AccountResponse { /** 64 lowercase hex; capability URL secret for `GET /view/:viewKey`. */ viewKey: string; /** * Next setup step the owner must complete (`name`, `lightning-address`, - * `rules`), or `null` when the signed-in app is allowed. Computed on the - * api; clients must not invent a parallel sequence. + * `rules`), or `null` when the signed-in app is allowed. Skip timestamps + * count as done for the wizard. Computed on the api; clients must not + * invent a parallel sequence. */ setup: AccountSetup; + /** + * Factually unset fields (skip does not clear them). Order: `name`, + * `lightning-address`, `rules`. Used by clients alongside action gates. + */ + missing: AccountMissingField[]; } /** @@ -109,16 +120,17 @@ export function serializeDebugAccount(account: Account): DebugAccountResponse { * Project an account for the owner (`GET /me`, profile writes, passkey finish). * * Includes `viewKey` so the owner can copy the capability URL. Never used - * by the operator debug listing. + * by the operator debug listing. Does not expose `profileMessageId`. * * @param account - Stored account. - * @returns Eleven fields including `viewKey` and `setup`. + * @returns Twelve fields including `viewKey`, `setup`, and `missing`. */ export function serializeOwnerAccount(account: Account): OwnerAccountResponse { return { ...serializeAccount(account), viewKey: account.viewKey, setup: accountSetup(account), + missing: accountMissing(account), }; } diff --git a/src/lib/auth/account-setup.ts b/src/lib/auth/account-setup.ts index add0b59b..a653d6dc 100644 --- a/src/lib/auth/account-setup.ts +++ b/src/lib/auth/account-setup.ts @@ -4,24 +4,38 @@ import type { Account } from '@/lib/auth/store'; * Next owner setup step. The api is the source of truth; clients only route. * * Order matches onboarding: name, then Lightning Address, then living-room - * rules. `null` means the account may use the signed-in app. + * rules. Skip timestamps count as done for the wizard. `null` means the + * account may use the signed-in app. */ export type AccountSetup = 'name' | 'lightning-address' | 'rules' | null; +/** + * Account fields that are factually unset (skip does not count). + * + * Used by action gates via {@link requireAction}; order is + * `name`, `lightning-address`, `rules`. + */ +export type AccountMissingField = 'name' | 'lightning-address' | 'rules'; + /** * Compute the next setup step from stored account fields. * - * Missing name, missing Lightning Address, or missing rules agreement each - * block later screens. Blank strings after trim count as missing. + * A skip timestamp counts as completing that wizard step. Blank strings + * after trim count as missing unless skipped. * * @param account - Stored account. * @returns The next required step, or `null` when setup is complete. */ export function accountSetup(account: Account): AccountSetup { - if (account.name === null || account.name.trim() === '') { + const nameBlank = account.name === null || account.name.trim() === ''; + const nameSkipped = account.nameSkippedAt !== null && account.nameSkippedAt !== undefined; + if (nameBlank && !nameSkipped) { return 'name'; } - if (account.lightningAddress === null || account.lightningAddress.trim() === '') { + const lnBlank = account.lightningAddress === null || account.lightningAddress.trim() === ''; + const lnSkipped = + account.lightningAddressSkippedAt !== null && account.lightningAddressSkippedAt !== undefined; + if (lnBlank && !lnSkipped) { return 'lightning-address'; } if (account.rulesAgreedAt === null) { @@ -29,3 +43,23 @@ export function accountSetup(account: Account): AccountSetup { } return null; } + +/** + * Factually missing account fields (skip timestamps do not clear them). + * + * @param account - Stored account. + * @returns Missing fields in order: name, lightning-address, rules. + */ +export function accountMissing(account: Account): AccountMissingField[] { + const missing: AccountMissingField[] = []; + if (account.name === null || account.name.trim() === '') { + missing.push('name'); + } + if (account.lightningAddress === null || account.lightningAddress.trim() === '') { + missing.push('lightning-address'); + } + if (account.rulesAgreedAt === null) { + missing.push('rules'); + } + return missing; +} diff --git a/src/lib/auth/passkey.ts b/src/lib/auth/passkey.ts index 89c02b74..0f952586 100644 --- a/src/lib/auth/passkey.ts +++ b/src/lib/auth/passkey.ts @@ -235,6 +235,9 @@ export async function finishPasskeyRegistration( viewKey: randomHex(32), createdAt: now, rulesAgreedAt: null, + nameSkippedAt: null, + lightningAddressSkippedAt: null, + profileMessageId: null, }; await store.createAccount(account); if (nostr !== undefined) { diff --git a/src/lib/auth/postgres-store.ts b/src/lib/auth/postgres-store.ts index 70596b37..143935ef 100644 --- a/src/lib/auth/postgres-store.ts +++ b/src/lib/auth/postgres-store.ts @@ -26,9 +26,12 @@ interface AccountRow { created_at: Date | string; rules_agreed_at: Date | string | null; is_platform?: boolean | null; + name_skipped_at?: Date | string | null; + lightning_address_skipped_at?: Date | string | null; + profile_message_id?: string | null; } -const ACCOUNT_SELECT_COLUMNS = `id, linking_key, role, name, lightning_address, lightning_address_verified, forum_laws_dismissed, view_key, created_at, rules_agreed_at, is_platform`; +const ACCOUNT_SELECT_COLUMNS = `id, linking_key, role, name, lightning_address, lightning_address_verified, forum_laws_dismissed, view_key, created_at, rules_agreed_at, is_platform, name_skipped_at, lightning_address_skipped_at, profile_message_id`; /** Row shape of `auth_session`. */ interface SessionRow { @@ -98,8 +101,8 @@ export class PostgresAuthStore implements AuthStore { ); } await this.#sql.execute( - `INSERT INTO account (id, linking_key, role, name, lightning_address, lightning_address_verified, forum_laws_dismissed, created_at, view_key, rules_agreed_at, is_platform) - VALUES ($1, $2, $3, $4, $5, $6, $7, to_timestamp($8::double precision / 1000.0), $9, to_timestamp($10::double precision / 1000.0), $11) + `INSERT INTO account (id, linking_key, role, name, lightning_address, lightning_address_verified, forum_laws_dismissed, created_at, view_key, rules_agreed_at, is_platform, name_skipped_at, lightning_address_skipped_at, profile_message_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, to_timestamp($8::double precision / 1000.0), $9, to_timestamp($10::double precision / 1000.0), $11, to_timestamp($12::double precision / 1000.0), to_timestamp($13::double precision / 1000.0), $14) ON CONFLICT (linking_key) DO NOTHING`, [ account.id, @@ -113,6 +116,9 @@ export class PostgresAuthStore implements AuthStore { account.viewKey, account.rulesAgreedAt, account.isPlatform === true, + account.nameSkippedAt ?? null, + account.lightningAddressSkippedAt ?? null, + account.profileMessageId ?? null, ], ); } catch (error: unknown) { @@ -137,7 +143,10 @@ export class PostgresAuthStore implements AuthStore { forum_laws_dismissed = $7, created_at = to_timestamp($8::double precision / 1000.0), view_key = $9, rules_agreed_at = to_timestamp($10::double precision / 1000.0), - is_platform = $11 + is_platform = $11, + name_skipped_at = to_timestamp($12::double precision / 1000.0), + lightning_address_skipped_at = to_timestamp($13::double precision / 1000.0), + profile_message_id = $14 WHERE id = $1 AND ( $2::text IS NULL @@ -158,6 +167,9 @@ export class PostgresAuthStore implements AuthStore { account.viewKey, account.rulesAgreedAt, account.isPlatform === true, + account.nameSkippedAt ?? null, + account.lightningAddressSkippedAt ?? null, + account.profileMessageId ?? null, ], ); } catch (error: unknown) { @@ -506,6 +518,15 @@ function mapAccount(row: AccountRow): Account | undefined { createdAt: epochMs(row.created_at), rulesAgreedAt: row.rules_agreed_at === null ? null : epochMs(row.rules_agreed_at), isPlatform: row.is_platform === true, + nameSkippedAt: + row.name_skipped_at === null || row.name_skipped_at === undefined + ? null + : epochMs(row.name_skipped_at), + lightningAddressSkippedAt: + row.lightning_address_skipped_at === null || row.lightning_address_skipped_at === undefined + ? null + : epochMs(row.lightning_address_skipped_at), + profileMessageId: row.profile_message_id ?? null, }; } diff --git a/src/lib/auth/profile-message.ts b/src/lib/auth/profile-message.ts new file mode 100644 index 00000000..386fc28b --- /dev/null +++ b/src/lib/auth/profile-message.ts @@ -0,0 +1,102 @@ +import type { Account, AuthStore } from '@/lib/auth/store'; +import { logEvent } from '@/lib/log'; +import { unsignedNostrDefaults, type MessageRow } from '@/lib/message'; +import type { MessageStore } from '@/lib/message-store'; +import type { PushStore } from '@/lib/push-store'; +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. + * + * 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). + * + * @param args - Auth store, message store, account snapshot, clock, optional push. + * @returns The account (unchanged, or with `profileMessageId` set after insert). + */ +export async function ensureProfileMessage(args: { + auth: AuthStore; + messages: MessageStore; + account: Account; + now: () => number; + pushStore?: PushStore; +}): Promise { + const trimmed = args.account.name === null ? '' : args.account.name.trim(); + if (trimmed === '') { + return args.account; + } + + const existingId = args.account.profileMessageId; + if (typeof existingId === 'string' && existingId.trim() !== '') { + const existing = await args.messages.getById(existingId); + if (existing !== undefined) { + return args.account; + } + } + + const messageId = crypto.randomUUID(); + const row: MessageRow = { + id: messageId, + accountId: args.account.id, + name: trimmed, + text: trimmed, + createdAt: new Date(args.now()), + hasPhoto: false, + hasVideo: false, + videoContentType: null, + ...unsignedNostrDefaults(), + }; + + let created: MessageRow; + try { + created = await args.messages.create(row); + } catch { + return args.account; + } + + const live = await args.auth.getAccount(args.account.id); + if (live === undefined) { + await args.messages.deleteById(created.id); + return args.account; + } + const liveId = live.profileMessageId; + if (typeof liveId === 'string' && liveId.trim() !== '') { + const winner = await args.messages.getById(liveId); + if (winner !== undefined) { + await args.messages.deleteById(created.id); + return live; + } + } + + const updated: Account = { + ...live, + profileMessageId: created.id, + }; + try { + await args.auth.updateAccount(updated); + } catch { + await args.messages.deleteById(created.id); + return live; + } + + const confirmed = await args.auth.getAccount(args.account.id); + if (confirmed === undefined || confirmed.profileMessageId !== created.id) { + await args.messages.deleteById(created.id); + return confirmed === undefined ? live : confirmed; + } + + if (args.pushStore !== undefined) { + try { + await enqueueForumPushes(args.pushStore, args.account.id, created.id, args.now()); + } catch { + logEvent('push.enqueue.failed'); + } + } + return updated; +} diff --git a/src/lib/auth/requirements.ts b/src/lib/auth/requirements.ts new file mode 100644 index 00000000..54c3dc9a --- /dev/null +++ b/src/lib/auth/requirements.ts @@ -0,0 +1,60 @@ +import { accountMissing, type AccountMissingField } from '@/lib/auth/account-setup'; +import type { Account } from '@/lib/auth/store'; + +/** + * Signed-in actions that gate on account fields via {@link requireAction}. + */ +export type AccountAction = 'forum.read' | 'forum.post' | 'contact.post' | 'forum.pay'; + +/** Stable 409 error string when {@link requireAction} fails. */ +export const MISSING_REQUIREMENTS_ERROR = 'missing_requirements'; + +/** Conflict body when an action's required fields are missing. */ +export interface MissingRequirementsBody { + /** Always {@link MISSING_REQUIREMENTS_ERROR}. */ + error: typeof MISSING_REQUIREMENTS_ERROR; + /** Non-empty list in action order: `rules`, then `name`. */ + missing: AccountMissingField[]; +} + +/** Action → required fields. Order is the 409 `missing` order. */ +const ACTION_NEEDS: Record = { + 'forum.read': ['rules'], + 'forum.post': ['rules', 'name'], + 'contact.post': ['rules', 'name'], + 'forum.pay': ['rules'], +}; + +/** + * Fields an action requires before it may proceed. + * + * @param action - Gated action. + * @returns Required missing-field names in 409 order. + */ +export function actionRequirements(action: AccountAction): readonly AccountMissingField[] { + return ACTION_NEEDS[action]; +} + +/** + * Whether the account satisfies an action's field requirements. + * + * Skip timestamps do not satisfy {@link accountMissing}; only real field + * values count. Filters to the action's needs and preserves + * {@link actionRequirements} order. + * + * @param account - Authenticated account. + * @param action - Gated action. + * @returns `{ ok: true }` or `{ ok: false, missing }` (never empty). + */ +export function requireAction( + account: Account, + action: AccountAction, +): { ok: true } | { ok: false; missing: AccountMissingField[] } { + const needed = actionRequirements(action); + const present = new Set(accountMissing(account)); + const missing = needed.filter((field) => present.has(field)); + if (missing.length === 0) { + return { ok: true }; + } + return { ok: false, missing: [...missing] }; +} diff --git a/src/lib/auth/schema.ts b/src/lib/auth/schema.ts index 1e532c2e..b20f6057 100644 --- a/src/lib/auth/schema.ts +++ b/src/lib/auth/schema.ts @@ -69,4 +69,8 @@ export const AUTH_SCHEMA_SQL: readonly string[] = [ `CREATE UNIQUE INDEX IF NOT EXISTS passkey_credential_account_uidx ON passkey_credential (account_id)`, `ALTER TABLE account ADD COLUMN IF NOT EXISTS is_platform boolean NOT NULL DEFAULT false`, `CREATE UNIQUE INDEX IF NOT EXISTS account_is_platform_uidx ON account (is_platform) WHERE is_platform`, + // Skip / profile-note columns: no FK to message here (auth migrates before message). + `ALTER TABLE account ADD COLUMN IF NOT EXISTS name_skipped_at timestamptz`, + `ALTER TABLE account ADD COLUMN IF NOT EXISTS lightning_address_skipped_at timestamptz`, + `ALTER TABLE account ADD COLUMN IF NOT EXISTS profile_message_id uuid`, ]; diff --git a/src/lib/auth/store.ts b/src/lib/auth/store.ts index 8a266e58..bcd4fe84 100644 --- a/src/lib/auth/store.ts +++ b/src/lib/auth/store.ts @@ -56,6 +56,12 @@ export interface Account { * stored account may be true. Default false. Omitted on member `GET /me`. */ isPlatform?: boolean; + /** Epoch ms when the owner skipped the name wizard step, or null/omitted. */ + nameSkippedAt?: number | null; + /** Epoch ms when the owner skipped the Lightning Address wizard step, or null/omitted. */ + lightningAddressSkippedAt?: number | null; + /** Id of the single top-level profile forum message, or null/omitted. */ + profileMessageId?: string | null; } /** diff --git a/src/lib/message-store.ts b/src/lib/message-store.ts index 3c31562a..89de20c1 100644 --- a/src/lib/message-store.ts +++ b/src/lib/message-store.ts @@ -374,6 +374,11 @@ export const MESSAGE_SCHEMA_SQL: readonly string[] = [ 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)`, + `ALTER TABLE account DROP CONSTRAINT IF EXISTS account_profile_message_id_fkey`, + `ALTER TABLE account ADD CONSTRAINT account_profile_message_id_fkey + FOREIGN KEY (profile_message_id) REFERENCES message (id) ON DELETE SET NULL`, + `CREATE UNIQUE INDEX IF NOT EXISTS account_profile_message_uidx + ON account (profile_message_id) WHERE profile_message_id IS NOT NULL`, ]; /** diff --git a/src/lib/nostr/event.ts b/src/lib/nostr/event.ts index f27a7fd3..1a252277 100644 --- a/src/lib/nostr/event.ts +++ b/src/lib/nostr/event.ts @@ -226,25 +226,28 @@ export interface Kind0ProfileContent { * Build kind:0 `content` JSON (no extra whitespace). * * Omit `lud16` when the account has no Lightning Address. Always set `picture` - * to {@link KIND0_PICTURE_URL} and `about` to `21.gifts`. Set `nip05` when a - * public host is available. Never set `bot`. + * to {@link KIND0_PICTURE_URL}. `about` defaults to `21.gifts` and is the + * profile-note text when the worker passes it. Set `nip05` when a public host + * is available. Never set `bot`. * * @param name - Non-null display name. * @param lightningAddress - Linked LUD-16, or `null`. * @param nip05 - NIP-05 identifier, or `null`. + * @param about - Kind:0 about text (profile note, or default `21.gifts`). * @returns JSON string for the kind:0 `content` field. */ export function buildKind0Content( name: string, lightningAddress: string | null, nip05: string | null = null, + about: string = '21.gifts', ): string { const body: Kind0ProfileContent = { name, display_name: name, website: 'https://21.gifts', picture: KIND0_PICTURE_URL, - about: '21.gifts', + about, }; if (lightningAddress !== null) { body.lud16 = lightningAddress; @@ -274,6 +277,7 @@ export interface UnsignedKind0 { * @param lightningAddress - Linked LUD-16, or `null`. * @param createdAtUnix - Unix seconds at enqueue/publish. * @param nip05 - NIP-05 identifier, or `null`. + * @param about - Kind:0 about text (profile note, or default `21.gifts`). * @returns Unsigned event fields for `finalizeEvent`. */ export function buildKind0Event( @@ -281,10 +285,11 @@ export function buildKind0Event( lightningAddress: string | null, createdAtUnix: number, nip05: string | null = null, + about: string = '21.gifts', ): UnsignedKind0 { return { kind: 0, - content: buildKind0Content(name, lightningAddress, nip05), + content: buildKind0Content(name, lightningAddress, nip05, about), tags: [], created_at: createdAtUnix, }; diff --git a/src/lib/nostr/worker.ts b/src/lib/nostr/worker.ts index cbc60bf0..913a20a9 100644 --- a/src/lib/nostr/worker.ts +++ b/src/lib/nostr/worker.ts @@ -1,5 +1,6 @@ import { readFile } from 'node:fs/promises'; import { verifyEvent, type NostrEvent } from 'nostr-tools/pure'; +import { ensureProfileMessage } from '@/lib/auth/profile-message'; import type { Account, AuthStore } from '@/lib/auth/store'; import type { ConversationThread } from '@/lib/conversation'; import type { ConversationStore } from '@/lib/conversation-store'; @@ -129,7 +130,8 @@ function reservedContent( * `t=bitcoin` is dropped and re-signed before fan-out. Then unsigned rows are * signed. Then published unpaid rows missing a photo URL or a video URL * (`PUBLIC_BASE_URL` set) or Damus `#bitcoin`/`#21gifts` in content are reset - * for the next tick. Pending rows EVENT as-is — resetting them first renews + * for the next tick (`profileMessageId` rows are skipped so a name note is + * not rewritten with those hashtags). Pending rows EVENT as-is — resetting them first renews * the 60s sign lease and they never reach a relay. Zapped rows (`sats !== 0`) * keep their event id so receipts still resolve. An empty API base skips * photo- and video-URL resign so it cannot un-publish and loop. When @@ -177,6 +179,7 @@ export async function runNostrWorkerTick(deps: NostrWorkerDeps): Promise { }); await indexInboundForumReplies(deps, urls); await indexInboundDirectMessages(deps, urls); + await backfillProfileMessages(deps); } /** @@ -402,7 +405,17 @@ async function resignVideoKind1(deps: NostrWorkerDeps): Promise { } async function resignHashtagKind1(deps: NostrWorkerDeps): Promise { - await resetPublishedBatch(deps, await deps.messages.listSignedMissingHashtags(WORKER_BATCH)); + const rows = await deps.messages.listSignedMissingHashtags(WORKER_BATCH); + const accounts = await deps.auth.listAccounts(); + const profileIds = new Set( + accounts + .map((account) => account.profileMessageId) + .filter((id): id is string => typeof id === 'string' && id !== ''), + ); + await resetPublishedBatch( + deps, + rows.filter((row) => !profileIds.has(row.id)), + ); } function kind1HasBitcoinTag(event: Record | null): boolean { @@ -517,6 +530,34 @@ 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). + * + * @param deps - Auth and message stores (and optional push). + */ +async function backfillProfileMessages(deps: NostrWorkerDeps): Promise { + const accounts = await deps.auth.listAccounts(); + for (const account of accounts) { + if (account.name === null || account.name.trim() === '') { + continue; + } + const profileId = account.profileMessageId; + if (typeof profileId === 'string' && profileId.trim() !== '') { + const existing = await deps.messages.getById(profileId); + if (existing !== undefined) { + continue; + } + } + await ensureProfileMessage({ + auth: deps.auth, + messages: deps.messages, + account, + now: deps.now, + }); + } +} + async function publishProfiles(deps: NostrWorkerDeps, writeSet: ResolvedWriteSet): Promise { const cache = profileCacheFor(deps.auth); const watermarks = profileWatermarkFor(deps.auth); @@ -535,7 +576,15 @@ async function publishProfiles(deps: NostrWorkerDeps, writeSet: ResolvedWriteSet } const namedForLive = named.map((row) => (row.id === live.id ? live : row)); const nip05 = domain === null ? null : nip05Identifier(live, namedForLive, domain); - const content = buildKind0Content(live.name, live.lightningAddress, nip05); + let about = '21.gifts'; + const profileId = live.profileMessageId; + if (typeof profileId === 'string' && profileId.trim() !== '') { + const note = await deps.messages.getById(profileId); + if (note !== undefined) { + about = note.text; + } + } + const content = buildKind0Content(live.name, live.lightningAddress, nip05, about); if (reservedContent(cache, live.id) === content) { continue; } @@ -565,6 +614,7 @@ async function publishProfiles(deps: NostrWorkerDeps, writeSet: ResolvedWriteSet live.lightningAddress, reservation.createdAt, nip05, + about, ); const signed = await signEventForAccount(deps.auth, live.id, deps.kek, unsigned); if (cache.get(live.id) !== reservation) { diff --git a/src/routes/contact.ts b/src/routes/contact.ts index c3aca3d1..04272aeb 100644 --- a/src/routes/contact.ts +++ b/src/routes/contact.ts @@ -1,6 +1,7 @@ import { Hono } from 'hono'; import { z } from 'zod'; import { resolveSession } from '@/lib/auth/service'; +import { MISSING_REQUIREMENTS_ERROR, requireAction } from '@/lib/auth/requirements'; import type { Account, AuthStore } from '@/lib/auth/store'; import { serializeContact, type ContactRow } from '@/lib/contact'; import type { ContactStore } from '@/lib/contact-store'; @@ -11,9 +12,9 @@ import { normalizeForumText } from '@/lib/message'; import { bearerToken } from '@/routes/me'; /** - * `/contact` — signed-in member private mailbox to 21.gifts. Posts when the - * account has a display name. Shares the {@link AuthStore} with `/auth` and - * `/me`. Never listed publicly. + * `/contact` — signed-in member private mailbox to 21.gifts. Requires rules + * agreement and a display name (`requireAction` `contact.post`). Shares the + * {@link AuthStore} with `/auth` and `/me`. Never listed publicly. */ /** Collaborators the `/contact` routes need. */ @@ -57,13 +58,16 @@ export function contactRoutes(deps: ContactRouteDeps): Hono { if (account === null) { return c.json({ error: 'Unauthorized' }, 401); } + const gate = requireAction(account, 'contact.post'); + if (!gate.ok) { + return c.json({ error: MISSING_REQUIREMENTS_ERROR, missing: gate.missing }, 409); + } + /* v8 ignore next -- requireAction already rejected a missing name */ + const authorName = (account.name ?? '').trim(); const parsed = textBody.safeParse(await c.req.json().catch(() => null)); if (!parsed.success) { return c.json({ error: 'Expected a JSON body with a "text" string' }, 400); } - if (account.name === null || account.name.trim() === '') { - return c.json({ error: 'Set a name before posting' }, 400); - } const text = normalizeForumText(parsed.data.text); // Forum photo-only posts may be empty; contact has no photo and still // requires 1–500 characters. @@ -74,7 +78,7 @@ export function contactRoutes(deps: ContactRouteDeps): Hono { const row: ContactRow = { id: crypto.randomUUID(), accountId: account.id, - name: account.name.trim(), + name: authorName, text, createdAt, }; diff --git a/src/routes/debug.ts b/src/routes/debug.ts index f7edcc78..b6cba5ab 100644 --- a/src/routes/debug.ts +++ b/src/routes/debug.ts @@ -4,16 +4,19 @@ import { finalizeEvent, generateSecretKey } from 'nostr-tools/pure'; import { z } from 'zod'; import { serializeDebugAccount } from '@/lib/auth/account-json'; import { randomHex } from '@/lib/auth/hex'; +import { ensureProfileMessage } from '@/lib/auth/profile-message'; import { issueSession } from '@/lib/auth/service'; import type { Account, AuthStore } from '@/lib/auth/store'; import { bearerMatchesDebugToken } from '@/lib/debug-token'; import { normalizeLightningAddress } from '@/lib/lightning-address'; import type { FetchFn } from '@/lib/lnurlp'; import { logEvent } from '@/lib/log'; +import type { MessageStore } from '@/lib/message-store'; import { normalizeDisplayName } from '@/lib/name'; import { LIGHTNING_ADDRESS_NOT_ZAP, probeNip57Mint } from '@/lib/nip57-probe'; import { publicKeyHexFromSecret } from '@/lib/nostr/keys'; import type { ConversationStore } from '@/lib/conversation-store'; +import type { PushStore } from '@/lib/push-store'; /** * Operator debug surface for registered accounts. @@ -36,10 +39,36 @@ export interface DebugRouteDeps { * member→platform thread at the new official account. */ conversationStore?: ConversationStore; + /** Forum store for profile notes after provisioned name writes. */ + messageStore?: MessageStore; + /** Optional push outbox for profile-note create. */ + pushStore?: PushStore; /** Clock for minted debug sessions. Defaults to `Date.now`. */ now?: () => number; } +/** + * Args for {@link ensureProfileMessage} during debug provision. + * + * @param deps - Debug collaborators (message store required at the call site). + * @param account - Account that just received a name. + * @param now - Clock. + * @returns Helper input, including push when configured. + */ +function profileEnsureArgs( + deps: DebugRouteDeps & { messageStore: MessageStore }, + account: Account, + now: () => number, +): Parameters[0] { + return { + auth: deps.store, + messages: deps.messageStore, + account, + now, + ...(deps.pushStore === undefined ? {} : { pushStore: deps.pushStore }), + }; +} + /** Body schema for operator role, Lightning Address unlink, and platform flag. */ const patchBody = z .object({ @@ -157,6 +186,7 @@ export function debugRoutes(deps: DebugRouteDeps): Hono { viewKey: string; created: boolean; }> = []; + const clock = deps.now ?? Date.now; for (const row of classified) { if (row.existing !== undefined) { const named = await deps.store.updateAccountNameByLightningAddress( @@ -166,6 +196,11 @@ export function debugRoutes(deps: DebugRouteDeps): Hono { if (named === undefined || named.name !== row.name) { return c.json({ error: 'Could not save the account' }, 500); } + if (deps.messageStore !== undefined) { + await ensureProfileMessage( + profileEnsureArgs({ ...deps, messageStore: deps.messageStore }, named, clock), + ); + } updated += 1; results.push({ name: named.name, @@ -185,8 +220,11 @@ export function debugRoutes(deps: DebugRouteDeps): Hono { lightningAddressVerified: false, forumLawsDismissed: false, viewKey, - createdAt: Date.now(), + createdAt: clock(), rulesAgreedAt: null, + nameSkippedAt: null, + lightningAddressSkippedAt: null, + profileMessageId: null, }); const stored = await deps.store.getAccountByLightningAddress(row.lightningAddress); if (stored === undefined) { @@ -194,6 +232,11 @@ export function debugRoutes(deps: DebugRouteDeps): Hono { } const didCreate = stored.viewKey === viewKey; if (didCreate) { + if (deps.messageStore !== undefined) { + await ensureProfileMessage( + profileEnsureArgs({ ...deps, messageStore: deps.messageStore }, stored, clock), + ); + } created += 1; results.push({ name: stored.name ?? row.name, @@ -209,6 +252,11 @@ export function debugRoutes(deps: DebugRouteDeps): Hono { if (named === undefined || named.name !== row.name) { return c.json({ error: 'Could not save the account' }, 500); } + if (deps.messageStore !== undefined) { + await ensureProfileMessage( + profileEnsureArgs({ ...deps, messageStore: deps.messageStore }, named, clock), + ); + } updated += 1; results.push({ name: named.name, @@ -243,6 +291,7 @@ export function debugRoutes(deps: DebugRouteDeps): Hono { if (parsed.data.lightningAddress === null) { updated.lightningAddress = null; updated.lightningAddressVerified = false; + updated.lightningAddressSkippedAt = null; } if (parsed.data.platform !== undefined) { updated.isPlatform = parsed.data.platform; diff --git a/src/routes/me.ts b/src/routes/me.ts index 50f56e68..0df57ff1 100644 --- a/src/routes/me.ts +++ b/src/routes/me.ts @@ -4,13 +4,16 @@ import { resolveSession } from '@/lib/auth/service'; import { normalizeLightningAddress } from '@/lib/lightning-address'; import { normalizeDisplayName } from '@/lib/name'; import { serializeOwnerAccount } from '@/lib/auth/account-json'; +import { ensureProfileMessage } from '@/lib/auth/profile-message'; import type { Account, AuthStore } from '@/lib/auth/store'; import type { InvoicePayer } from '@/lib/invoice-payer'; import { logEvent } from '@/lib/log'; import { resolveLnurlp, type FetchFn } from '@/lib/lnurlp'; +import type { MessageStore } from '@/lib/message-store'; import { LIGHTNING_ADDRESS_NOT_ZAP, probeNip57Mint } from '@/lib/nip57-probe'; import { ensureAccountNostrKey } from '@/lib/nostr/keys'; import { signEventForAccount } from '@/lib/nostr/sign'; +import type { PushStore } from '@/lib/push-store'; import { confirmVerification, startVerification } from '@/lib/verification'; /** @@ -24,6 +27,8 @@ import { confirmVerification, startVerification } from '@/lib/verification'; export interface MeRouteDeps { /** Shared auth persistence port. */ store: AuthStore; + /** Forum persistence (profile notes on first name). */ + messages: MessageStore; /** Clock returning epoch milliseconds (injected for testability). */ now: () => number; /** Pays the verification micro-payment invoice. */ @@ -32,6 +37,8 @@ export interface MeRouteDeps { fetchImpl: FetchFn; /** AES KEK for signing the NIP-57 mint probe; omit when unset. */ nostrKek?: Uint8Array; + /** Optional push outbox; profile-note create enqueues when present. */ + pushStore?: PushStore; } /** @@ -87,11 +94,14 @@ const addressBody = z.object({ address: z.string() }); /** Body schema for confirming address verification. */ const confirmBody = z.object({ nonce: z.string() }); +/** Body schema for skipping a wizard step. */ +const skipBody = z.object({ step: z.enum(['name', 'lightning-address']) }); + /** * Build the `/me` route group. * - * @param deps - Shared store, clock, payer, fetch, and optional `nostrKek` for the NIP-57 mint probe. - * @returns A Hono app exposing account, display-name, forum-laws dismiss, + * @param deps - Shared store, message store, clock, payer, fetch, optional push, and optional `nostrKek` for the NIP-57 mint probe. + * @returns A Hono app exposing account, display-name, setup skip, forum-laws dismiss, * living-room rules agreement, link/unlink, and verification routes. */ export function meRoutes(deps: MeRouteDeps): Hono { @@ -103,6 +113,32 @@ export function meRoutes(deps: MeRouteDeps): Hono { } return c.json(serializeOwnerAccount(account), 200); }) + .post('/setup/skip', async (c) => { + const account = await authedAccount(deps, c.req.header('authorization')); + if (account === null) { + return c.json({ error: 'Unauthorized' }, 401); + } + const parsed = skipBody.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) { + return c.json( + { error: 'Expected a JSON body with step "name" or "lightning-address"' }, + 400, + ); + } + const current = await storedAccount(deps, account.id); + /* v8 ignore next 3 -- the account row cannot vanish mid-request after auth */ + if (current === null) { + return c.json({ error: 'Unauthorized' }, 401); + } + const skippedAt = deps.now(); + const updated: Account = + parsed.data.step === 'name' + ? { ...current, nameSkippedAt: skippedAt } + : { ...current, lightningAddressSkippedAt: skippedAt }; + await deps.store.updateAccount(updated); + logEvent('account.setup.skipped', { accountId: current.id, step: parsed.data.step }); + return c.json(serializeOwnerAccount(updated), 200); + }) .post('/name', async (c) => { const account = await authedAccount(deps, c.req.header('authorization')); if (account === null) { @@ -121,10 +157,23 @@ export function meRoutes(deps: MeRouteDeps): Hono { if (current === null) { return c.json({ error: 'Unauthorized' }, 401); } - const updated: Account = { ...current, name }; - await deps.store.updateAccount(updated); + const withName: Account = { ...current, name }; + await ensureProfileMessage({ + auth: deps.store, + messages: deps.messages, + account: withName, + 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); + } + const named: Account = { ...live, name }; + await deps.store.updateAccount(named); logEvent('account.name.set', { accountId: current.id }); - return c.json(serializeOwnerAccount(updated), 200); + return c.json(serializeOwnerAccount(named), 200); }) .post('/forum-laws-dismissed', async (c) => { const account = await authedAccount(deps, c.req.header('authorization')); @@ -267,6 +316,7 @@ export function meRoutes(deps: MeRouteDeps): Hono { ...current, lightningAddress: null, lightningAddressVerified: false, + lightningAddressSkippedAt: null, }; await deps.store.updateAccount(updated); await deps.store.deleteVerification(account.id); diff --git a/src/routes/members.ts b/src/routes/members.ts new file mode 100644 index 00000000..ecd37f3e --- /dev/null +++ b/src/routes/members.ts @@ -0,0 +1,93 @@ +import { Hono } from 'hono'; +import { resolveSession } from '@/lib/auth/service'; +import { MISSING_REQUIREMENTS_ERROR, requireAction } from '@/lib/auth/requirements'; +import type { Account, AuthStore } from '@/lib/auth/store'; +import { logEvent } from '@/lib/log'; +import { MESSAGE_LIST_LIMIT, serializeMessage } from '@/lib/message'; +import type { MessageStore } from '@/lib/message-store'; +import { bearerToken } from '@/routes/me'; +import { MESSAGE_ID_RE } from '@/routes/messages'; + +/** + * `/members` — signed-in member profile cards (live identity + profile note). + */ + +/** Collaborators the `/members` routes need. */ +export interface MembersRouteDeps { + /** Shared auth persistence port. */ + authStore: AuthStore; + /** Forum persistence (profile notes). */ + messageStore: MessageStore; + /** Clock returning epoch milliseconds (injected for testability). */ + now: () => number; +} + +/** Resolve the account behind a request's bearer session, or `null`. */ +async function authedAccount( + deps: MembersRouteDeps, + header: string | undefined, +): Promise { + const token = bearerToken(header); + if (token === null) { + return null; + } + return resolveSession(deps.authStore, deps.now(), token); +} + +/** + * Build the `/members` route group. + * + * Mounted at `/members` so the public path is `GET /members/:accountId`. + * + * @param deps - Auth store, message store, and clock. + * @returns A Hono app with `GET /:accountId`. + */ +export function membersRoutes(deps: MembersRouteDeps): Hono { + return new Hono().get('/:accountId', async (c) => { + const caller = await authedAccount(deps, c.req.header('authorization')); + if (caller === null) { + return c.json({ error: 'Unauthorized' }, 401); + } + const gate = requireAction(caller, 'forum.read'); + if (!gate.ok) { + return c.json({ error: MISSING_REQUIREMENTS_ERROR, missing: gate.missing }, 409); + } + const accountId = c.req.param('accountId'); + if (!MESSAGE_ID_RE.test(accountId)) { + return c.json({ error: 'Not found' }, 404); + } + try { + const account = await deps.authStore.getAccount(accountId); + if (account === undefined) { + return c.json({ error: 'Not found' }, 404); + } + let profileMessage: ReturnType | null = null; + const profileId = account.profileMessageId; + if (typeof profileId === 'string' && profileId.trim() !== '') { + const row = await deps.messageStore.getById(profileId); + if (row !== undefined) { + const payable = + row.eventId !== null && + account.lightningAddress !== null && + account.lightningAddress.trim() !== ''; + const children = await deps.messageStore.listReplies(row.id, MESSAGE_LIST_LIMIT); + profileMessage = serializeMessage(row, payable, account.role, children.length, true); + } + } + return c.json( + { + id: account.id, + name: account.name, + role: account.role, + lightningAddress: account.lightningAddress, + createdAt: new Date(account.createdAt).toISOString(), + profileMessage, + }, + 200, + ); + } catch { + logEvent('members.get.failed'); + return c.json({ error: 'Messages are unavailable' }, 503); + } + }); +} diff --git a/src/routes/messages.ts b/src/routes/messages.ts index 96c34fa8..5c1319a8 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -1,6 +1,7 @@ import { Hono, type Context } from 'hono'; import { z } from 'zod'; import { resolveSession } from '@/lib/auth/service'; +import { MISSING_REQUIREMENTS_ERROR, requireAction } from '@/lib/auth/requirements'; import type { Account, AuthStore } from '@/lib/auth/store'; import { inspectBolt11, isNip57Invoice } from '@/lib/bolt11'; import { GIFT_INVOICE_MAX_MSAT } from '@/lib/config'; @@ -315,11 +316,8 @@ async function postMultipartMessage( deps: MessagesRouteDeps, c: Context, account: Account, + authorName: string, ): Promise { - /* v8 ignore next 3 -- named accounts; trim-empty is the same 400 as JSON POST */ - if (account.name === null || account.name.trim() === '') { - return c.json({ error: 'Set a name before posting' }, 400); - } const form = await c.req.formData(); /* v8 ignore next -- form.get is string or File */ const rawText = String(form.get('text') ?? ''); @@ -358,7 +356,7 @@ async function postMultipartMessage( const row: MessageRow = { id: crypto.randomUUID(), accountId: account.id, - name: account.name.trim(), + name: authorName, text, createdAt: new Date(deps.now()), hasPhoto: photo !== undefined, @@ -425,6 +423,10 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { if (account === null) { return c.json({ error: 'Unauthorized' }, 401); } + const gate = requireAction(account, 'forum.read'); + if (!gate.ok) { + return c.json({ error: MISSING_REQUIREMENTS_ERROR, missing: gate.missing }, 409); + } try { const rows = await deps.store.listLatest(MESSAGE_LIST_LIMIT); const messages = []; @@ -461,6 +463,12 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { if (account === null) { return c.json({ error: 'Unauthorized' }, 401); } + const gate = requireAction(account, 'forum.post'); + if (!gate.ok) { + return c.json({ error: MISSING_REQUIREMENTS_ERROR, missing: gate.missing }, 409); + } + /* v8 ignore next -- requireAction already rejected a missing name */ + const authorName = (account.name ?? '').trim(); if (!postLimiter.allow(account.id, deps.now())) { logEvent('messages.rate_limited', { accountId: account.id }); c.header('Retry-After', '10'); @@ -469,15 +477,12 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { /* v8 ignore next -- missing content-type is JSON parse 400 */ const requestType = c.req.header('content-type') ?? ''; if (requestType.toLowerCase().includes('multipart/form-data')) { - return postMultipartMessage(deps, c, account); + return postMultipartMessage(deps, c, account, authorName); } const parsed = postBody.safeParse(await c.req.json().catch(() => null)); if (!parsed.success) { return c.json({ error: 'Expected a JSON body with text and/or photo' }, 400); } - if (account.name === null || account.name.trim() === '') { - return c.json({ error: 'Set a name before posting' }, 400); - } const rawText = parsed.data.text ?? ''; const text = normalizeForumText(rawText); if (text === null) { @@ -509,7 +514,7 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { const row: MessageRow = { id: crypto.randomUUID(), accountId: account.id, - name: account.name.trim(), + name: authorName, text, createdAt: new Date(deps.now()), hasPhoto: photo !== undefined, @@ -614,6 +619,10 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { if (account === null) { return c.json({ error: 'Unauthorized' }, 401); } + const payGate = requireAction(account, 'forum.pay'); + if (!payGate.ok) { + return c.json({ error: MISSING_REQUIREMENTS_ERROR, missing: payGate.missing }, 409); + } const messageIdParam = c.req.param('id'); if (!MESSAGE_ID_RE.test(messageIdParam)) { return c.json({ error: 'Not found' }, 404); diff --git a/src/server.ts b/src/server.ts index f9e92187..13e7f90f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -8,6 +8,7 @@ import { authRoutes } from '@/routes/auth'; import { SimpleWebAuthnPasskeyCeremony } from '@/lib/auth/webauthn'; import type { PasskeyCeremony } from '@/lib/auth/webauthn'; import { meRoutes } from '@/routes/me'; +import { membersRoutes } from '@/routes/members'; import { viewRoutes } from '@/routes/view'; import { lightningAddressRoutes } from '@/routes/lightning-address'; import { debugRoutes } from '@/routes/debug'; @@ -240,12 +241,15 @@ export function createApp(deps: AppDeps = {}): Hono { '/me', meRoutes({ store, + messages: messageStore, now, payer: invoicePayer, fetchImpl, + pushStore, ...(nostrKek === undefined ? {} : { nostrKek }), }), ); + app.route('/members', membersRoutes({ authStore: store, messageStore, now })); app.route('/view', viewRoutes({ store })); app.route( '/lightning-address', @@ -253,7 +257,15 @@ export function createApp(deps: AppDeps = {}): Hono { ); app.route( '/debug/accounts', - debugRoutes({ store, debugToken, fetchImpl, conversationStore, now }), + debugRoutes({ + store, + debugToken, + fetchImpl, + conversationStore, + messageStore, + pushStore, + now, + }), ); app.route('/debug/contacts', debugContactsRoutes({ store: contactStore, debugToken })); app.route('/debug/messages', debugMessagesRoutes({ store: messageStore, debugToken }));