diff --git a/CONCEPT.md b/CONCEPT.md index 51d9a6b5..04fd55f5 100644 --- a/CONCEPT.md +++ b/CONCEPT.md @@ -142,7 +142,11 @@ One exclusive `account.role` per account. Roles form a strict hierarchy `founder > moderator > verified > basis`: a higher role can always do and see everything a lower role can. New passkey accounts are **Basis**. `verified` is a moderator confirming this person in real life -(forum badge), not Lightning-Address proof. A **moderator** is proposed by +(forum badge), not Lightning-Address proof. A **funding-program grant** is +independent of that role: moderators review living-room posts against the +three convictions (human decision). `basis` cannot apply. Spend pings and +spend invoices require an admitted grant or a trial on today's UTC day. +A **moderator** is proposed by an existing moderator and confirmed by a **different** staff member, or appointed directly by a founder. Those grants persist as trust edges (`POST /trust/verify`, `POST /trust/propose-moderator`, @@ -841,6 +845,7 @@ repository — they're intentionally not part of this project's scope. | 2026-09-17 | Public Trust Chain picks one incoming kind per subject: the oldest eligible sibling (`createdAt` then `id`). Eligible: `verify`, `moderator_appoint`, and `moderator_propose` only when the subject is a `moderator`. First contact wins; later appoint, confirm, or propose do not replace it. A pending propose (subject still `verified`) stays private. **Supersedes** the 2026-09-17 kind-priority projection (`moderator_propose` if moderator, else `verify`, else `moderator_appoint`). | | 2026-09-16 | confirm/appoint notify only the subject (`moderator_appointed`); Web Push `url` `/welcome`, tag `moderator_appointed:`; not a living-room fan-out; unique `(recipient, type, reply_id)` with `reply_id` = subject id; missing stores no-op; failure does not fail the trust POST. | | 2026-09-17 | Staff Bearer `GET /trust/proposals` lists pending `moderator_propose` (verified subject, no confirm/appoint). Session `GET /trust-chain` still omits a pending propose; once the subject is a `moderator`, that propose is eligible as the public incoming edge only when it is the oldest eligible sibling. | +| 2026-09-20 | Funding-program grants (`funding_grant`) are independent of `account.role`. `verified` remains a real-life meeting (forum badge). Moderators review posts against the three convictions. `basis` cannot apply; owner JSON `funding` is `null`. Status none → pending → trial (eligible only on that UTC day) or admitted (recurring) or rejected (may re-apply). Expired trial is effective pending (lazy persist). Spend ping and `POST /invoices` require `eligibleToday`; `GET /invoices/eligible?address=` returns `{ eligible }`. | | 2026-09-17 | Inbox last-read is per (account, conversation). `GET /conversations` adds per-row `unread` and list `unreadCount`; `POST /conversations/:id/read` stamps last-read. Does not copy DMs into Notifications. | | 2026-09-17 | Inbound private messages enqueue Web Push (`type: conversation`, url `/messages?c=`, tag `conversation:`) to bell subscribers only. No in-app Notification rows for DMs. Every outbox `unreadCount` (forum, zap, conversation) is notification unread plus listed inbox unread. Push failure does not fail HTTP 200 or Nostr ingest. | | 2026-09-17 | A living-room note may carry up to 10 JPEG/PNG/WebP stills. Photo 0 stays on `message.photo` (Damus `/photo.jpg` unchanged). Extras 1–9 live in `message_extra_photo` and are served at `/messages/:id/photo/1.jpg` … `/photo/9.webp`. Public JSON includes `photoCount` (0–10). POST accepts `photos[]` (max 10) and still accepts singular `photo`. Video stays exclusive (poster = photo 0, no extras). | @@ -854,6 +859,7 @@ repository — they're intentionally not part of this project's scope. | 2026-09-20 | Passkey authenticate/register finish and operator debug session mint refuse an account whose stored `sessionRefused` flag is true: HTTP 403 with the wrong-account error and no bearer. `GET /me` with an already-minted token for that row is the same 403 so the client can sign the visitor out. Other authenticated routes treat that token as missing (401). Operators set the flag with `PATCH /debug/accounts/:id`. The account row is not deleted. **Supersedes** the same-day listed-id copy of this row. | | 2026-09-20 | A paid moderator stipend in the closed Moderators group carries a durable `gift_for_message_id` / public `giftFor` link to the group message that triggered it, so the app can render the stipend row attached under that message. Absent or null on every other conversation row. | | 2026-09-21 | `GET /conversations` (and other public conversation list rows) include per-row `unreadMessageCount` (inbound messages strictly after last-read; `0` when none). `unread` stays `unreadMessageCount > 0`. Envelope `unreadCount` remains the number of listed unread threads (menu/PWA badge). Gift-only inbound counts; outbound does not. | +| 2026-09-21 | Optional `GET /messages?hashtag=` token filter on live top-level `text` (name without `#`; token match; combines with `mode`/`limit`/`cursor`). No new entity, table, or index. A shops page of 20 is 20 matching notes, not 20 mixed notes filtered later. | ## Next Steps diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 96389ca7..2efb8d3f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,7 +36,7 @@ api/ │ │ ├── brand.ts # GET /favicon.ico, /favicon.svg, /apple-touch-icon.png │ │ ├── auth.ts # Passkey: /auth/passkey/register|authenticate begin/finish │ │ ├── me.ts # GET /me; GET /me/activity; PUT /me/about; GET /me/about/photo; POST /me/setup/skip; POST /me/name; POST /me/username; POST /me/location; POST /me/forum-laws-dismissed; POST /me/notification-level; POST /me/rules-agreement; link/unlink + address verification -│ │ ├── members.ts # GET /members/:accountId (Bearer; live identity + profile note + counts + trust); GET /members/:accountId/activity; GET /members/:accountId/posts; GET /members/:accountId/replies +│ │ ├── members.ts # GET /members/:accountId (Bearer; live identity + profile note + counts + trust + fundingReviewedAt); GET /members/:accountId/activity; GET /members/:accountId/posts; GET /members/:accountId/replies │ │ ├── view.ts # GET /view/:viewKey (public profile card); GET /view/:viewKey/about/photo; GET /view/:viewKey/activity │ │ ├── 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) @@ -49,14 +49,15 @@ api/ │ │ ├── debug-trust.ts # POST/DELETE /debug/trust-edges (operator DEBUG_TOKEN; no role change) │ │ ├── trust-chain.ts # session GET /trust-chain (founder seeds; ?around= one hop) │ │ ├── trust.ts # GET /trust/proposals; POST /trust/verify, propose-moderator, confirm-moderator, appoint-moderator +│ │ ├── funding.ts # POST /funding/apply; GET /funding/applications; GET /funding/applications/:accountId; POST /funding/trial, admit, reject │ │ ├── push.ts # GET /push/vapid-public; POST/DELETE /me/push-subscriptions │ │ ├── stats.ts # GET /gifts/stats (public gift totals) │ │ ├── gifts.ts # GET /gifts?day= (public per-day gift list) -│ │ ├── invoices.ts # GET /invoices/passkey, GET /invoices/posted, POST /invoices, POST /invoices/proof (spend worker) +│ │ ├── invoices.ts # GET /invoices/passkey, GET /invoices/eligible, GET /invoices/posted, POST /invoices, POST /invoices/proof (spend worker) │ │ ├── messages.ts # GET/POST /messages, public GET /messages/:id, GET /messages/hidden (session, not DEBUG_TOKEN), DELETE /messages/:id, GET /messages/:id/replies, GET /messages/:id/photo, GET /messages/:id/video.*, POST /messages/:id/invoice │ │ ├── well-known.ts # GET /.well-known/nostr.json (NIP-05); GET /.well-known/lnurlp/:username (LUD-16) │ │ ├── contact.ts # POST /contact (private mailbox + platform thread) -│ │ ├── conversations.ts # GET/POST /conversations, GET /conversations/moderator-group, GET/POST /conversations/:id, POST /conversations/:id/read, POST /conversations/:id/invoice +│ │ ├── conversations.ts # GET/POST /conversations, GET /conversations/moderator-group, GET/POST /conversations/:id, POST /conversations/:id/read, POST /conversations/:id/invoice, GET /conversations/:id/messages/:messageId/photo, GET /conversations/:id/messages/:messageId/photo/:file │ │ └── notifications.ts # GET /notifications, POST /notifications/read-all, POST /notifications/:id/read │ ├── lib/ │ │ ├── meta.ts # Service constants (name, version, repo URL) @@ -75,7 +76,9 @@ api/ │ │ ├── contact-store.ts # ContactStore port, InMemoryContactStore, PostgresContactStore │ │ ├── trust.ts # Trust-chain types, buildTrustChain, accountTrust, serializeTrustEdge │ │ ├── trust-store.ts # TrustStore port, InMemoryTrustStore, PostgresTrustStore, TRUST_SCHEMA_SQL -│ │ ├── conversation.ts # PN public JSON (optional counterpart/sender accountId; no eventId / npub) +│ │ ├── funding.ts # Funding-grant types, utcDayKey, effectiveStatus, eligibleToday, serializeOwnerFunding, fundingReviewedAt, expiredTrialAsPending +│ │ ├── funding-store.ts # FundingStore port, InMemoryFundingStore, PostgresFundingStore, FUNDING_SCHEMA_SQL, loadGrantEffective +│ │ ├── conversation.ts # PN public JSON (optional counterpart/sender accountId; hasPhoto/photoCount; no eventId / npub / bytes) │ │ ├── api-log.ts # HTTP audit log store (`api_log`) │ │ ├── request-auth.ts # Classify bearer for api_log (session/debug/spend/none) │ │ ├── conversation-store.ts # ConversationStore port, memory + Postgres @@ -102,7 +105,7 @@ api/ │ │ ├── gift-recorder.ts # Persist proven spend gifts into `gift` (no-op or SQL) │ │ ├── verification.ts # Address proof-of-control start/confirm domain logic │ │ ├── debug-token.ts # Constant-time DEBUG_TOKEN Bearer compare -│ │ ├── boot-stores.ts # DATABASE_URL → auth, optional QueryGiftStore + SqlGiftRecorder, message, contact, conversation, notification, push, trust_edge, BTC-USD and USD-fiat rates, KEK, db_change +│ │ ├── boot-stores.ts # DATABASE_URL → auth, optional QueryGiftStore + SqlGiftRecorder, message, contact, conversation, notification, push, trust_edge, funding_grant, BTC-USD and USD-fiat rates, KEK, db_change │ │ ├── money.ts # Sats/BTC strings and historical USD cents │ │ ├── btc-usd-candles.ts # Coinbase Exchange BTC-USD daily closes │ │ ├── btc-usd-store.ts # btc_usd_daily migrate + rate book @@ -180,6 +183,8 @@ api/ │ │ ├── trust-store.test.ts │ │ ├── api-log.test.ts │ │ ├── request-auth.test.ts +│ │ ├── funding.test.ts +│ │ ├── funding-store.test.ts │ │ ├── conversation.test.ts │ │ ├── conversation-store.test.ts │ │ ├── conversation-push.test.ts @@ -235,6 +240,7 @@ api/ │ ├── debug-trust.test.ts │ ├── trust-chain.test.ts │ ├── trust.test.ts +│ ├── funding.test.ts │ └── view.test.ts ├── docs/handbook/ # Mandatory: every function + HTTP endpoint │ ├── README.md @@ -246,11 +252,12 @@ api/ │ ├── usd_fiat_daily.sql # UTC daily USD→CHF/EUR/PHP ECB crosses │ ├── message.sql # message + nostr_zap_receipt + nostr_zapper + nostr_blocked_pubkey + nostr_zap_payment + message_invoice + nostr_zap_ingest + message_extra_photo │ ├── contact.sql # private contact mailbox table for POST /contact -│ ├── conversation.sql # PN threads + messages + conversation_read (per-viewer last-read; member/platform/Damus; closed moderator_group singleton, HTTP-only / skipped Nostr) +│ ├── conversation.sql # PN threads + messages + conversation_read (per-viewer last-read; member/platform/Damus; closed moderator_group singleton, HTTP-only / skipped Nostr) + conversation_message.photo / photo_content_type + conversation_message_extra_photo │ ├── api_log.sql # HTTP audit log (who called which path) │ ├── push.sql # push_subscription + push_outbox │ ├── notification.sql # in-app Notifications rows (`forum_post`, `forum_reply`, `zap`) │ ├── trust_edge.sql # who granted which staff status (GET /trust-chain) +│ ├── funding_grant.sql # funding-program grant (one row per account; spend ping / invoice gate) │ └── db_change.sql # append-only row-change log ├── scripts/ │ ├── check-handbook.mjs # CI gate: missing heading → exit 1 @@ -355,6 +362,7 @@ the default boot surface (today: `requestPayInvoice`, which needs a configured `PostgresMessageStore`, `backfillZapPayments`, `backfillExternalZappers`, `migrateMessageSchema`, `PostgresContactStore`, `migrateContactSchema`, `PostgresTrustStore`, `migrateTrustSchema`, +`PostgresFundingStore`, `migrateFundingSchema`, `PostgresConversationStore`, `migrateConversationSchema`, `PostgresPushStore`, `migratePushSchema`, `PostgresNotificationStore`, `migrateNotificationSchema`, `PostgresApiLogStore`, @@ -399,7 +407,7 @@ gap. Reviewers enforce this; `migrateDbChangeSchema` in `src/lib/db-change.ts` / - Logging is done by Postgres AFTER INSERT OR UPDATE OR DELETE **row** triggers named `trg_db_change` on every `public` table except `db_change` itself — **not** by application store methods. New public tables are covered on the next SQL boot - (`migrateDbChangeSchema` after `migrateApiLogSchema` / `migrateTrustSchema` / `migrateNotificationSchema` / `migratePushSchema`) once the table exists. A + (`migrateDbChangeSchema` after `migrateApiLogSchema` / `migrateFundingSchema` / `migrateTrustSchema` / `migrateNotificationSchema` / `migratePushSchema`) once the table exists. A missing table **fails** the write; it does not skip the log. - `db_change` is append-only at runtime. UPDATE, DELETE, and TRUNCATE on it **must** fail (exception `db_change is append-only`). `migrateDbChangeSchema` @@ -461,33 +469,33 @@ docker run -p 3000:3000 -e BIND_ADDR=0.0.0.0:3000 21gifts/api:dev Configuration is read from environment variables only — no config files. Currently: -| Variable | Default | Purpose | -| ----------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `BIND_ADDR` | `0.0.0.0:3000` | Listen address | -| `SERVICE_VERSION` | `0.1.0` | Surfaced via `/info` | -| `DATABASE_URL` | _(unset → in-memory)_ | Postgres connection string. When set, auth, `btc_usd_daily`, `usd_fiat_daily`, `message` (plus `message_invoice`, `nostr_zap_ingest`, `nostr_zap_payment`, `nostr_zapper`, and `nostr_blocked_pubkey`; `nostr_zap_receipt` includes `payer_pubkey` and `zap_request_id`), `contact`, `conversation` / `conversation_message` / `conversation_read`, `notification`, `push_subscription`, `push_outbox`, `trust_edge`, `api_log`, and `db_change` are migrated, `GET /gifts` and `GET /gifts/stats` read `gift` plus persisted BTC-USD daily closes and USD→CHF/EUR/PHP ECB crosses (best-effort boot fill; failures log and do not kill the process), `GET/POST /messages`, `GET /messages/:id`, `GET /messages/hidden` (`PostgresMessageStore.listHidden`), `DELETE /messages/:id` (uses `PostgresMessageStore.markDeleted` soft-hide, not `deleteById`), `GET /messages/:id/replies`, `GET /messages/:id/photo`, and `GET /messages/:id/video.*` (MIME in Postgres, bytes under `MEDIA_DIR`) use `PostgresMessageStore`, `POST /contact` / `GET /debug/contacts` use `PostgresContactStore`, `GET/POST /conversations`, `GET /conversations/moderator-group`, `GET/POST /conversations/:id`, and `POST /conversations/:id/read` use `PostgresConversationStore`, `GET /notifications` / `POST /notifications/read-all` / `POST /notifications/:id/read` use `PostgresNotificationStore`, `GET /trust-chain`, `GET /trust/proposals`, and staff `POST /trust/*` use `PostgresTrustStore`, `GET /debug/api-log` uses `PostgresApiLogStore`, `GET /debug/invoices`, `GET /debug/zap-ingests`, and `GET /debug/external-pubkeys` list invoice attempts, zap ingests, and external-pubkey state, `POST /debug/invoices/settle` manually settles a paid member forum invoice through those existing durable tables, and a matching `POST /invoices/proof` inserts into `gift`. Unset keeps `InMemoryAuthStore`, in-memory forum, contact, conversation, notification, push, trust, and `api_log` stores, empty gift stats, empty day lists, and a no-op gift recorder. | -| `DEBUG_TOKEN` | _(unset → debug off)_ | Operator bearer for `GET /debug/accounts`, `POST /debug/accounts`, `PATCH /debug/accounts/:id`, `POST /debug/accounts/:id/session`, `GET /debug/contacts`, `GET /debug/api-log`, `GET /debug/invoices`, `POST /debug/invoices/settle`, `GET /debug/zap-ingests`, `GET /debug/messages`, `GET /debug/messages/:id`, `GET /debug/messages/:id/photo`, `PUT /debug/messages/:id/video`, `POST /debug/messages/:id/restore`, `GET /debug/external-pubkeys`, `POST /debug/push-ping`, `POST /debug/trust-edges`, and `DELETE /debug/trust-edges`. Unset or blank → `503`; the process still boots. | -| `NIP57_PROBE` | _(unset → probe on)_ | Set to `0` to skip the NIP-57 mint probe on `POST /debug/accounts` new addresses (Playwright e2e only). Unset or any other value probes. Production must not set this. The process still boots. | -| `WEBAUTHN_RP_ID` | _(none — required for passkey)_ | WebAuthn RP ID (`21.gifts` / `dev.21.gifts` / `localhost`). Passkey routes return `500` until it is set; the process still boots. Not a secret. | -| `WEBAUTHN_RP_NAME` | `21.gifts` | Human-readable RP name. | -| `CORS_ALLOWED_ORIGINS` | built-in apex / app aliases / localhost | Comma-separated browser origins. Passkey finish keeps those whose hostname is the RP ID or `app.`. | -| `SPEND_URL` | _(unset → no ping)_ | Base URL of the spend process (no trailing slash). Not a secret. Unset/blank → no ping; the process still boots. | -| `SPEND_API_TOKEN` | _(none — optional)_ | Bearer for spend-worker `GET /invoices/passkey`, `GET /invoices/posted`, `POST /invoices`, and `POST /invoices/proof`, and also the Bearer sent to spend `POST {SPEND_URL}/ping`. Unset/blank → invoice routes **503**; ping is skipped. The process still boots. | -| `BTC_USD_CANDLES_URL` | Coinbase Exchange BTC-USD candles URL | Optional override for daily close fetch used by `GET /gifts` and `GET /gifts/stats`. Blank/unset → default Coinbase URL; the process still boots. | -| `FRANKFURTER_RATES_URL` | Frankfurter ECB USD→CHF/EUR/PHP URL | Optional override for daily USD-fiat fetch used by `GET /gifts` and `GET /gifts/stats`. Blank/unset → default Frankfurter ECB URL; the process still boots. | -| `NOSTR_NSEC_KEK` | _(required with `DATABASE_URL`)_ | 32-byte hex AES-GCM KEK for custodial nsec. With `DATABASE_URL`, missing or malformed KEK **throws at boot**. Memory boots omit it. | -| `NOSTR_PUBLISH` | _(unset → sign only)_ | Set to `1` to fan out signed kind:1 notes, replaceable kind:0 profiles, and NIP-65 kind:10002 relay lists over WebSockets. Unchanged kind:0 / kind:10002 content is skipped for the life of the AuthStore instance. Other values do not publish. | -| `NOSTR_PUBLISH_PUBLIC` | _(unset → space-only published)_ | Set to `1` (with `NOSTR_PUBLISH=1`) to also write kind:1 notes, kind:0 profiles, and kind:10002 relay lists to Damus / Primal / nos.lol. Unset: space ACK is terminal `published`. Does not gate zap ingest or invoice `relays`. | -| `NOSTR_RELAY_URL` | `wss://relay.nostr.space` | Compose durability relay (nostr.space). Used when `NOSTR_RELAY_SPACE` is unset. | -| `NOSTR_RELAY_SPACE` | _(falls back to `NOSTR_RELAY_URL`)_ | Optional override of the durability relay WebSocket URL. | -| `NOSTR_RELAY_PUBLIC` | Damus, Primal, nos.lol | Optional comma-separated public relays. Used for kind:1, kind:0, and kind:10002 write when `NOSTR_PUBLISH_PUBLIC=1`, and always for zap ingest, invoice `relays` tags, and staff-hide NIP-09 (even when that flag is off). | -| `PUBLIC_BASE_URL` | _(unset → no media URL / no NIP-05)_ | Site origin for public photo/video URLs in kind:1 and the NIP-05 domain (`https://21.gifts` → `https://api.21.gifts` for media; nip05 uses hostname `21.gifts`). Unset or blank → media notes are signed without a URL and NIP-05 is omitted. Also the origin used to build Cloudflare purge URLs on `DELETE /messages/:id`. Not required at boot. Playwright pins it to `http://127.0.0.1:3000`. | -| `CLOUDFLARE_ZONE_ID` | _(unset → skip media purge)_ | Cloudflare zone id for `DELETE /messages/:id` `purge_cache` of public photo/video URLs. Not a secret. Unset or blank (or unpaired with a token) → skip purge; the process still boots. | -| `CLOUDFLARE_API_TOKEN` | _(unset → skip media purge)_ | Cloudflare API token with cache-purge permission for `DELETE /messages/:id`. Secret. Never log. Unset or blank → skip purge; the process still boots. Pair with `CLOUDFLARE_ZONE_ID` and `PUBLIC_BASE_URL`. | -| `MEDIA_DIR` | _(required — no default)_ | Directory for forum video files. Missing or blank → **throws at boot** (no temp fallback). Image and Compose pin `/data/media`. Not a secret. Vitest setup and Playwright set it for tests. | -| `VAPID_PUBLIC_KEY` | _(unset → push HTTP 503)_ | URL-safe base64 uncompressed P-256 public key (65 decoded bytes). Not a secret. Missing, blank, malformed, or unpaired with a valid private key → push HTTP **503**; the process still boots. | -| `VAPID_PRIVATE_KEY` | _(unset → push HTTP 503)_ | URL-safe base64 P-256 private key. Secret. Never log. Pair with `VAPID_PUBLIC_KEY`. | -| `VAPID_SUBJECT` | `https://21.gifts` | VAPID `sub` URI. Optional. | +| Variable | Default | Purpose | +| ----------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `BIND_ADDR` | `0.0.0.0:3000` | Listen address | +| `SERVICE_VERSION` | `0.1.0` | Surfaced via `/info` | +| `DATABASE_URL` | _(unset → in-memory)_ | Postgres connection string. When set, auth, `btc_usd_daily`, `usd_fiat_daily`, `message` (plus `message_invoice`, `nostr_zap_ingest`, `nostr_zap_payment`, `nostr_zapper`, and `nostr_blocked_pubkey`; `nostr_zap_receipt` includes `payer_pubkey` and `zap_request_id`), `contact`, `conversation` / `conversation_message` / `conversation_read`, `notification`, `push_subscription`, `push_outbox`, `trust_edge`, `funding_grant`, `api_log`, and `db_change` are migrated, `GET /gifts` and `GET /gifts/stats` read `gift` plus persisted BTC-USD daily closes and USD→CHF/EUR/PHP ECB crosses (best-effort boot fill; failures log and do not kill the process), `GET/POST /messages`, `GET /messages/:id`, `GET /messages/hidden` (`PostgresMessageStore.listHidden`), `DELETE /messages/:id` (uses `PostgresMessageStore.markDeleted` soft-hide, not `deleteById`), `GET /messages/:id/replies`, `GET /messages/:id/photo`, and `GET /messages/:id/video.*` (MIME in Postgres, bytes under `MEDIA_DIR`) use `PostgresMessageStore`, `POST /contact` / `GET /debug/contacts` use `PostgresContactStore`, `GET/POST /conversations`, `GET /conversations/moderator-group`, `GET/POST /conversations/:id`, and `POST /conversations/:id/read` use `PostgresConversationStore`, `GET /notifications` / `POST /notifications/read-all` / `POST /notifications/:id/read` use `PostgresNotificationStore`, `GET /trust-chain`, `GET /trust/proposals`, and staff `POST /trust/*` use `PostgresTrustStore`, `/funding` apply/applications/trial/admit/reject and grant lookups use `PostgresFundingStore`, `GET /debug/api-log` uses `PostgresApiLogStore`, `GET /debug/invoices`, `GET /debug/zap-ingests`, and `GET /debug/external-pubkeys` list invoice attempts, zap ingests, and external-pubkey state, `POST /debug/invoices/settle` manually settles a paid member forum invoice through those existing durable tables, and a matching `POST /invoices/proof` inserts into `gift`. Unset keeps `InMemoryAuthStore`, in-memory forum, contact, conversation, notification, push, trust, funding, and `api_log` stores, empty gift stats, empty day lists, and a no-op gift recorder. | +| `DEBUG_TOKEN` | _(unset → debug off)_ | Operator bearer for `GET /debug/accounts`, `POST /debug/accounts`, `PATCH /debug/accounts/:id`, `POST /debug/accounts/:id/session`, `GET /debug/contacts`, `GET /debug/api-log`, `GET /debug/invoices`, `POST /debug/invoices/settle`, `GET /debug/zap-ingests`, `GET /debug/messages`, `GET /debug/messages/:id`, `GET /debug/messages/:id/photo`, `PUT /debug/messages/:id/video`, `POST /debug/messages/:id/restore`, `GET /debug/external-pubkeys`, `POST /debug/push-ping`, `POST /debug/trust-edges`, and `DELETE /debug/trust-edges`. Unset or blank → `503`; the process still boots. | +| `NIP57_PROBE` | _(unset → probe on)_ | Set to `0` to skip the NIP-57 mint probe on `POST /debug/accounts` new addresses (Playwright e2e only). Unset or any other value probes. Production must not set this. The process still boots. | +| `WEBAUTHN_RP_ID` | _(none — required for passkey)_ | WebAuthn RP ID (`21.gifts` / `dev.21.gifts` / `localhost`). Passkey routes return `500` until it is set; the process still boots. Not a secret. | +| `WEBAUTHN_RP_NAME` | `21.gifts` | Human-readable RP name. | +| `CORS_ALLOWED_ORIGINS` | built-in apex / app aliases / localhost | Comma-separated browser origins. Passkey finish keeps those whose hostname is the RP ID or `app.`. | +| `SPEND_URL` | _(unset → no ping)_ | Base URL of the spend process (no trailing slash). Not a secret. Unset/blank → no ping; the process still boots. | +| `SPEND_API_TOKEN` | _(none — optional)_ | Bearer for spend-worker `GET /invoices/passkey`, `GET /invoices/eligible`, `GET /invoices/posted`, `POST /invoices`, and `POST /invoices/proof`, and also the Bearer sent to spend `POST {SPEND_URL}/ping`. Unset/blank → invoice routes **503**; ping is skipped. The process still boots. | +| `BTC_USD_CANDLES_URL` | Coinbase Exchange BTC-USD candles URL | Optional override for daily close fetch used by `GET /gifts` and `GET /gifts/stats`. Blank/unset → default Coinbase URL; the process still boots. | +| `FRANKFURTER_RATES_URL` | Frankfurter ECB USD→CHF/EUR/PHP URL | Optional override for daily USD-fiat fetch used by `GET /gifts` and `GET /gifts/stats`. Blank/unset → default Frankfurter ECB URL; the process still boots. | +| `NOSTR_NSEC_KEK` | _(required with `DATABASE_URL`)_ | 32-byte hex AES-GCM KEK for custodial nsec. With `DATABASE_URL`, missing or malformed KEK **throws at boot**. Memory boots omit it. | +| `NOSTR_PUBLISH` | _(unset → sign only)_ | Set to `1` to fan out signed kind:1 notes, replaceable kind:0 profiles, and NIP-65 kind:10002 relay lists over WebSockets. Unchanged kind:0 / kind:10002 content is skipped for the life of the AuthStore instance. Other values do not publish. | +| `NOSTR_PUBLISH_PUBLIC` | _(unset → space-only published)_ | Set to `1` (with `NOSTR_PUBLISH=1`) to also write kind:1 notes, kind:0 profiles, and kind:10002 relay lists to Damus / Primal / nos.lol. Unset: space ACK is terminal `published`. Does not gate zap ingest or invoice `relays`. | +| `NOSTR_RELAY_URL` | `wss://relay.nostr.space` | Compose durability relay (nostr.space). Used when `NOSTR_RELAY_SPACE` is unset. | +| `NOSTR_RELAY_SPACE` | _(falls back to `NOSTR_RELAY_URL`)_ | Optional override of the durability relay WebSocket URL. | +| `NOSTR_RELAY_PUBLIC` | Damus, Primal, nos.lol | Optional comma-separated public relays. Used for kind:1, kind:0, and kind:10002 write when `NOSTR_PUBLISH_PUBLIC=1`, and always for zap ingest, invoice `relays` tags, and staff-hide NIP-09 (even when that flag is off). | +| `PUBLIC_BASE_URL` | _(unset → no media URL / no NIP-05)_ | Site origin for public photo/video URLs in kind:1 and the NIP-05 domain (`https://21.gifts` → `https://api.21.gifts` for media; nip05 uses hostname `21.gifts`). Unset or blank → media notes are signed without a URL and NIP-05 is omitted. Also the origin used to build Cloudflare purge URLs on `DELETE /messages/:id`. Not required at boot. Playwright pins it to `http://127.0.0.1:3000`. | +| `CLOUDFLARE_ZONE_ID` | _(unset → skip media purge)_ | Cloudflare zone id for `DELETE /messages/:id` `purge_cache` of public photo/video URLs. Not a secret. Unset or blank (or unpaired with a token) → skip purge; the process still boots. | +| `CLOUDFLARE_API_TOKEN` | _(unset → skip media purge)_ | Cloudflare API token with cache-purge permission for `DELETE /messages/:id`. Secret. Never log. Unset or blank → skip purge; the process still boots. Pair with `CLOUDFLARE_ZONE_ID` and `PUBLIC_BASE_URL`. | +| `MEDIA_DIR` | _(required — no default)_ | Directory for forum video files. Missing or blank → **throws at boot** (no temp fallback). Image and Compose pin `/data/media`. Not a secret. Vitest setup and Playwright set it for tests. | +| `VAPID_PUBLIC_KEY` | _(unset → push HTTP 503)_ | URL-safe base64 uncompressed P-256 public key (65 decoded bytes). Not a secret. Missing, blank, malformed, or unpaired with a valid private key → push HTTP **503**; the process still boots. | +| `VAPID_PRIVATE_KEY` | _(unset → push HTTP 503)_ | URL-safe base64 P-256 private key. Secret. Never log. Pair with `VAPID_PUBLIC_KEY`. | +| `VAPID_SUBJECT` | `https://21.gifts` | VAPID `sub` URI. Optional. | More will be added as concrete subsystems that need runtime configuration (relay client, …) land. The LUD-16 metadata cache TTL is a code constant diff --git a/FLOWS.md b/FLOWS.md index 5289d3ec..80615282 100644 --- a/FLOWS.md +++ b/FLOWS.md @@ -172,11 +172,14 @@ worker holds lightning.space LNDHub credentials and calls: 3. `POST /invoices/proof` — preimage (`sha256` = payment hash); the api records the gift for `GET /gifts/stats` and `GET /gifts?day=`. After recording the gift, when the invoice has `messageId` the api inserts a platform-account gift-reply under a top-level post first, then `addSats`. This path does not notify (no in-app rows, no Web Push). When `messageId` is already a reply, it hides a deterministic spend marker and `addSats`s that reply (no nested gift-reply). When the invoice has `groupMessageId`, the api also inserts a platform-account conversation message in the closed Moderators group (text + paid sats, name `21.gifts`) after the triggering group message, at payment time; a missing or mismatched group reference is ignored and does not block the 200. Recorded description is `21gifts moderator` when `groupMessageId` is stored, else `21gifts daily`. Recurring **USD** gifts are paid by the external spend worker **when the -recipient posts a top-level note**, not on a daily timer. Invoice HTTP -(`POST /invoices` / `POST /invoices/proof`) is unchanged. Recurring donor UI -is still a sketch. **Do not invent** `/me/donor`, `/me/recurring`, or -scheduler paths. HTTP that exists today is only the spend-worker invoice -pair above (`SPEC.md`). +recipient posts a top-level note**, not on a daily timer, and only when +that recipient is funding-eligible today. `POST /invoices` 403s +`Funding grant required` when not eligible. `GET /invoices/eligible?address=` +is the spend lookup and returns 200 `{ eligible }` (false when not eligible). +`POST /invoices/proof` does not check the grant. +Recurring donor UI is still a sketch. **Do not invent** `/me/donor`, +`/me/recurring`, or scheduler paths. HTTP that exists today is the +spend-worker invoice surface in `SPEC.md`. --- @@ -187,9 +190,11 @@ Public comment / encouragement is a v1 surface. The composer POSTs (requires rules + name + username + Lightning Address — missing requirements are **409** `missing_requirements`); a **new top-level** persist pings spend (`POST {SPEND_URL}/ping` with -`{ address, messageId }` and Bearer `SPEND_API_TOKEN`); replies and media replay do +`{ address, messageId }` and Bearer `SPEND_API_TOKEN`) only when the author +is funding-eligible today; otherwise log `spend.ping.skipped` / +`not_eligible` and still 200; replies and media replay do not ping; unset/blank env skips the ping and still returns 200; -the public thread is listed via `GET /messages` (requires rules; newest first, name +the public thread is listed via `GET /messages` (requires rules; newest first, optional `hashtag` query (name without `#`; token filter on `text`), name snapshotted at post, `sats`, `payable`, `hasPhoto`, and live author `role` — never photo bytes). Bytes are public `GET /messages/:id/photo` (Nostr `imeta`). Staff hide is a public-API filter **and** a best-effort NIP-09 (`kind: 5`, signed with the note author's custodial nsec) on the durability relay plus the public relay list, plus a best-effort Cloudflare purge of public photo/video URLs. Unsigned/public GET of a hidden row stays 404. A founder/moderator session may GET the hidden row (and photo/video) so the app can show who hid it and when. Hiding a note also retracts in-app notifications whose parent or reply is that note or a direct child. `GET /notifications` drops remaining rows whose parent or reply message is missing or hidden. Operator `GET /debug/messages` (Bearer `DEBUG_TOKEN`) still lists and fetches soft-hidden forum rows and their photo bytes. Restore does not undelete Nostr. The shipped UI is a messenger-group thread: oldest notes at the top, newest at the bottom, @@ -285,7 +290,11 @@ via `GET /conversations` (per-row `unread` / `unreadMessageCount`; envelope `POST /conversations/:id/read`. NIP-17 gift wraps and legacy kind:4 inbound; outbound wraps with the sender nsec (platform nsec for staff on official threads). Forum replies stay on `/messages` and are not mixed -with PNs. Lightning gifts in a Direct/Contact thread use +with PNs. `moderator_group` POST may include `{ photo }` / `{ photos }` +(JPEG/PNG/WebP, ≤10; empty text is allowed only when at least one photo is present); Direct/Contact/Damus remain +text-only; bytes via authenticated GET +`/conversations/:id/messages/:messageId/photo` (photo 0) and +`/conversations/:id/messages/:messageId/photo/:file` (extras 1–9). Lightning gifts in a Direct/Contact thread use `POST /conversations/:id/invoice` (`{ sats, text? }`). Payment is confirmed when a matching zap receipt is ingested: the api appends a conversation message (`text` + `sats`, or empty `text` with `sats` only) and does **not** diff --git a/SPEC.md b/SPEC.md index de168041..d7957d84 100644 --- a/SPEC.md +++ b/SPEC.md @@ -4,7 +4,7 @@ > Product decisions live in [`CONCEPT.md`](./CONCEPT.md); this file owns > request/response contracts for routes that exist in code today. -**Status**: living document. Last revised 2026-09-21 (`GET /conversations` list/open rows include per-row `unreadMessageCount`; envelope `unreadCount` remains unread thread count; `GET /trust-chain` requires a member Bearer session; public graph uses at most one incoming kind per subject: the oldest eligible sibling (`createdAt` then `id`); eligible `verify`, `moderator_appoint`, and `moderator_propose` only when the subject is a moderator; `moderator_confirm` never; later appoint/confirm/propose do not replace the first eligible contact; owner `notificationLevel` on GET `/me` and `POST /me/notification-level`; fan-out filters in-app and Web Push by `all` / `active` / `mentions`; GET `/notifications` applies the same filter to stored rows (`moderator_appointed` always stays; `unreadCount` is matching unread in the newest 1000, not `store.unreadCount()`, and may exceed the 200 page); a zap that inserts a gift-reply fans out only `notifyZap`, not a second `forum_reply`; gift-reply row still lands in the thread; confirm/appoint notify the subject only with `moderator_appointed` and Web Push url `/welcome`; official platform account (`isPlatform`) never fans out living-room `forum_post` / `forum_reply` / `zap`; house daily gift-replies still persist). +**Status**: living document. Last revised 2026-09-21 (funding-program grants independent of `account.role`; spend ping and `POST /invoices` require `eligibleToday`; `GET /invoices/eligible`; `GET /conversations` list/open rows include per-row `unreadMessageCount`; envelope `unreadCount` remains unread thread count; `GET /trust-chain` requires a member Bearer session; public graph uses at most one incoming kind per subject: the oldest eligible sibling (`createdAt` then `id`); eligible `verify`, `moderator_appoint`, and `moderator_propose` only when the subject is a moderator; `moderator_confirm` never; later appoint/confirm/propose do not replace the first eligible contact; owner `notificationLevel` on GET `/me` and `POST /me/notification-level`; fan-out filters in-app and Web Push by `all` / `active` / `mentions`; GET `/notifications` applies the same filter to stored rows (`moderator_appointed` always stays; `unreadCount` is matching unread in the newest 1000, not `store.unreadCount()`, and may exceed the 200 page); a zap that inserts a gift-reply fans out only `notifyZap`, not a second `forum_reply`; gift-reply row still lands in the thread; confirm/appoint notify the subject only with `moderator_appointed` and Web Push url `/welcome`; official platform account (`isPlatform`) never fans out living-room `forum_post` / `forum_reply` / `zap`; house daily gift-replies still persist). --- @@ -36,10 +36,13 @@ verification payment requires an injected invoice payer; the default `GET /lightning-address` resolves LUD-16 metadata with an in-memory cache; it does not fetch or pay invoices. -Spend-worker invoice routes (`GET /invoices/passkey`, `GET /invoices/posted`, -`POST /invoices`, `POST /invoices/proof`) check passkey eligibility and a live -**top-level** forum post, fetch a BOLT11 via LNURL-pay, and accept a preimage proof. Issue -requires a passkey-backed account for the address and at least one live **top-level** forum +Spend-worker invoice routes: `GET /invoices/passkey` and `GET /invoices/posted` +report those gates; `GET /invoices/eligible` reports `eligibleToday`; +`POST /invoices` requires passkey, a funding-program grant (`eligibleToday`), and a live +**top-level** forum post, then fetches a BOLT11 via LNURL-pay; +`POST /invoices/proof` accepts a preimage without re-checking the grant. Issue +requires a passkey-backed account for the address that is funding-eligible today +and at least one live **top-level** forum message that is not the auto-created profile note. Replies do not count. They require `SPEND_API_TOKEN`; when it is unset the routes return **503** and the process still boots. This service does not pay @@ -68,94 +71,103 @@ Public base URLs used in examples: | PRD | `https://api.21.gifts` | `https://21.gifts` | | DEV | `https://dev-api.21.gifts` | `https://dev.21.gifts` | -| Method | Path | Auth | Purpose | -| ------ | -------------------------------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------- | -| GET | `/healthz` | none | Liveness | -| GET | `/info` | none | Service identity | -| GET | `/.well-known/lnurlp/:username` | none | LUD-16 payRequest for username@21.gifts; settlement stays on linked Wallet of Satoshi | -| GET | `/favicon.ico` | none | Brand mark (favicon) | -| GET | `/favicon.svg` | none | Brand mark (SVG favicon) | -| GET | `/apple-touch-icon.png` | none | Brand mark (Apple touch icon) | -| POST | `/auth/passkey/register/begin` | none | Issue WebAuthn creation options | -| POST | `/auth/passkey/register/finish` | none | Verify attestation, issue session | -| POST | `/auth/passkey/authenticate/begin` | none | Issue WebAuthn request options | -| POST | `/auth/passkey/authenticate/finish` | none | Verify assertion, issue session | -| GET | `/me` | `Authorization: Bearer` | Account (`setup` + factual `missing` + `hasPosted` + `aboutMe` + `aboutMeHasPhoto` + `notificationLevel`) | -| GET | `/me/activity` | Bearer | Given + received series (forum zaps + house gifts; platform given = all outbound) | -| GET | `/view/:viewKey` | none | Public profile card by view key | -| GET | `/view/:viewKey/about/photo` | none | Profile-note photo bytes for the view-key card | -| GET | `/view/:viewKey/activity` | none | Public given/received payload for the account behind the view key | -| POST | `/me/setup/skip` | Bearer | Skip name or Lightning Address wizard step | -| POST | `/me/name` | Bearer | Set/replace display name (profile note when name + LN are both set); auto-assign username when free | -| POST | `/me/username` | Bearer | Set unique LUD-16 / NIP-05 local-part (cannot skip) | -| POST | `/me/location` | Bearer | Set, change, or clear free-text profile location | -| PUT | `/me/about` | Bearer | Set/clear About me text and optional photo on the profile note | -| GET | `/me/about/photo` | Bearer | Owner profile-note photo bytes | -| POST | `/me/forum-laws-dismissed` | Bearer | Dismiss welcome-forum living-room laws | -| POST | `/me/notification-level` | Bearer | Set owner fan-out filter (`all` / `active` / `mentions`) | -| 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 (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 | `/members/:accountId` | Bearer | Live member identity + profile note + `aboutMeHasPhoto` + counts + `trust` | -| GET | `/members/:accountId/activity` | Bearer | Same given/received payload as `/me/activity` for that member | -| GET | `/members/:accountId/posts` | Bearer | Live member top-level notes (latest 200) | -| GET | `/members/:accountId/replies` | Bearer | Live member replies (latest 200) | -| GET | `/trust-chain` | Bearer | Founder seeds (empty edges); `?around=` one hop of stored public edges | -| POST | `/trust/verify` | Bearer (moderator+) | Staff: confirm a person in real life (`verified`) | -| POST | `/trust/propose-moderator` | Bearer (moderator+) | Staff: propose a verified member as moderator | -| GET | `/trust/proposals` | Bearer (moderator+) | Staff: list pending moderator proposals | -| POST | `/trust/confirm-moderator` | Bearer (moderator+) | Staff: second, independent confirmation → `moderator` | -| POST | `/trust/appoint-moderator` | Bearer (founder) | Founder: appoint a moderator directly | -| GET | `/messages` | Bearer | List top-level forum notes (+ visible `replyCount`); 409 if rules missing | -| POST | `/messages` | Bearer | Post text/photo; 409 if rules/name/username/Lightning Address missing | -| GET | `/messages/hidden` | Bearer (moderator+) | Staff log of soft-hidden notes (session, not DEBUG_TOKEN) | -| GET | `/messages/:id` | none / Bearer (moderator+) | Live public JSON; staff hidden GET includes `deletedAt`/`deletedBy` | -| GET | `/messages/:id/replies` | none / Bearer (moderator+) | Live replies; staff `listReplies(..., true)` includes hidden children even under a live parent | -| GET | `/messages/:id/photo` | none / Bearer (moderator+) | Live photo bytes; staff hidden bytes `Cache-Control: private, no-store` | -| GET | `/messages/:id/video.*` | none / Bearer (moderator+) | Live video bytes; staff hidden bytes `Cache-Control: private, no-store` | -| DELETE | `/messages/:id` | Bearer (moderator+) | Soft-hide note + direct replies; retract in-app notifications; external target also blocks that pubkey | -| POST | `/messages/:id/invoice` | Bearer | NIP-57 zap / BOLT11 | -| POST | `/contact` | Bearer | Send private in-app contact `{ text }` | -| GET | `/conversations` | Bearer | List visible private threads (per-row `unreadMessageCount`; envelope `unreadCount` is thread count) | -| GET | `/conversations/moderator-group` | Bearer (moderator+) | Open/ensure closed moderator-group tool | -| POST | `/conversations` | Bearer | Open thread from a forum note (`forumMessageId`) | -| GET | `/conversations/:id` | Bearer | Oldest-first messages (`?sinceMessageId=` long-polls until that id exists) | -| POST | `/conversations/:id` | Bearer | Send `{ text }` in a private thread | -| POST | `/conversations/:id/invoice` | Bearer | NIP-57 zap / BOLT11 for a private gift (`{ sats, text? }` → `{ pr, amountSats, messageId }`) | -| POST | `/conversations/:id/read` | Bearer | Stamp last-read for the viewer | -| GET | `/notifications` | Bearer | List + unreadCount; drop leftover hidden forum_post/forum_reply (zap checks parent only) | -| POST | `/notifications/read-all` | Bearer | Mark all notifications read | -| POST | `/notifications/:id/read` | Bearer | Mark one notification read | -| GET | `/lightning-address` | none | Resolve LUD-16 metadata (cached) | -| GET | `/debug/accounts` | `Authorization: Bearer` | Operator account listing (`DEBUG_TOKEN`) | -| POST | `/debug/accounts` | `Authorization: Bearer` | Operator provision name + Lightning Address (`DEBUG_TOKEN`) | -| PATCH | `/debug/accounts/:id` | `Authorization: Bearer` | Operator set `role` / unlink Lightning Address / `platform` / `sessionRefused` | -| POST | `/debug/accounts/:id/session` | `Authorization: Bearer` | Operator mint of a member bearer (`DEBUG_TOKEN`) | -| GET | `/debug/api-log` | `Authorization: Bearer` | Operator HTTP audit log (`DEBUG_TOKEN`); no query string, body, or Authorization | -| GET | `/debug/contacts` | `Authorization: Bearer` | Operator contact listing (`DEBUG_TOKEN`) | -| GET | `/debug/invoices` | `Authorization: Bearer` | Operator invoice attempts, forum and conversation (`DEBUG_TOKEN`) | -| POST | `/debug/invoices/settle` | `Authorization: Bearer` | Resumable operator settlement of a paid forum invoice (`DEBUG_TOKEN`) | -| GET | `/debug/zap-ingests` | `Authorization: Bearer` | Operator kind:9735 ingest log (`DEBUG_TOKEN`) | -| GET | `/debug/messages` | `Authorization: Bearer` | Operator forum listing including hidden rows and replies (`DEBUG_TOKEN`) | -| GET | `/debug/messages/:id` | `Authorization: Bearer` | Operator single-note fetch including hidden rows (`DEBUG_TOKEN`) | -| GET | `/debug/messages/:id/photo` | `Authorization: Bearer` | Operator photo bytes including hidden notes (`DEBUG_TOKEN`) | -| PUT | `/debug/messages/:id/video` | `Authorization: Bearer` | Operator restore of missing forum-video bytes (`DEBUG_TOKEN`) | -| POST | `/debug/messages/:id/restore` | `Authorization: Bearer` | Operator unhide of a soft-hidden forum note (`DEBUG_TOKEN`) | -| GET | `/debug/external-pubkeys` | `Authorization: Bearer` | Operator lists entitled and blocked external pubkeys (`DEBUG_TOKEN`) | -| POST | `/debug/trust-edges` | `Authorization: Bearer` | Operator trust-edge backfill (`DEBUG_TOKEN`); does not change `role` | -| DELETE | `/debug/trust-edges` | `Authorization: Bearer` | Operator trust-edge delete (`DEBUG_TOKEN`); does not change `role` | -| GET | `/push/vapid-public` | Bearer | VAPID public key for Web Push subscribe | -| POST | `/me/push-subscriptions` | Bearer | Upsert a browser PushSubscription | -| DELETE | `/me/push-subscriptions` | Bearer | Remove a browser PushSubscription | -| POST | `/debug/push-ping` | Bearer `DEBUG_TOKEN` | Enqueue a test push for one account | -| GET | `/gifts` | none | Outbound gifts for one UTC day (`?day=`) | -| GET | `/gifts/stats` | none | Aggregated outbound gift statistics | -| GET | `/invoices/passkey` | Bearer `SPEND_API_TOKEN` | Whether a Lightning Address has a passkey-backed account | -| GET | `/invoices/posted` | Bearer `SPEND_API_TOKEN` | Whether a Lightning Address has a live top-level non-profile forum post | -| POST | `/invoices` | Bearer `SPEND_API_TOKEN` | Fetch a recipient BOLT11 (LNURL-pay; passkey and forum post required) | -| POST | `/invoices/proof` | Bearer `SPEND_API_TOKEN` | Accept payment preimage as proof | +| Method | Path | Auth | Purpose | +| ------ | ---------------------------------------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------- | +| GET | `/healthz` | none | Liveness | +| GET | `/info` | none | Service identity | +| GET | `/.well-known/lnurlp/:username` | none | LUD-16 payRequest for username@21.gifts; settlement stays on linked Wallet of Satoshi | +| GET | `/favicon.ico` | none | Brand mark (favicon) | +| GET | `/favicon.svg` | none | Brand mark (SVG favicon) | +| GET | `/apple-touch-icon.png` | none | Brand mark (Apple touch icon) | +| POST | `/auth/passkey/register/begin` | none | Issue WebAuthn creation options | +| POST | `/auth/passkey/register/finish` | none | Verify attestation, issue session | +| POST | `/auth/passkey/authenticate/begin` | none | Issue WebAuthn request options | +| POST | `/auth/passkey/authenticate/finish` | none | Verify assertion, issue session | +| GET | `/me` | `Authorization: Bearer` | Account (`setup` + factual `missing` + `hasPosted` + `aboutMe` + `aboutMeHasPhoto` + `notificationLevel`) | +| GET | `/me/activity` | Bearer | Given + received series (forum zaps + house gifts; platform given = all outbound) | +| GET | `/view/:viewKey` | none | Public profile card by view key | +| GET | `/view/:viewKey/about/photo` | none | Profile-note photo bytes for the view-key card | +| GET | `/view/:viewKey/activity` | none | Public given/received payload for the account behind the view key | +| POST | `/me/setup/skip` | Bearer | Skip name or Lightning Address wizard step | +| POST | `/me/name` | Bearer | Set/replace display name (profile note when name + LN are both set); auto-assign username when free | +| POST | `/me/username` | Bearer | Set unique LUD-16 / NIP-05 local-part (cannot skip) | +| POST | `/me/location` | Bearer | Set, change, or clear free-text profile location | +| PUT | `/me/about` | Bearer | Set/clear About me text and optional photo on the profile note | +| GET | `/me/about/photo` | Bearer | Owner profile-note photo bytes | +| POST | `/me/forum-laws-dismissed` | Bearer | Dismiss welcome-forum living-room laws | +| POST | `/me/notification-level` | Bearer | Set owner fan-out filter (`all` / `active` / `mentions`) | +| 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 (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 | `/members/:accountId` | Bearer | Live member identity + profile note + `aboutMeHasPhoto` + counts + `trust` | +| GET | `/members/:accountId/activity` | Bearer | Same given/received payload as `/me/activity` for that member | +| GET | `/members/:accountId/posts` | Bearer | Live member top-level notes (latest 200) | +| GET | `/members/:accountId/replies` | Bearer | Live member replies (latest 200) | +| GET | `/trust-chain` | Bearer | Founder seeds (empty edges); `?around=` one hop of stored public edges | +| POST | `/trust/verify` | Bearer (moderator+) | Staff: confirm a person in real life (`verified`) | +| POST | `/trust/propose-moderator` | Bearer (moderator+) | Staff: propose a verified member as moderator | +| GET | `/trust/proposals` | Bearer (moderator+) | Staff: list pending moderator proposals | +| POST | `/trust/confirm-moderator` | Bearer (moderator+) | Staff: second, independent confirmation → `moderator` | +| POST | `/trust/appoint-moderator` | Bearer (founder) | Founder: appoint a moderator directly | +| POST | `/funding/apply` | Bearer | Member apply (verified+; `basis` 403) | +| GET | `/funding/applications` | Bearer (moderator+) | Staff pending grant queue | +| GET | `/funding/applications/:accountId` | Bearer (moderator+) | Staff grant review | +| POST | `/funding/trial` | Bearer (moderator+) | One-UTC-day trial | +| POST | `/funding/admit` | Bearer (moderator+) | Admit grant | +| POST | `/funding/reject` | Bearer (moderator+) | Reject grant | +| GET | `/messages` | Bearer | List top-level forum notes (+ visible `replyCount`); 409 if rules missing | +| POST | `/messages` | Bearer | Post text/photo; 409 if rules/name/username/Lightning Address missing | +| GET | `/messages/hidden` | Bearer (moderator+) | Staff log of soft-hidden notes (session, not DEBUG_TOKEN) | +| GET | `/messages/:id` | none / Bearer (moderator+) | Live public JSON; staff hidden GET includes `deletedAt`/`deletedBy` | +| GET | `/messages/:id/replies` | none / Bearer (moderator+) | Live replies; staff `listReplies(..., true)` includes hidden children even under a live parent | +| GET | `/messages/:id/photo` | none / Bearer (moderator+) | Live photo bytes; staff hidden bytes `Cache-Control: private, no-store` | +| GET | `/messages/:id/video.*` | none / Bearer (moderator+) | Live video bytes; staff hidden bytes `Cache-Control: private, no-store` | +| DELETE | `/messages/:id` | Bearer (moderator+) | Soft-hide note + direct replies; retract in-app notifications; external target also blocks that pubkey | +| POST | `/messages/:id/invoice` | Bearer | NIP-57 zap / BOLT11 | +| POST | `/contact` | Bearer | Send private in-app contact `{ text }` | +| GET | `/conversations` | Bearer | List visible private threads (per-row `unreadMessageCount`; envelope `unreadCount` is thread count) | +| GET | `/conversations/moderator-group` | Bearer (moderator+) | Open/ensure closed moderator-group tool | +| POST | `/conversations` | Bearer | Open thread from a forum note (`forumMessageId`) | +| GET | `/conversations/:id` | Bearer | Oldest-first messages (`?sinceMessageId=` long-polls until that id exists) | +| GET | `/conversations/:id/messages/:messageId/photo` | Bearer | Private photo 0 bytes | +| GET | `/conversations/:id/messages/:messageId/photo/:file` | Bearer | Private extra stills 1–9 (`{1-9}.{jpg, jpeg, png, webp}`) | +| POST | `/conversations/:id` | Bearer | Send `{ text?, photo?, photos? }` (photos only on moderator_group) | +| POST | `/conversations/:id/invoice` | Bearer | NIP-57 zap / BOLT11 for a private gift (`{ sats, text? }` → `{ pr, amountSats, messageId }`) | +| POST | `/conversations/:id/read` | Bearer | Stamp last-read for the viewer | +| GET | `/notifications` | Bearer | List + unreadCount; drop leftover hidden forum_post/forum_reply (zap checks parent only) | +| POST | `/notifications/read-all` | Bearer | Mark all notifications read | +| POST | `/notifications/:id/read` | Bearer | Mark one notification read | +| GET | `/lightning-address` | none | Resolve LUD-16 metadata (cached) | +| GET | `/debug/accounts` | `Authorization: Bearer` | Operator account listing (`DEBUG_TOKEN`) | +| POST | `/debug/accounts` | `Authorization: Bearer` | Operator provision name + Lightning Address (`DEBUG_TOKEN`) | +| PATCH | `/debug/accounts/:id` | `Authorization: Bearer` | Operator set `role` / unlink Lightning Address / `platform` / `sessionRefused` | +| POST | `/debug/accounts/:id/session` | `Authorization: Bearer` | Operator mint of a member bearer (`DEBUG_TOKEN`) | +| GET | `/debug/api-log` | `Authorization: Bearer` | Operator HTTP audit log (`DEBUG_TOKEN`); no query string, body, or Authorization | +| GET | `/debug/contacts` | `Authorization: Bearer` | Operator contact listing (`DEBUG_TOKEN`) | +| GET | `/debug/invoices` | `Authorization: Bearer` | Operator invoice attempts, forum and conversation (`DEBUG_TOKEN`) | +| POST | `/debug/invoices/settle` | `Authorization: Bearer` | Resumable operator settlement of a paid forum invoice (`DEBUG_TOKEN`) | +| GET | `/debug/zap-ingests` | `Authorization: Bearer` | Operator kind:9735 ingest log (`DEBUG_TOKEN`) | +| GET | `/debug/messages` | `Authorization: Bearer` | Operator forum listing including hidden rows and replies (`DEBUG_TOKEN`) | +| GET | `/debug/messages/:id` | `Authorization: Bearer` | Operator single-note fetch including hidden rows (`DEBUG_TOKEN`) | +| GET | `/debug/messages/:id/photo` | `Authorization: Bearer` | Operator photo bytes including hidden notes (`DEBUG_TOKEN`) | +| PUT | `/debug/messages/:id/video` | `Authorization: Bearer` | Operator restore of missing forum-video bytes (`DEBUG_TOKEN`) | +| POST | `/debug/messages/:id/restore` | `Authorization: Bearer` | Operator unhide of a soft-hidden forum note (`DEBUG_TOKEN`) | +| GET | `/debug/external-pubkeys` | `Authorization: Bearer` | Operator lists entitled and blocked external pubkeys (`DEBUG_TOKEN`) | +| POST | `/debug/trust-edges` | `Authorization: Bearer` | Operator trust-edge backfill (`DEBUG_TOKEN`); does not change `role` | +| DELETE | `/debug/trust-edges` | `Authorization: Bearer` | Operator trust-edge delete (`DEBUG_TOKEN`); does not change `role` | +| GET | `/push/vapid-public` | Bearer | VAPID public key for Web Push subscribe | +| POST | `/me/push-subscriptions` | Bearer | Upsert a browser PushSubscription | +| DELETE | `/me/push-subscriptions` | Bearer | Remove a browser PushSubscription | +| POST | `/debug/push-ping` | Bearer `DEBUG_TOKEN` | Enqueue a test push for one account | +| GET | `/gifts` | none | Outbound gifts for one UTC day (`?day=`) | +| GET | `/gifts/stats` | none | Aggregated outbound gift statistics | +| GET | `/invoices/passkey` | Bearer `SPEND_API_TOKEN` | Whether a Lightning Address has a passkey-backed account | +| GET | `/invoices/posted` | Bearer `SPEND_API_TOKEN` | Whether a Lightning Address has a live top-level non-profile forum post | +| GET | `/invoices/eligible` | Bearer `SPEND_API_TOKEN` | Whether the address is funding-eligible today | +| POST | `/invoices` | Bearer `SPEND_API_TOKEN` | Fetch a recipient BOLT11 (LNURL-pay; passkey, funding grant, and forum post required) | +| POST | `/invoices/proof` | Bearer `SPEND_API_TOKEN` | Accept payment preimage as proof | Auth column: "Bearer (X+)" means minimum role X — X or any higher role. @@ -409,7 +421,8 @@ An account with `sessionRefused` and a still-valid minted token → **Response** "hasPosted": false, "aboutMe": null, "aboutMeHasPhoto": false, - "notificationLevel": "all" + "notificationLevel": "all", + "funding": null } ``` @@ -438,6 +451,7 @@ stays `null`)). | `aboutMe` | string \| null | Profile-note text when it is a real bio, else `null` (missing or soft-hidden (`deletedAt` set); auto name-copy is not a bio, including after a display-name rename when the note text still equals the stored profile-note `name` (Ada→Grace with text `Ada` stays `null`)) | | `aboutMeHasPhoto` | boolean | True when the live profile note has a stored JPEG/PNG/WebP. Independent of `aboutMe` (photo-only and name-copy notes can still have a photo). Bytes are `GET /me/about/photo`. Does not expose `profileMessageId`. | | `notificationLevel` | string | Owner fan-out filter: `all`, `active`, or `mentions`. Default `all`. Owner-only; omitted from public `GET /view/:viewKey` and member cards. | +| `funding` | object \| null | Funding-program grant. `null` for `basis`. Otherwise always an object; no row is `{ status: "none", trialUtcDate: null, admittedAt: null, reviewedByName: null }`. Admitted includes live `reviewedByName`. | ### `GET /me/activity` @@ -503,10 +517,12 @@ still equals the stored profile-note `name` (Ada→Grace with text `Ada` stays `null`); keep `profileMessage`), `aboutMeHasPhoto` (true when the live profile note has a stored photo; false when `profileMessage` is `null`), uncapped live `postCount` / `replyCount` from `countByAccount` -(not the latest-200 window), and `trust` (`verifiedBy` / `proposedBy` / -`confirmedBy` / `appointedBy`, each `{ id, name }` or `null`). Default +(not the latest-200 window), `trust` (`verifiedBy` / `proposedBy` / +`confirmedBy` / `appointedBy`, each `{ id, name }` or `null`), and +`fundingReviewedAt` (`grant.admittedAt` when the effective grant is +admitted, else `null`). Default `trust` is all-null when no stored edges exist. Never `viewKey` / -`eventId`. +`eventId`. Never pending/trial/rejected on the member card. ### `GET /members/:accountId/posts` @@ -716,6 +732,49 @@ when the caller is not a founder). **200** `{ id, name, role }` with 200), the api notifies the subject only (`moderator_appointed`, Web Push url `/welcome`). Notify failure does not fail the POST. +### `POST /funding/apply` + +Bearer session. Role `basis` → **403**. Effective status `none` or +`rejected` upserts `pending` (`appliedAt` now; trial/admitted/decided +cleared). `pending` / `trial` / `admitted` → **409**. **200** +`{ "funding": OwnerFundingJson }`. Store throw → **503** +`{ "error": "Funding is unavailable" }` (`funding.write.failed`). + +### `GET /funding/applications` + +Staff Bearer (moderator). Lists effective **pending** grants +oldest `appliedAt` first (expired trials included after lazy persist). +JSON `{ "applications": [ { accountId, name, role, appliedAt } ] }`. +Logs `funding.applications.listed`. Same 401/403/503 shapes as +`GET /trust/proposals` with `{ "error": "Funding is unavailable" }`. + +### `GET /funding/applications/:accountId` + +Staff Bearer. **404** when the id is not a UUID, the account is missing, +or there is no grant. **200** `{ account: { id, name, role, lightningAddress }, +grant: { status, appliedAt, trialUtcDate, admittedAt, decidedAt }, +messages }` with **effective** grant status and the same video-drop as +member posts (`MESSAGE_LIST_LIMIT`, `serializeMessage`). + +### `POST /funding/trial` + +Staff Bearer. Body `{ "accountId" }`. Target must be effective pending, +not self, not `basis`. Sets `trial`, `trialUtcDate` = today UTC, +`decidedAt`/`decidedBy` now. **200** `{ id, name, role, funding }`. +Self / ineligible → **409**. Same 401/403/400/404/503 as +`POST /trust/verify` with Funding-unavailable 503. + +### `POST /funding/admit` + +Staff Bearer. Target effective pending **or** trial, not self, not +`basis`. Sets `admitted`, `admittedAt` now, `trialUtcDate` null. +**200** same shape as trial. + +### `POST /funding/reject` + +Staff Bearer. Target effective pending or trial, not self. Sets +`rejected` and clears trial/admitted. **200** same shape as trial. + ### `GET /view/:viewKey` Public capability URL for a read-only profile card. No auth. Not a session: @@ -2295,6 +2354,21 @@ Success is always **200** (never 404 for an unknown address): or `{ "hasPasskey": false }` when there is no account for the address or the account has no passkey credential. +### `GET /invoices/eligible` + +Spend-worker funding-grant check. Query `address=name@domain.tld`. Same +`SPEND_API_TOKEN` Bearer as `GET /invoices/passkey` (503 unconfigured / +401 unauthorized / 400 invalid address). + +Success is always **200** (never 404 for an unknown address): + +```json +{ "eligible": true } +``` + +or `{ "eligible": false }` when there is no account for the address, the +role is `basis`, or the grant is not admitted / trial-today. + ### `GET /invoices/posted` Spend-worker eligibility check. Query `address=name@domain.tld`. Same @@ -2323,8 +2397,9 @@ note never become `messageId`. Spend-worker invoice fetch. After address and amount validation, the api requires a 21.gifts account for `address` that already has a passkey -credential and at least one live **top-level** forum message that is not the -auto-created profile note. Replies do not unlock an invoice. It then resolves +credential, a funding grant eligible today (`eligibleToday`), and at least +one live **top-level** forum message that is not the auto-created profile +note. Replies do not unlock an invoice. It then resolves LUD-16, GETs the LNURL-pay callback, decodes the BOLT11, and stores `{ id, pr, paymentHash }` in memory. It does not pay. @@ -2386,10 +2461,18 @@ No account for the address, or the account has no passkey credential → { "error": "Passkey required" } ``` +The account has a passkey but is not funding-eligible today (`eligibleToday`) +→ **403** (after the passkey check, before the forum-post check; no invoice +is stored): + +```json +{ "error": "Funding grant required" } +``` + The account has a passkey but no live **top-level** forum message other than the auto-created profile note, or `messageId` is set but is not that -address's live top-level non-profile note → **403** (after the passkey check, -before any LNURL fetch; no invoice is stored): +address's live top-level non-profile note → **403** (after the passkey and +grant checks, before any LNURL fetch; no invoice is stored): ```json { "error": "Forum post required" } @@ -2483,7 +2566,9 @@ Public member forum thread. Bearer session required. After auth, `requireAction(account, 'forum.read')` (rules). Returns **only top-level notes** (`parent_id IS NULL`) via `listFeed`. Query `mode` (`all` default, `active`, `unpaid`, `popular`), `limit` (1–200, default -**200**), and opaque `cursor`. Response `{ messages }` plus `nextCursor` +**200**), opaque `cursor`, and optional `hashtag` (name without `#`; +token match on live top-level `text`; combines with mode/limit/cursor). +Response `{ messages }` plus `nextCursor` only when the page is full. Newest first (`createdAt` descending, then `id`) except `popular` (sats descending). Replies are never listed here — use `GET /messages/:id/replies`. The list path does not load reply rows. @@ -2517,7 +2602,7 @@ Missing/invalid/expired bearer → **Response** `401`: { "error": "Unauthorized" } ``` -Unknown `mode`, `limit` outside 1–200, or a bad/mismatched `cursor` → **Response** `400`: +Unknown `mode`, `limit` outside 1–200, a bad/mismatched `cursor`, or an invalid `hashtag` → **Response** `400`: ```json { "error": "Invalid mode" } @@ -2531,6 +2616,10 @@ Unknown `mode`, `limit` outside 1–200, or a bad/mismatched `cursor` → **Resp { "error": "Invalid cursor" } ``` +```json +{ "error": "Invalid hashtag" } +``` + `mode=active` is paid notes plus unpaid founder/moderator notes; `unpaid` is `sats = 0`; `popular` is paid notes ordered by sats descending. Missing rules → **Response** `409`: @@ -2754,7 +2843,8 @@ POST with the same account, parent, normalised text, and media bytes returns Text-only posts are unchanged (still **429** on burst). After a **new** top-level persist, the api POSTs `{ address, messageId }` to `{SPEND_URL}/ping` with Bearer `SPEND_API_TOKEN` (fire-and-await; `messageId` is the UUID of the new -top-level row). Errors are logged; the POST still +top-level row) only when `eligibleToday` for the author's funding grant. +Otherwise no ping, log `spend.ping.skipped` / `not_eligible`. Errors are logged; the POST still returns **200**. Replies do not ping. Idempotent media replay does not ping again. Unset or blank `SPEND_URL` or `SPEND_API_TOKEN` skips the ping; the process still boots. The worker signs a @@ -3498,6 +3588,8 @@ Success → **Response** `200`: "createdAt": "2026-08-29T12:00:00.000Z", "fromMe": true, "sats": 0, + "hasPhoto": false, + "photoCount": 0, "accountId": "" }, { @@ -3507,6 +3599,8 @@ Success → **Response** `200`: "createdAt": "2026-08-29T12:00:02.000Z", "fromMe": false, "sats": 6158, + "hasPhoto": false, + "photoCount": 0, "accountId": "", "giftFor": "" } @@ -3520,12 +3614,35 @@ for another message (never JSON `null`). Members always receive the stored sender (typically `21.gifts` on a platform send). Staff receive the actor when `actorAccountId` is set. `fromMe` / list `lastFromMe` use the actor when set, otherwise the sender; there is no staff-as-platform shortcut. +`hasPhoto` / `photoCount` (0–10) flag stills; bytes are never in this JSON. List rows also include `lastSats` (0 when the last message is unpaid text). +### `GET /conversations/:id/messages/:messageId/photo` + +Bearer session required. Private photo 0 bytes after `getById` + `canAccess`. +No Damus `.jpg` alias and no public CDN: success is raw image bytes with +`Content-Type` jpeg/png/webp, `Content-Disposition: inline; filename="photo.{jpg|png|webp}"`, +`Cache-Control: private, no-store`, and **no** `Access-Control-Allow-Origin`. +Missing/forbidden thread → **404** `{ "error": "Not found" }`. Missing still, +non-UUID message id, or a message in another thread → **404** +`{ "error": "Photo not found" }`. No bearer → **401**. Store throw → **503** +`{ "error": "Conversations are unavailable" }` (`conversations.photo.failed`). + +These routes register **before** `GET /conversations/:id`. + +### `GET /conversations/:id/messages/:messageId/photo/:file` + +Bearer session required. Extra stills 1–9. `:file` must match +`^([1-9])\.(jpg|jpeg|png|webp)$`; else **404** `{ "error": "Photo not found" }`. +There is **no** `/photo/0.jpg`. Same auth, belonging, private cache headers, +and 401/404/503 JSON as photo 0. + ### `POST /conversations/:id` -Bearer session required. Body `{ "text": "…" }` 1–500 via -`normalizeForumText`. Moderator replies on a +Bearer session required. Body `{ "text"?: "…", "photo"?: { "contentType", "data" }, "photos"?: [{ "contentType", "data" }] }` +(at most 10 stills; non-empty `photos` wins over singular `photo`). Text 1–500 via +`normalizeForumText`. Empty text is allowed only on `moderator_group` when a still is present; +photos on Direct/Contact/Damus are 400. Moderator replies on a platform thread persist as the platform account (sender + Nostr nsec) and record the logged-in staff as `actorAccountId` / `actorName`. Staff JSON uses the actor; members still see `21.gifts`. The worker signs with the @@ -3538,19 +3655,30 @@ HTTP body; `groupMessageId` is the new conversation message id) only when Lightning Address is a non-empty trimmed string, `spendPing` is set, **and** the caller has a live living-room top-level post (not the profile note) whose `createdAt` is on -the same UTC day. No such post → **200**, no ping, -log `spend.ping.skipped` / `no_public_post`. Ping throw still **200**. +the same UTC day **and** `eligibleToday` for the author's funding grant. +No such post → **200**, no ping, log `spend.ping.skipped` / +`no_public_post`. Public post today but not funding-eligible → **200**, no +ping, log `spend.ping.skipped` / `not_eligible`. Ping throw still **200**. Living-room lookup failure after persist is still **200**, no ping, log -`spend.ping.skipped` / `posted_unreachable`. Empty or invalid text is -**400** and does not ping. Verified, basis and the platform account **404** +`spend.ping.skipped` / `posted_unreachable`. Empty or invalid text **without a still** +is **400** and does not ping. Verified, basis and the platform account **404** on that id. -Same 401 / 400 text / 404 / 503 shapes as the list/get routes, plus +Same 401 / 404 / 503 shapes as the list/get routes, plus +**400** `{ "error": "Expected a JSON body with text and/or photo" }` +(including any `video` field; stills only), +**400** `{ "error": "At most 10 photos" }`, +**400** `{ "error": "Photo must be a JPEG, PNG, or WebP under 1 MiB" }`, +**400** `{ "error": "Photos are only allowed in the Moderators group" }`, +**400** `{ "error": "Text must be 1–500 characters or include a photo" }` +(moderator-group empty text without a still), +**400** `{ "error": "Text must be 1–500 characters" }`, **400** `{ "error": "Set a name before posting" }` when the sending member has no display name. Success → **Response** `200` (one public conversation message, including -optional `accountId` — actor for staff when set, otherwise sender). After persist, the api enqueues one Web Push +`hasPhoto`, `photoCount` 0–10, and optional `accountId` — actor for staff +when set, otherwise sender; never photo bytes). After persist, the api enqueues one Web Push (`type: conversation`, url `/messages?c=`) to each bell-subscribed counterpart. `unreadCount` on that payload (and on forum and zap payloads) is in-app notification unread plus listed inbox unread. diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 16be7458..fc71d160 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -206,7 +206,7 @@ ## Endpoint: POST /auth/passkey/authenticate/finish -- **Purpose:** Verifies the assertion and issues `{ token, account }` immediately. Requires `Origin`. `{ token, account }` uses owner JSON including `hasPosted`, `aboutMe`, `aboutMeHasPhoto`, and `notificationLevel`. An account with `sessionRefused` is refused with no bearer. +- **Purpose:** Verifies the assertion and issues `{ token, account }` immediately. Requires `Origin`. `{ token, account }` uses owner JSON including `hasPosted`, `aboutMe`, `aboutMeHasPhoto`, `notificationLevel`, and `funding`. An account with `sessionRefused` is refused with no bearer. - **Errors:** 400 invalid body/origin/challenge/credential; 403 `{ error: 'You signed in with the wrong account. Please try again with the correct account.' }` when `sessionRefused` is true; 500 if WebAuthn is unconfigured. - **Used by:** App passkey sign-in. - **Auth:** Public (proof is the assertion). @@ -220,7 +220,7 @@ ## Endpoint: POST /auth/passkey/register/finish -- **Purpose:** Verifies the attestation, creates a `linkingKey: null` account (or binds a passkey to a provisioned account without recreating it), issues `{ token, account }`. Requires `Origin`. `{ token, account }` uses owner JSON including `hasPosted`, `aboutMe`, `aboutMeHasPhoto`, and `notificationLevel`. An account with `sessionRefused` is refused with no bearer. +- **Purpose:** Verifies the attestation, creates a `linkingKey: null` account (or binds a passkey to a provisioned account without recreating it), issues `{ token, account }`. Requires `Origin`. `{ token, account }` uses owner JSON including `hasPosted`, `aboutMe`, `aboutMeHasPhoto`, `notificationLevel`, and `funding`. An account with `sessionRefused` is refused with no bearer. - **Errors:** 400 invalid body/origin/challenge/passkey; 403 `{ error: 'You signed in with the wrong account. Please try again with the correct account.' }` when `sessionRefused` is true; 500 if WebAuthn is unconfigured. - **Used by:** App passkey account creation and claim-by-viewKey. - **Auth:** Public (proof is the attestation). @@ -295,6 +295,13 @@ - **Used by:** the external spend worker before issuing a gift invoice. - **Auth:** `Authorization: Bearer` matching `SPEND_API_TOKEN`. +## Endpoint: GET /invoices/eligible + +- **Purpose:** Spend-worker only. Query `address=local@domain`. Returns `{ eligible: boolean }` so spend can filter before issue. Fail closed: unknown address, `basis`, or a grant that is not admitted / trial-today → `eligible: false` (always HTTP 200 on success; never 404). +- **Errors:** 503 if the token env is unset; 401 wrong/missing Bearer; 400 missing or invalid Lightning Address (`Not a valid Lightning Address (expected name@domain)`). +- **Used by:** the external spend worker before issuing a gift invoice. +- **Auth:** `Authorization: Bearer` matching `SPEND_API_TOKEN`. + ## Endpoint: GET /invoices/posted - **Purpose:** Spend-worker only. Query `address=local@domain`. Returns `{ hasPosted, messageId, postedAt }` (`messageId` newest live top-level non-profile id, or null; `postedAt` that row's `createdAt` ISO-8601, or null). `hasPosted: false` always pairs with `messageId: null` and `postedAt: null`. `hasPosted: true` can still have `messageId: null` and `postedAt: null` when `listPostsByAccount` yields no non-profile row. Fail closed: unknown address, or account with no live **top-level** forum message that is not the auto-created profile note → `hasPosted: false`, `messageId: null`, `postedAt: null` (always HTTP 200 on success; never 404). Replies do not count. Photo-only / empty-text top-level notes still count. Damus-only rows (`accountId` null) and soft-deleted rows do not. @@ -304,8 +311,8 @@ ## Endpoint: POST /invoices -- **Purpose:** Spend-worker only. Bearer `SPEND_API_TOKEN`. Body `{ address, amountMsat, comment?, messageId?, groupMessageId? }` (`comment` max 255). Optional `messageId` UUID; when set, 403 Forum post required if not that author's live top-level non-profile note; 503 `Platform account is not configured` if no isPlatform account. Optional `groupMessageId` UUID, mutually exclusive with `messageId`. Display-only: stored only when it is that address's message in the closed `moderator_group` thread and an `isPlatform` account exists; otherwise the invoice is still issued and `invoice.group_message_ignored` is logged. A missing conversation store never blocks the 200. The living-room post gate still applies when `groupMessageId` is set. Requires a 21.gifts account for `address` that already has a passkey credential and at least one live **top-level** forum message that is not the auto-created profile note. Replies do not unlock (`Forum post required`). Then resolves LUD-16, fetches a BOLT11 via LNURL-pay, decodes hash/amount, stores the invoice in memory. -- **Errors:** 503 if the token env is unset; 503 `{ error: 'Platform account is not configured' }` when `messageId` is set and there is no `isPlatform` account (before LNURL); 401 wrong/missing Bearer; 400 bad JSON/address/amount/`comment` longer than 255, invalid `messageId` or `groupMessageId` UUID, or both `messageId` and `groupMessageId` set; 403 `{ error: 'Passkey required' }` when there is no account or the account has no passkey (before LNURL); 403 `{ error: 'Forum post required' }` when the account has a passkey but no live top-level non-profile forum row, or when `messageId` is set but is not that author's live top-level non-profile note (after passkey, before LNURL); 502 provider did not issue a matching invoice. +- **Purpose:** Spend-worker only. Bearer `SPEND_API_TOKEN`. Body `{ address, amountMsat, comment?, messageId?, groupMessageId? }` (`comment` max 255). Optional `messageId` UUID; when set, 403 Forum post required if not that author's live top-level non-profile note; 503 `Platform account is not configured` if no isPlatform account. Optional `groupMessageId` UUID, mutually exclusive with `messageId`. Display-only: stored only when it is that address's message in the closed `moderator_group` thread and an `isPlatform` account exists; otherwise the invoice is still issued and `invoice.group_message_ignored` is logged. A missing conversation store never blocks the 200. The living-room post gate still applies when `groupMessageId` is set. Requires a 21.gifts account for `address` that already has a passkey credential, is `eligibleToday` (admitted, or trial whose `trialUtcDate` is today UTC; `basis` never), and has at least one live **top-level** forum message that is not the auto-created profile note. Replies do not unlock (`Forum post required`). Then resolves LUD-16, fetches a BOLT11 via LNURL-pay, decodes hash/amount, stores the invoice in memory. +- **Errors:** 503 if the token env is unset; 503 `{ error: 'Platform account is not configured' }` when `messageId` is set and there is no `isPlatform` account (before LNURL); 401 wrong/missing Bearer; 400 bad JSON/address/amount/`comment` longer than 255, invalid `messageId` or `groupMessageId` UUID, or both `messageId` and `groupMessageId` set; 403 `{ error: 'Passkey required' }` when there is no account or the account has no passkey (before LNURL); 403 `{ error: 'Funding grant required' }` when the account has a passkey but is not `eligibleToday` (after passkey, before the living-room post check and LNURL); 403 `{ error: 'Forum post required' }` when the account has a passkey and is eligible but no live top-level non-profile forum row, or when `messageId` is set but is not that author's live top-level non-profile note (after grant, before LNURL); 502 provider did not issue a matching invoice. - **Used by:** the external spend worker before paying via lightning.space. - **Auth:** `Authorization: Bearer` matching `SPEND_API_TOKEN`. @@ -325,14 +332,14 @@ ## Endpoint: GET /me -- **Purpose:** Bearer session. Current owner account JSON (id, linkingKey, role, name, `username` (`string | null` LUD-16 / NIP-05 local-part), `location` (`string | null`, never omit, never `""`), lightning address, verified flag, forumLawsDismissed, `createdAt`, `rulesAgreedAt`, owner `viewKey`, `setup`, `missing`, `hasPosted`, `aboutMe`, `aboutMeHasPhoto`, `notificationLevel`). `hasPosted` is true when the account has a live forum row that is not the auto-created profile note (`profileMessageId` excluded). `aboutMe` is the profile-note text when it is a real bio, else `null` (missing or soft-hidden (`deletedAt` set); auto name-copy is not a bio). `aboutMeHasPhoto` is true when the live profile note has a stored photo. `notificationLevel` is the owner fan-out filter (`all` \| `active` \| `mentions`, default `all`, owner-only). `setup` is the next wizard step (`name` \| `username` \| `lightning-address` \| `rules`) or `null` when complete; username is not skippable; skip timestamps count as done for name and Lightning Address, not username. `missing` lists factually unset fields (`name`, `username`, `lightning-address`, `rules`) even when skipped. Does not expose `profileMessageId`. Location is not a setup step. An account with `sessionRefused` is 403 (not 401) so the client can sign the visitor out. +- **Purpose:** Bearer session. Current owner account JSON (id, linkingKey, role, name, `username` (`string | null` LUD-16 / NIP-05 local-part), `location` (`string | null`, never omit, never `""`), lightning address, verified flag, forumLawsDismissed, `createdAt`, `rulesAgreedAt`, owner `viewKey`, `setup`, `missing`, `hasPosted`, `aboutMe`, `aboutMeHasPhoto`, `notificationLevel`, `funding`). `hasPosted` is true when the account has a live forum row that is not the auto-created profile note (`profileMessageId` excluded). `aboutMe` is the profile-note text when it is a real bio, else `null` (missing or soft-hidden (`deletedAt` set); auto name-copy is not a bio). `aboutMeHasPhoto` is true when the live profile note has a stored photo. `notificationLevel` is the owner fan-out filter (`all` \| `active` \| `mentions`, default `all`, owner-only). `funding` is `null` for `basis`; otherwise always an object (`status: 'none'` when there is no row). `setup` is the next wizard step (`name` \| `username` \| `lightning-address` \| `rules`) or `null` when complete; username is not skippable; skip timestamps count as done for name and Lightning Address, not username. `missing` lists factually unset fields (`name`, `username`, `lightning-address`, `rules`) even when skipped. Does not expose `profileMessageId`. Location is not a setup step. An account with `sessionRefused` is 403 (not 401) so the client can sign the visitor out. - **Errors:** 401 if missing/expired; 403 `{ error: 'You signed in with the wrong account. Please try again with the correct account.' }` when the bearer belongs to an account with `sessionRefused`. - **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`, `username` (`string | null` LUD-16 / NIP-05 local-part), `location` (`string | null`, never omit, never `""`), `role`, `lightningAddress`, ISO `createdAt`, `profileMessage` (`serializeMessage` with `accountId` / `replyCount` like the signed-in forum list, or `null` when no note or when the profile note is soft-hidden via `deletedAt`), derived `aboutMe` (profile-note text when it is a real bio, else `null` when the profile note is missing or soft-hidden via `deletedAt` (same as `profileMessage`); auto name-copy is not a bio; keep `profileMessage`), `aboutMeHasPhoto` (true when the live profile note has a stored photo; false when `profileMessage` is null), uncapped live `postCount` / `replyCount` from `countByAccount` (not the latest-200 window), and `trust` (`accountTrust`: `verifiedBy` / `proposedBy` / `confirmedBy` / `appointedBy`, each `{ id, name }` or `null`; all-null when no stored edges). Soft-hide does **not** clear `account.profileMessageId`. Never includes `viewKey`, linkingKey, npub, nsec, or `eventId`. +- **Purpose:** Bearer required. Live member profile card for `:accountId` (UUID): `id`, `name`, `username` (`string | null` LUD-16 / NIP-05 local-part), `location` (`string | null`, never omit, never `""`), `role`, `lightningAddress`, ISO `createdAt`, `profileMessage` (`serializeMessage` with `accountId` / `replyCount` like the signed-in forum list, or `null` when no note or when the profile note is soft-hidden via `deletedAt`), derived `aboutMe` (profile-note text when it is a real bio, else `null` when the profile note is missing or soft-hidden via `deletedAt` (same as `profileMessage`); auto name-copy is not a bio; keep `profileMessage`), `aboutMeHasPhoto` (true when the live profile note has a stored photo; false when `profileMessage` is null), uncapped live `postCount` / `replyCount` from `countByAccount` (not the latest-200 window), `trust` (`accountTrust`: `verifiedBy` / `proposedBy` / `confirmedBy` / `appointedBy`, each `{ id, name }` or `null`; all-null when no stored edges), and `fundingReviewedAt` (`grant.admittedAt` when the effective grant is admitted, else `null`; does not expose pending/trial/rejected). Soft-hide does **not** clear `account.profileMessageId`. 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. @@ -360,8 +367,8 @@ ## Endpoint: GET /messages -- **Purpose:** Bearer required. After auth, `requireAction(account, 'forum.read')` (needs rules). Lists **top-level** forum notes only (`parent_id` null, `deleted_at` null) via `listFeed`. Query: `mode` (`all` default, `active` = paid notes plus unpaid founder/moderator notes, `unpaid` = `sats = 0`, `popular` = paid sats-desc), `limit` (1–200, default 200), opaque `cursor` (keyset). Body `{ messages }` plus `nextCursor` when `listFeed` returned `limit` rows (before missing-file video drops on listed parents). Newest-first except `popular`. Each row includes author name snapshotted at post unless stored `name` trims empty, then `truncatePubkeyDisplay(row.authorPubkey ?? '')` / `'npub'` if the pubkey is missing, `text`, ISO `createdAt`, `sats`, `payable`, `hasPhoto`, `photoCount` (0–10; `hasPhoto` still means photo 0 exists), `hasVideo`, `videoContentType`, live author `role`, and `replyCount` of live direct children that have either an account or a recorded zapper pubkey (`account_id IS NOT NULL OR (author_pubkey IS NOT NULL AND EXISTS (SELECT 1 FROM nostr_zapper WHERE pubkey = lower(author_pubkey)))`). Soft-hidden top-level notes are omitted. A `hasVideo` row whose file is missing or empty is deleted and omitted. The list path does **not** load replies or drop missing-file video children (`GET /messages/:id/replies` still does). 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 external top-level notes); a row with `accountId === null` and a recorded-zapper author pubkey carries `via: 'nostr'` and never the pubkey (same `serializeMessage` rule as `GET /messages/:id`); never includes `deletedAt` / `deletedBy`; omits `goalSats` when unset (null/0/absent) and includes the key only when a positive whole-sat goal is stored on a top-level note; `payable` is true when the note has a non-empty `eventId` and the author has a non-blank Lightning Address; missing author → `role` `"basis"` and `payable` false. `videoContentType` is `null` when `hasVideo` is false. -- **Errors:** 401 `{ error: 'Unauthorized' }` missing/invalid/expired bearer; 400 `{ error: 'Invalid mode' }` / `{ error: 'Invalid limit' }` / `{ error: 'Invalid cursor' }`; 409 `{ error: 'missing_requirements', missing: ['rules'] }` when rules are not agreed; 503 `{ error: 'Messages are unavailable' }` when the store throws, `serializeMessage` throws (invalid `createdAt`), or author lookup throws (`messages.list.failed`). +- **Purpose:** Bearer required. After auth, `requireAction(account, 'forum.read')` (needs rules). Lists **top-level** forum notes only (`parent_id` null, `deleted_at` null) via `listFeed`. Query: `mode` (`all` default, `active` = paid notes plus unpaid founder/moderator notes, `unpaid` = `sats = 0`, `popular` = paid sats-desc), `limit` (1–200, default 200), opaque `cursor` (keyset), optional `hashtag` (name without `#`; token match on `text`; combines with mode/limit/cursor). Body `{ messages }` plus `nextCursor` when `listFeed` returned `limit` rows (before missing-file video drops on listed parents). Newest-first except `popular`. Each row includes author name snapshotted at post unless stored `name` trims empty, then `truncatePubkeyDisplay(row.authorPubkey ?? '')` / `'npub'` if the pubkey is missing, `text`, ISO `createdAt`, `sats`, `payable`, `hasPhoto`, `photoCount` (0–10; `hasPhoto` still means photo 0 exists), `hasVideo`, `videoContentType`, live author `role`, and `replyCount` of live direct children that have either an account or a recorded zapper pubkey (`account_id IS NOT NULL OR (author_pubkey IS NOT NULL AND EXISTS (SELECT 1 FROM nostr_zapper WHERE pubkey = lower(author_pubkey)))`). Soft-hidden top-level notes are omitted. A `hasVideo` row whose file is missing or empty is deleted and omitted. The list path does **not** load replies or drop missing-file video children (`GET /messages/:id/replies` still does). 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 external top-level notes); a row with `accountId === null` and a recorded-zapper author pubkey carries `via: 'nostr'` and never the pubkey (same `serializeMessage` rule as `GET /messages/:id`); never includes `deletedAt` / `deletedBy`; omits `goalSats` when unset (null/0/absent) and includes the key only when a positive whole-sat goal is stored on a top-level note; `payable` is true when the note has a non-empty `eventId` and the author has a non-blank Lightning Address; missing author → `role` `"basis"` and `payable` false. `videoContentType` is `null` when `hasVideo` is false. +- **Errors:** 401 `{ error: 'Unauthorized' }` missing/invalid/expired bearer; 400 `{ error: 'Invalid mode' }` / `{ error: 'Invalid limit' }` / `{ error: 'Invalid cursor' }` / `{ error: 'Invalid hashtag' }`; 409 `{ error: 'missing_requirements', missing: ['rules'] }` when rules are not agreed; 503 `{ error: 'Messages are unavailable' }` when the store throws, `serializeMessage` throws (invalid `createdAt`), or author lookup throws (`messages.list.failed`). - **Used by:** App public comment thread. - **Auth:** `Authorization: Bearer` session. @@ -423,7 +430,7 @@ ## Endpoint: POST /messages -- **Purpose:** Bearer required. After auth, `requireAction(account, 'forum.post')` (needs rules + name + username + Lightning Address; skip timestamps do not satisfy; username cannot be skipped). JSON `{ text?, photo?: { contentType, data }, photos?: { contentType, data }[], inReplyTo?, goalSats? }` (`photos` max 10; non-empty `photos` wins over singular `photo`; dual-send uses `photos`) (base64 JPEG/PNG/WebP ≤ 1 MiB) or `multipart/form-data` with `text`, `video` (MP4/WebM/MOV ≤ 32 MiB), optional JPEG/PNG/WebP `poster`, and optional `goalSats` (string form field). Optional `goalSats` is a whole-sat ask on a top-level note (JSON number; multipart string). Omitted, JSON `null`, or a multipart empty/missing field means no goal. Max 10_000_000; above max is rejected, not clamped. 200 JSON may include `goalSats` or omit the key. 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 (singular or `photos`) or video-only allowed; at least one of non-empty trimmed text, photo, non-empty `photos`, or video required. Name snapshot. 200 is the public message including `sats`, `payable`, `hasPhoto`, `photoCount` (0–10), `hasVideo`, `videoContentType`, the session account's live `role`, and `accountId` (not wrapped; never `contentFp`). Identical live photo/video from the same account+parent (same normalised text + same media bytes) returns the existing row (200, same id) without consuming the 1/10s burst limiter and without a second push; text-only is unchanged (new row + burst). New notes have `sats` 0 and `payable` false until signed (and stay `payable` false without author LN). Top-level creates call `notifyForumPost` (kind `forum_post`, tag `forum_post:`, url `/notifications`) for every account except the actor (no-op when the actor is the official platform account), then filtered by each account's `notificationLevel` (Web Push still only to bell subscribers, same filter). After a new top-level persist, the api POSTs `{ address, messageId }` to `{SPEND_URL}/ping` with Bearer `SPEND_API_TOKEN` (fire-and-await, errors logged, still 200). Replies do not ping. A reply calls `notifyForumReply` (kind `forum_reply`, tag `forum_reply:`, url `/notifications`) for every account except the actor (no-op when the actor is the official platform account), then filtered by each account's `notificationLevel` — Damus-only parents still fan out; a self-reply skips only the actor. The booted process always has those stores (in-memory without `DATABASE_URL`, Postgres when it is set). Photo-only empty text still notifies; missing `pushStore` still writes in-app rows; notification or push failure still returns 200. It does not copy into the member↔member inbox. Unpaid replies from anyone except the parent author or `verified` are 403. +- **Purpose:** Bearer required. After auth, `requireAction(account, 'forum.post')` (needs rules + name + username + Lightning Address; skip timestamps do not satisfy; username cannot be skipped). JSON `{ text?, photo?: { contentType, data }, photos?: { contentType, data }[], inReplyTo?, goalSats? }` (`photos` max 10; non-empty `photos` wins over singular `photo`; dual-send uses `photos`) (base64 JPEG/PNG/WebP ≤ 1 MiB) or `multipart/form-data` with `text`, `video` (MP4/WebM/MOV ≤ 32 MiB), optional JPEG/PNG/WebP `poster`, and optional `goalSats` (string form field). Optional `goalSats` is a whole-sat ask on a top-level note (JSON number; multipart string). Omitted, JSON `null`, or a multipart empty/missing field means no goal. Max 10_000_000; above max is rejected, not clamped. 200 JSON may include `goalSats` or omit the key. 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 (singular or `photos`) or video-only allowed; at least one of non-empty trimmed text, photo, non-empty `photos`, or video required. Name snapshot. 200 is the public message including `sats`, `payable`, `hasPhoto`, `photoCount` (0–10), `hasVideo`, `videoContentType`, the session account's live `role`, and `accountId` (not wrapped; never `contentFp`). Identical live photo/video from the same account+parent (same normalised text + same media bytes) returns the existing row (200, same id) without consuming the 1/10s burst limiter and without a second push; text-only is unchanged (new row + burst). New notes have `sats` 0 and `payable` false until signed (and stay `payable` false without author LN). Top-level creates call `notifyForumPost` (kind `forum_post`, tag `forum_post:`, url `/notifications`) for every account except the actor (no-op when the actor is the official platform account), then filtered by each account's `notificationLevel` (Web Push still only to bell subscribers, same filter). After a new top-level persist, the api POSTs `{ address, messageId }` to `{SPEND_URL}/ping` with Bearer `SPEND_API_TOKEN` only when `eligibleToday` (fire-and-await, errors logged, still 200; ineligible logs `spend.ping.skipped` / `not_eligible`). Replies do not ping. A reply calls `notifyForumReply` (kind `forum_reply`, tag `forum_reply:`, url `/notifications`) for every account except the actor (no-op when the actor is the official platform account), then filtered by each account's `notificationLevel` — Damus-only parents still fan out; a self-reply skips only the actor. The booted process always has those stores (in-memory without `DATABASE_URL`, Postgres when it is set). Photo-only empty text still notifies; missing `pushStore` still writes in-app rows; notification or push failure still returns 200. It does not copy into the member↔member inbox. Unpaid replies from anyone except the parent author or `verified` are 403. - **Errors:** 401 Unauthorized; 409 `{ error: 'missing_requirements', missing: [...] }` when rules, name, username, and/or Lightning Address are missing (order `rules`, then `name`, then `username`, then `lightning-address`); 400 Expected a JSON body with text and/or photo (including JSON `goalSats` type/range errors); 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 `{ error: 'At most 10 photos' }` when `photos.length > 10`; 400 Poster must be a JPEG, PNG, or WebP under 1 MiB; 400 Video must be an MP4, WebM, or MOV under 32 MiB; 400 `{ error: 'A reply cannot ask for a goal' }` when `inReplyTo` is set and `goalSats` is a positive number; 400 `{ error: 'Goal must be a positive whole-sat amount' }` when a multipart `goalSats` is present and not `/^\d+$/` or not an integer 1..10_000_000; 404 `{ error: 'Not found' }` when `inReplyTo` is present but not a UUID, the parent is missing, soft-hidden (`deletedAt` set), or the parent is itself a reply (`parentId !== null`); 403 `{ error: 'A reply needs a Bitcoin payment' }` when `inReplyTo` is a valid parent and the caller is neither the parent author nor `verified`; 429 Too many messages (`Retry-After: 10`); 503 Messages are unavailable (`messages.create.failed`). - **Used by:** App forum composer and reply composer (exempt free replies only). - **Auth:** `Authorization: Bearer` session. @@ -492,8 +499,8 @@ ## Endpoint: GET /conversations/:id -- **Purpose:** Bearer required. `:id` is a UUID. Messages oldest-first (cap 200) as `{ messages: [{ id, name, text, createdAt, fromMe, sats, accountId?, giftFor? }] }`. Envelope is `{ messages }` only (no counterpart `accountId` on the thread). Optional `accountId` is the sender 21.gifts account (omitted when `senderAccountId` is null). For a staff viewer, `name` and `accountId` are the actor when `actorAccountId` is set. Members still see the house/sender snapshot (`21.gifts` on official replies). `fromMe` is true when the actor (else sender) is the viewer; Damus inbound (`senderAccountId` null) is false. No staff-as-platform shortcut. Optional `giftFor` is the id of the group message a paid moderator stipend belongs to (omitted on every other row; never JSON `null`). Optional `?sinceMessageId=` (UUID) long-polls until that id is in the thread (pay-sheet confirmation); timeout still 200 with the current messages. 404 when the session may not see the thread. `moderator_group` is 404 `{ error: 'Not found' }` unless the caller is a group member (`isModeratorGroupMember`: at least moderator, never the platform account) (no existence leak). Unauthenticated 401. -- **Errors:** 401 Unauthorized; 400 `{ error: 'Expected sinceMessageId to be a UUID' }`; 404 Not found; 503 Conversations are unavailable. +- **Purpose:** Bearer required. `:id` is a UUID. Messenger-style keyset paging returns the newest `limit` messages (default/max 200), oldest-first within each page, as `{ messages: [{ id, name, text, createdAt, fromMe, sats, hasPhoto, photoCount, accountId?, giftFor? }], nextCursor? }`. Optional `?limit=` is 1–200. Optional `?cursor=` is base64url JSON `{ k: 't', c: , i: }` encoded and decoded by the message-feed cursor helpers; it selects rows exclusively older than `createdAt` + `id`. A full page includes `nextCursor`, encoded from that page's oldest row; a shorter page omits it. The envelope has no counterpart `accountId` on the thread. Optional message `accountId` is the sender 21.gifts account (omitted when `senderAccountId` is null). For a staff viewer, `name` and `accountId` are the actor when `actorAccountId` is set. Members still see the house/sender snapshot (`21.gifts` on official replies). `fromMe` is true when the actor (else sender) is the viewer; Damus inbound (`senderAccountId` null) is false. No staff-as-platform shortcut. `hasPhoto` / `photoCount` (0–10) flag stills; bytes are never in this JSON. Optional `giftFor` is the id of the group message a paid moderator stipend belongs to (omitted on every other row; never JSON `null`). Optional `?sinceMessageId=` (UUID) long-polls until that id is in the thread (pay-sheet confirmation); timeout still 200 with the current messages, and the response then uses the newest page regardless of a valid supplied cursor. A present invalid cursor is still 400. 404 when the session may not see the thread. `moderator_group` is 404 `{ error: 'Not found' }` unless the caller is a group member (`isModeratorGroupMember`: at least moderator, never the platform account) (no existence leak). Unauthenticated 401. +- **Errors:** 401 Unauthorized; 400 `{ error: 'Expected sinceMessageId to be a UUID' }`; 400 `{ error: 'Invalid limit' }`; 400 `{ error: 'Invalid cursor' }`; 404 Not found; 503 Conversations are unavailable. - **Used by:** App conversation thread and gift pay-sheet poll. - **Auth:** `Authorization: Bearer` session. @@ -506,11 +513,25 @@ ## Endpoint: POST /conversations/:id -- **Purpose:** Bearer required. Body `{ text }` 1–500 via `normalizeForumText`. Appends a message. 200 is the public conversation message (`fromMe` true when this session is the actor; staff JSON `name`/`accountId` are the actor; members still see the platform sender). Staff (at least moderator) replies on a platform thread persist `senderAccountId` as the platform account (worker signs with the platform nsec) and store `actorAccountId`/`actorName` as the logged-in staff. After persist, enqueues one conversation Web Push to bell-subscribed counterparts (member threads `/messages?c=`; `moderator_group` `/moderate/group`; `unreadCount` is notification unread + listed inbox unread; `moderator_group` notifies other accounts with `roleAtLeast` `moderator`, not the platform account). Push failure is void-caught (`conversations.push.failed`) so 200 is unchanged. Does not write in-app Notification rows. Local persist does not wait for relay ACK. On `moderator_group`, sender is the caller account (at least moderator, not platform), `nostrPublishState` skipped (never Nostr). `moderator_group` is 404 `{ error: 'Not found' }` unless the caller is a group member (`isModeratorGroupMember`: at least moderator, never the platform account) (no existence leak). After a new persist, ping `{ address, kind: "moderator", groupMessageId }` only when Lightning Address is a non-empty trimmed string, `spendPing` is set, **and** the caller has a live living-room top-level post (not the profile note) whose `createdAt` is on the same UTC day. No living-room post today → 200, no ping (`spend.ping.skipped` `no_public_post`). Ping throw still 200, no ping. Living-room lookup failure after persist still 200, no ping (`spend.ping.skipped` `posted_unreachable`). Empty/invalid text still 400, no ping. -- **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; 404 Not found; 503 Conversations are unavailable. +- **Purpose:** Bearer required. Body `{ text?, photo?, photos? }` (at most 10 stills; non-empty `photos` wins over singular `photo`). Empty text allowed only in `moderator_group` when a photo is present. Other kinds 400 `{ error: 'Photos are only allowed in the Moderators group' }` if a photo is included. Text 1–500 via `normalizeForumText`. Appends a message. 200 is the public conversation message (`fromMe` true when this session is the actor; staff JSON `name`/`accountId` are the actor; members still see the platform sender; `hasPhoto` and `photoCount` 0–10; never photo bytes). Staff (at least moderator) replies on a platform thread persist `senderAccountId` as the platform account (worker signs with the platform nsec) and store `actorAccountId`/`actorName` as the logged-in staff. After persist, enqueues one conversation Web Push to bell-subscribed counterparts (member threads `/messages?c=`; `moderator_group` `/moderate/group`; `unreadCount` is notification unread + listed inbox unread; `moderator_group` notifies other accounts with `roleAtLeast` `moderator`, not the platform account). Push failure is void-caught (`conversations.push.failed`) so 200 is unchanged. Does not write in-app Notification rows. Local persist does not wait for relay ACK. On `moderator_group`, sender is the caller account (at least moderator, not platform), `nostrPublishState` skipped (never Nostr). `moderator_group` is 404 `{ error: 'Not found' }` unless the caller is a group member (`isModeratorGroupMember`: at least moderator, never the platform account) (no existence leak). After a new persist, ping `{ address, kind: "moderator", groupMessageId }` only when Lightning Address is a non-empty trimmed string, `spendPing` is set, **and** the caller has a live living-room top-level post (not the profile note) whose `createdAt` is on the same UTC day **and** `eligibleToday` for the author's funding grant. No living-room post today → 200, no ping (`spend.ping.skipped` `no_public_post`). Public post today but not funding-eligible → 200, no ping (`spend.ping.skipped` `not_eligible`). Ping throw still 200, no ping. Living-room lookup failure after persist still 200, no ping (`spend.ping.skipped` `posted_unreachable`). Empty/invalid text still 400, no ping (moderator-group empty text without a photo is 400 `{ error: 'Text must be 1–500 characters or include a photo' }`). +- **Errors:** 401 Unauthorized; 400 Expected a JSON body with text and/or photo; 400 At most 10 photos; 400 Photo must be a JPEG, PNG, or WebP under 1 MiB; 400 Photos are only allowed in the Moderators group; 400 Text must be 1–500 characters or include a photo; 400 Set a name before posting; 400 Text must be 1–500 characters; 404 Not found; 503 Conversations are unavailable. - **Used by:** App conversation composer. - **Auth:** `Authorization: Bearer` session. +## Endpoint: GET /conversations/:id/messages/:messageId/photo + +- **Purpose:** Bearer required. UUID `:id` and `:messageId`. After getById + canAccess, serve photo 0 via getPhoto + forumPhotoResponse, then override to `Cache-Control: private, no-store` and drop `Access-Control-Allow-Origin` (forum helper is public CDN; conversation stills stay private). No Damus `.jpg` alias. Missing still or message not in this thread → 404 `{ error: 'Photo not found' }`. Unknown/unauthorized thread → 404 `{ error: 'Not found' }`. Registered before GET `/:id`. +- **Errors:** 401 `{ error: 'Unauthorized' }`; 404 `{ error: 'Not found' }` / `{ error: 'Photo not found' }`; 503 `{ error: 'Conversations are unavailable' }` (`conversations.photo.failed`). +- **Used by:** App moderator-group composer thumbnails. +- **Auth:** `Authorization: Bearer` session. + +## Endpoint: GET /conversations/:id/messages/:messageId/photo/:file + +- **Purpose:** Bearer required. Extra stills 1–9. `:file` must match `^([1-9])\.(jpg|jpeg|png|webp)$`; else 404 `{ error: 'Photo not found' }`. Same canAccess / belonging / private cache headers as photo 0. No `/photo/0.jpg`. +- **Errors:** Same 401 / 404 / 503 as photo 0 (`conversations.photo.failed`). +- **Used by:** App moderator-group extra stills. +- **Auth:** `Authorization: Bearer` session. + ## Endpoint: POST /me/forum-laws-dismissed - **Purpose:** Bearer required. No body. Sets `forumLawsDismissed` to `true` on the account (idempotent; no un-dismiss). Returns the owner account JSON (same as GET `/me`, including `viewKey`). @@ -637,6 +658,48 @@ - **Used by:** Founder appointment of a moderator. - **Auth:** `Authorization: Bearer` session. Founder only. +## Endpoint: POST /funding/apply + +- **Purpose:** Bearer session. Role `basis` → 403. Effective `none` or `rejected` upserts `pending` (`appliedAt` now; trial/admitted/decided cleared). `pending` / `trial` / `admitted` → 409. 200 `{ funding: OwnerFundingJson }`. Logs `funding.applied`. +- **Errors:** 401 `{ error: 'Unauthorized' }`; 403 `{ error: 'Forbidden' }` for `basis`; 409 `{ error: 'Conflict' }`; 503 `{ error: 'Funding is unavailable' }` (`funding.write.failed`). +- **Used by:** App funding apply. +- **Auth:** `Authorization: Bearer` session. Not `basis`. + +## Endpoint: GET /funding/applications + +- **Purpose:** Staff Bearer. Effective pending grants only (expired trials after lazy persist). JSON `{ applications: [{ accountId, name, role, appliedAt }] }` oldest `appliedAt` first. Logs `funding.applications.listed` `{ count }`. +- **Errors:** 401 `{ error: 'Unauthorized' }`; 403 `{ error: 'Forbidden' }` when the live role is not at least moderator; 503 `{ error: 'Funding is unavailable' }` (`funding.list.failed`). +- **Used by:** Staff funding queue. +- **Auth:** `Authorization: Bearer` session (moderator). + +## Endpoint: GET /funding/applications/:accountId + +- **Purpose:** Staff Bearer. 200 `{ account: { id, name, role, lightningAddress }, grant: { status, appliedAt, trialUtcDate, admittedAt, decidedAt }, messages }` with **effective** grant status and the same video-drop as member posts (`MESSAGE_LIST_LIMIT`, `serializeMessage`). +- **Errors:** 401/403 as list; 404 `{ error: 'Not found' }` for a non-UUID, missing account, or no grant; 503 `{ error: 'Funding is unavailable' }` (`funding.list.failed`). +- **Used by:** Staff funding review. +- **Auth:** `Authorization: Bearer` session (moderator). + +## Endpoint: POST /funding/trial + +- **Purpose:** Staff Bearer. Body `{ accountId }`. Target must be effective pending, not self, not `basis`. Sets `trial`, `trialUtcDate` = today UTC, `decidedAt`/`decidedBy` now, `admittedAt` null. 200 `{ id, name, role, funding }`. Logs `funding.trial`. +- **Errors:** 401/403 as list; 400 `{ error: 'Expected a JSON body with an "accountId" string' }`; 404 `{ error: 'Not found' }`; 409 `{ error: 'Conflict' }` for self / not pending / `basis`; 503 `{ error: 'Funding is unavailable' }` (`funding.write.failed`). +- **Used by:** Staff one-day trial. +- **Auth:** `Authorization: Bearer` session. Staff only. + +## Endpoint: POST /funding/admit + +- **Purpose:** Staff Bearer. Body `{ accountId }`. Target effective pending or trial, not self, not `basis`. Sets `admitted`, `admittedAt` now, `trialUtcDate` null. 200 `{ id, name, role, funding }`. Logs `funding.admitted`. +- **Errors:** Same 401/403/400/404/409/503 JSON as `POST /funding/trial`. +- **Used by:** Staff recurring admission. +- **Auth:** `Authorization: Bearer` session. Staff only. + +## Endpoint: POST /funding/reject + +- **Purpose:** Staff Bearer. Body `{ accountId }`. Target effective pending or trial, not self. Sets `rejected` and clears trial/admitted. 200 `{ id, name, role, funding }`. Logs `funding.rejected`. +- **Errors:** Same 401/403/400/404/409/503 JSON as `POST /funding/trial` (409 does not require the subject to be non-`basis`). +- **Used by:** Staff funding reject (subject may re-apply). +- **Auth:** `Authorization: Bearer` session. Staff only. + ## Endpoint: POST /debug/trust-edges - **Purpose:** Operator backfill of a stored trust edge. Body `{ "subjectId", "actorId", "kind" }` with `kind` one of `verify` / `moderator_propose` / `moderator_confirm` / `moderator_appoint`. Inserts the edge, logs `debug.trust_edges.inserted` `{ subjectId, actorId, kind }`, and returns `{ id, subjectId, actorId, kind, createdAt }` (`createdAt` ISO-8601). Does **not** change `account.role`. `PATCH /debug/accounts/:id` remains role-only. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 0d90889b..5514036d 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -178,7 +178,7 @@ ## Function: migrateConversationSchema -- **Purpose:** Applies `CONVERSATION_SCHEMA_SQL` in order (`conversation` + `conversation_message` + `conversation_read` tables and unique indexes, including `conversation_read_conversation_id_idx`). CREATE CHECK includes `moderator_group`; ALTER DROP/ADD `conversation_kind_check`; unique partial index `conversation_moderator_group_uidx`. Additive `ALTER TABLE conversation_message ADD COLUMN IF NOT EXISTS sats bigint NOT NULL DEFAULT 0`, `actor_account_id uuid REFERENCES account (id)`, and `actor_name text NOT NULL DEFAULT ''` (logged-in staff on a platform send; sender stays the platform account), and `gift_for_message_id uuid` (no foreign key; id of the group message a paid moderator stipend belongs to). Unwrap `DO` block is followed by a last stipend-repair `DO` that backfills `gift_for_message_id` on `moderator_group` house stipend rows written before the column existed (idempotent; links a row only when exactly one message of someone else precedes it within five minutes, leaves an ambiguous row `NULL` without writing it, and never touches a row that already has the column set; skipped until the `db_change` audit trigger is attached; the partial index `conversation_message_gift_unlinked_idx` keeps its per-boot check off a sequential scan). The partial index `conversation_message_nostr_event_unrepaired_idx` supports the nostr-event boot repair's predicate so a converged table can be confirmed without a sequential scan. On every boot, the array runs an idempotent repair unwrapping `conversation_message.nostr_event` values stored as jsonb string scalars (`jsonb_typeof(nostr_event) = 'string'`); it matches no rows once complete. The unwrap is skipped while the `db_change` audit trigger is not attached and retried on the next boot; a row whose value cannot be parsed is skipped with a warning instead of failing the migration. `db_change` attach runs later and covers the new public tables. +- **Purpose:** Applies `CONVERSATION_SCHEMA_SQL` in order (`conversation` + `conversation_message` + `conversation_read` tables and unique indexes, including `conversation_read_conversation_id_idx`). CREATE CHECK includes `moderator_group`; ALTER DROP/ADD `conversation_kind_check`; unique partial index `conversation_moderator_group_uidx`. Additive `ALTER TABLE conversation_message ADD COLUMN IF NOT EXISTS sats bigint NOT NULL DEFAULT 0`, `actor_account_id uuid REFERENCES account (id)`, and `actor_name text NOT NULL DEFAULT ''` (logged-in staff on a platform send; sender stays the platform account), and `gift_for_message_id uuid` (no foreign key; id of the group message a paid moderator stipend belongs to). Additive `ALTER TABLE conversation_message ADD COLUMN IF NOT EXISTS photo bytea` and `photo_content_type text`, then `CREATE TABLE IF NOT EXISTS conversation_message_extra_photo` (idx 1–9, ON DELETE CASCADE). Unwrap `DO` block is followed by a last stipend-repair `DO` that backfills `gift_for_message_id` on `moderator_group` house stipend rows written before the column existed (idempotent; links a row only when exactly one message of someone else precedes it within five minutes, leaves an ambiguous row `NULL` without writing it, and never touches a row that already has the column set; skipped until the `db_change` audit trigger is attached; the partial index `conversation_message_gift_unlinked_idx` keeps its per-boot check off a sequential scan). The partial index `conversation_message_nostr_event_unrepaired_idx` supports the nostr-event boot repair's predicate so a converged table can be confirmed without a sequential scan. On every boot, the array runs an idempotent repair unwrapping `conversation_message.nostr_event` values stored as jsonb string scalars (`jsonb_typeof(nostr_event) = 'string'`); it matches no rows once complete. The unwrap is skipped while the `db_change` audit trigger is not attached and retried on the next boot; a row whose value cannot be parsed is skipped with a warning instead of failing the migration. `db_change` attach runs later and covers the new public tables. - **Inputs:** `SqlClient`. - **Returns / side effects:** Void; idempotent SQL execute; `docs/schema/conversation.sql` mirrors the DDL and documents the boot repair statements by comment (the `DO $unwrap$` and stipend-repair `DO` blocks live only in `CONVERSATION_SCHEMA_SQL`). - **Used by:** `openBootStores` when SQL opens, after `migrateContactSchema` and before `migrateDbChangeSchema`. @@ -202,7 +202,7 @@ - **Purpose:** Applies `DB_CHANGE_SCHEMA_SQL` in order so durable Postgres row changes are append-logged in `db_change` via AFTER INSERT/UPDATE/DELETE triggers (not from application store methods). On UPDATE, every bytea column (found via `pg_attribute` on `TG_RELID`) whose value is unchanged and was not hashed by `db_change_redact` is stored in both `before` and `after` as an object with `unchanged` true, `sha256` as the hex digest of the column text, and `bytes` as the `octet_length` of that text; INSERT, DELETE and the UPDATE that changes the bytes keep the full value, so any row state is reconstructable by chaining to the latest earlier full image; secret columns keep their sha256 hash; the no-op comparison still happens on the raw images before redaction. - **Inputs:** `SqlClient`. - **Returns / side effects:** Void; idempotent SQL matching `docs/schema/db_change.sql` (pgcrypto, table, redact/log/immutable functions, triggers, attach loop). The immutability-guard `DO` drops the append-only trigger once, hashes `view_key` values that still match a live `account.view_key`, leaves non-matches unchanged, then recreates the trigger. -- **Used by:** `openBootStores` when SQL opens, immediately after `migrateTrustSchema` (notification → trust → `db_change`). +- **Used by:** `openBootStores` when SQL opens, immediately after `migrateFundingSchema` (notification → trust → funding → `db_change`). ## Function: DB_CHANGE_SCHEMA_SQL @@ -241,7 +241,7 @@ ## Function: PostgresMessageStore -- **Purpose:** Durable `MessageStore` over Postgres (`message` table plus `message_invoice` and `nostr_zap_ingest`). Nullable `goal_sats` (optional whole-sat ask; SQL null means no goal) is selected with the other message columns and inserted on both `create` INSERT shapes (top-level `VALUES` and reply `INSERT … SELECT … WHERE EXISTS`); a non-null `parentId` binds `goal_sats` SQL null even if the row carried a positive `goalSats`; `mapMessageRow` maps it to `goalSats` (`null` when SQL null). `deleteById` removes zap receipts, invoices, child replies, and the row in **one** parameterised data-modifying CTE `query`, then unlinks on-disk videos from the returned rows. `markDeleted` soft-hides via a single UPDATE CTE (`deleted_at` / `deleted_by` on the untagged target and untagged direct replies; never `DELETE FROM message`). `markUndeleted` unhides via a single UPDATE CTE (clears `deleted_at` / `deleted_by` on the hidden target and stamp-matched direct replies; already-live target is a no-op for children; never `DELETE FROM message`). Live-only lists/claims require `deleted_at IS NULL`: `listLatest` is **top-level only** (`WHERE parent_id IS NULL AND deleted_at IS NULL`) with subquery `replyCount` (live attributed direct children, `(child.account_id IS NOT NULL OR (child.author_pubkey IS NOT NULL AND EXISTS (SELECT 1 FROM nostr_zapper z WHERE z.pubkey = lower(child.author_pubkey))))`), selecting Nostr columns plus `(photo IS NOT NULL) AS has_photo`, `deleted_at`, `deleted_by`, and never the `photo` bytea column (HTTP window newest-first; product UX is a messenger group — clients reverse); `listFeed` is the GET `/messages` keyset page (`mode` all/active/unpaid/popular, exclusive cursor, cap 1–200, same live `replyCount`; `popular` is sats-desc); `listReplies` is oldest-first attributed children (`WHERE parent_id = $1` plus the account-or-zapper predicate; `deleted_at IS NULL` unless `includeHidden === true`); `listChildIds` is `SELECT id FROM message WHERE parent_id = $1` (any `deleted_at`); `listDebug` is operator newest-first **all** rows (`SELECT … FROM message ORDER BY created_at DESC, id DESC LIMIT $1`, no `deleted_at` / `parent_id` filter; never `photo` bytea); `listHidden` is staff newest-hidden-first **soft-hidden** rows (`SELECT … FROM message WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC, id DESC LIMIT $1`; never `photo` bytea); `listDirectChildren` is every direct child including hidden (`SELECT … FROM message WHERE parent_id = $1 ORDER BY created_at ASC, id ASC`); `listPublishedEventIds` returns non-null live top-level `event_id`s newest-first for inbound reply REQ; `findLiveByAccountContent` returns the oldest live row for account+parent+`content_fp`; `accountHasLiveTopLevelPost` (`parent_id IS NULL`, exclude profile id, replies do not count); `countByAccount` is one `COUNT(*) FILTER` query of live posts (`parent_id IS NULL`) vs replies (`parent_id IS NOT NULL`) for `account_id = $1` and `deleted_at IS NULL` (uncapped; not derived from a list); `listPostsByAccount` is newest-first live top-level notes for one account (`WHERE parent_id IS NULL AND deleted_at IS NULL AND account_id = $1`, `LIMIT`, subquery `replyCount` of live direct children matching `(child.account_id IS NOT NULL OR (child.author_pubkey IS NOT NULL AND EXISTS (SELECT 1 FROM nostr_zapper z WHERE z.pubkey = lower(child.author_pubkey))))`); `listRepliesByAccount` is newest-first live replies for one account (`WHERE parent_id IS NOT NULL AND deleted_at IS NULL AND account_id = $1`, `LIMIT`, no `replyCount`); `create(row, photo?, video?, extraPhotos?)` inserts optional photo bytes, optional extra stills into `message_extra_photo` (indices 1..n max 9, ignored when `video` is set, require photo 0 when non-empty), optional `video_content_type` (disk write via `writeForumVideo`; `removeForumVideo` unlink on INSERT failure), and `content_fp` when media is present and `account_id` is not null; `photoCount` is (photo 0 ? 1 : 0) + extras length; a non-null `parent_id` requires a live parent (`deleted_at` null) via `INSERT … SELECT … WHERE EXISTS`; a 0-row insert calls `getById` and returns that row when the id already exists (gift-reply retry after the parent was later deleted), otherwise throws without inserting; on unique violation `23505` it returns the existing row when `getById` matches the inserted id (no video unlink; gift-reply retry), otherwise unlinks the new video and returns the existing live row from `findLiveByAccountContent`; `getPhoto` loads bytes by id; `getExtraPhoto(id, index)` / `listExtraPhotos(id)` load extras from `message_extra_photo`; `getById` / `getByEventId` still return soft-hidden rows; `claimUnsigned`/`claimUnpublished` lease live rows (`deleted_at IS NULL`; `claimed_until <= now` is expired; unsigned requires `pending` + null `event_id`); `listPendingSigned` returns live pending rows whose kind:1 lacks `t=bitcoin` (`created_at ASC, id ASC`); `clearSignedEvent` nulls `event_id` / `nostr_event` / `claimed_until` only while `pending` and `event_id` still matches the listed id and no child reply exists (`NOT EXISTS`); `listSignedMissingPhoto` returns published **top-level** live rows (`parent_id IS NULL`, `deleted_at IS NULL`) with a photo whose kind:1 content lacks `/messages/:id/photo.` plus an image extension (`sats = 0`, `nostr_attempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded so fan-out is not starved, video rows / `video_content_type` excluded so posters are not treated as missing photos, parents with children skipped via `NOT EXISTS`, `created_at ASC, id ASC`); `listSignedMissingVideo` returns published **top-level** live rows (`parent_id IS NULL`, `deleted_at IS NULL`) with `video_content_type` set whose kind:1 content lacks `/messages/:id/video.` (`sats = 0`, `nostr_attempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded, parents with children skipped via `NOT EXISTS`, `created_at ASC, id ASC`); `listSignedMissingHashtags` returns published unpaid **top-level** live rows (`parent_id IS NULL`, `deleted_at IS NULL`, parents with children skipped via `NOT EXISTS`) whose kind:1 content lacks a `#bitcoin` or `#21gifts` token (next character must not be `[A-Za-z0-9_]`; `sats = 0`, `nostr_attempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded so fan-out is not starved, includes null / non-string content, `created_at ASC, id ASC`; optional extras map lists rows whose kind:1 also lacks that account's location token; one-arg still bitcoin/21gifts only; optional `excludeIds` applied before the limit so profile notes cannot fill the batch); `resetSignedEvent` nulls `event_id` / `nostr_event` / `claimed_until`, parks `pending`, clears the epoch, increments `nostr_attempts`, and stamps `nostr_first_attempt_at` once, only when `event_id` still matches, `sats` is 0, and no child reply exists (`NOT EXISTS`); `updateSignedEvent` (false on `event_id` collision); `updatePublishState`; `addSats`; `recordZapReceipt` (one statement: `INSERT nostr_zap_receipt ON CONFLICT DO NOTHING` plus `UPDATE message.sats`); `recordInvoiceAttempt` / `listInvoiceAttempts` (each attempt includes `lnurlResponse`: raw LNURL callback JSON object or null); `findOkInvoiceByPaymentHash` / `findOkInvoiceByPr` (newest `result = 'ok'` row, `ORDER BY created_at DESC, id DESC LIMIT 1`); `listOpenConversationZapEventIds` (returns `{ eventId, conversationMessageId }[]`, one row per ok invoice so the same event id may repeat; SQL requires non-null `conversation_id` and `conversation_message_id` and `NOT EXISTS` on `conversation_message`); `updateZapReceiptGift` (`UPDATE nostr_zap_receipt` payer / gift-reply / `comment` columns; omitted patch fields are left unchanged; missing event id is a no-op); `getZapReceiptGift` (one receipt by `event_id`); `listZapReceiptsAwaitingGiftReply` (`(payer_account_id IS NOT NULL OR payer_pubkey IS NOT NULL) AND gift_reply_id IS NULL`, `ORDER BY event_id ASC`, includes `comment`); `recordZapIngest` / `listZapIngests`; `listInvoiceAttemptsForPayer` (uncapped `WHERE payer_account_id = $1`, newest-first); `listIndexedZapIngests` (uncapped `WHERE outcome = 'indexed'`); `updateText` (`UPDATE message SET text = $2 WHERE id = $1 RETURNING …`; sats / photos / event ids unchanged; missing id → no row); `listAuthoredMessages` (`WHERE account_id = $1`, including hidden, no LIMIT). `mapMessageRow` keeps `nostr_publish_state` `skipped` (gift-only replies). +- **Purpose:** Durable `MessageStore` over Postgres (`message` table plus `message_invoice` and `nostr_zap_ingest`). Nullable `goal_sats` (optional whole-sat ask; SQL null means no goal) is selected with the other message columns and inserted on both `create` INSERT shapes (top-level `VALUES` and reply `INSERT … SELECT … WHERE EXISTS`); a non-null `parentId` binds `goal_sats` SQL null even if the row carried a positive `goalSats`; `mapMessageRow` maps it to `goalSats` (`null` when SQL null). `deleteById` removes zap receipts, invoices, child replies, and the row in **one** parameterised data-modifying CTE `query`, then unlinks on-disk videos from the returned rows. `markDeleted` soft-hides via a single UPDATE CTE (`deleted_at` / `deleted_by` on the untagged target and untagged direct replies; never `DELETE FROM message`). `markUndeleted` unhides via a single UPDATE CTE (clears `deleted_at` / `deleted_by` on the hidden target and stamp-matched direct replies; already-live target is a no-op for children; never `DELETE FROM message`). Live-only lists/claims require `deleted_at IS NULL`: `listLatest` is **top-level only** (`WHERE parent_id IS NULL AND deleted_at IS NULL`) with subquery `replyCount` (live attributed direct children, `(child.account_id IS NOT NULL OR (child.author_pubkey IS NOT NULL AND EXISTS (SELECT 1 FROM nostr_zapper z WHERE z.pubkey = lower(child.author_pubkey))))`), selecting Nostr columns plus `(photo IS NOT NULL) AS has_photo`, `deleted_at`, `deleted_by`, and never the `photo` bytea column (HTTP window newest-first; product UX is a messenger group — clients reverse); `listFeed` is the GET `/messages` keyset page (`mode` all/active/unpaid/popular, exclusive cursor, optional `hashtag` token filter on `text`, cap 1–200, same live `replyCount`; `popular` is sats-desc); `listReplies` is oldest-first attributed children (`WHERE parent_id = $1` plus the account-or-zapper predicate; `deleted_at IS NULL` unless `includeHidden === true`); `listChildIds` is `SELECT id FROM message WHERE parent_id = $1` (any `deleted_at`); `listDebug` is operator newest-first **all** rows (`SELECT … FROM message ORDER BY created_at DESC, id DESC LIMIT $1`, no `deleted_at` / `parent_id` filter; never `photo` bytea); `listHidden` is staff newest-hidden-first **soft-hidden** rows (`SELECT … FROM message WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC, id DESC LIMIT $1`; never `photo` bytea); `listDirectChildren` is every direct child including hidden (`SELECT … FROM message WHERE parent_id = $1 ORDER BY created_at ASC, id ASC`); `listPublishedEventIds` returns non-null live top-level `event_id`s newest-first for inbound reply REQ; `findLiveByAccountContent` returns the oldest live row for account+parent+`content_fp`; `accountHasLiveTopLevelPost` (`parent_id IS NULL`, exclude profile id, replies do not count); `countByAccount` is one `COUNT(*) FILTER` query of live posts (`parent_id IS NULL`) vs replies (`parent_id IS NOT NULL`) for `account_id = $1` and `deleted_at IS NULL` (uncapped; not derived from a list); `listPostsByAccount` is newest-first live top-level notes for one account (`WHERE parent_id IS NULL AND deleted_at IS NULL AND account_id = $1`, `LIMIT`, subquery `replyCount` of live direct children matching `(child.account_id IS NOT NULL OR (child.author_pubkey IS NOT NULL AND EXISTS (SELECT 1 FROM nostr_zapper z WHERE z.pubkey = lower(child.author_pubkey))))`); `listRepliesByAccount` is newest-first live replies for one account (`WHERE parent_id IS NOT NULL AND deleted_at IS NULL AND account_id = $1`, `LIMIT`, no `replyCount`); `create(row, photo?, video?, extraPhotos?)` inserts optional photo bytes, optional extra stills into `message_extra_photo` (indices 1..n max 9, ignored when `video` is set, require photo 0 when non-empty), optional `video_content_type` (disk write via `writeForumVideo`; `removeForumVideo` unlink on INSERT failure), and `content_fp` when media is present and `account_id` is not null; `photoCount` is (photo 0 ? 1 : 0) + extras length; a non-null `parent_id` requires a live parent (`deleted_at` null) via `INSERT … SELECT … WHERE EXISTS`; a 0-row insert calls `getById` and returns that row when the id already exists (gift-reply retry after the parent was later deleted), otherwise throws without inserting; on unique violation `23505` it returns the existing row when `getById` matches the inserted id (no video unlink; gift-reply retry), otherwise unlinks the new video and returns the existing live row from `findLiveByAccountContent`; `getPhoto` loads bytes by id; `getExtraPhoto(id, index)` / `listExtraPhotos(id)` load extras from `message_extra_photo`; `getById` / `getByEventId` still return soft-hidden rows; `claimUnsigned`/`claimUnpublished` lease live rows (`deleted_at IS NULL`; `claimed_until <= now` is expired; unsigned requires `pending` + null `event_id`); `listPendingSigned` returns live pending rows whose kind:1 lacks `t=bitcoin` (`created_at ASC, id ASC`); `clearSignedEvent` nulls `event_id` / `nostr_event` / `claimed_until` only while `pending` and `event_id` still matches the listed id and no child reply exists (`NOT EXISTS`); `listSignedMissingPhoto` returns published **top-level** live rows (`parent_id IS NULL`, `deleted_at IS NULL`) with a photo whose kind:1 content lacks `/messages/:id/photo.` plus an image extension (`sats = 0`, `nostr_attempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded so fan-out is not starved, video rows / `video_content_type` excluded so posters are not treated as missing photos, parents with children skipped via `NOT EXISTS`, `created_at ASC, id ASC`); `listSignedMissingVideo` returns published **top-level** live rows (`parent_id IS NULL`, `deleted_at IS NULL`) with `video_content_type` set whose kind:1 content lacks `/messages/:id/video.` (`sats = 0`, `nostr_attempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded, parents with children skipped via `NOT EXISTS`, `created_at ASC, id ASC`); `listSignedMissingHashtags` returns published unpaid **top-level** live rows (`parent_id IS NULL`, `deleted_at IS NULL`, parents with children skipped via `NOT EXISTS`) whose kind:1 content lacks a `#bitcoin` or `#21gifts` token (next character must not be `[A-Za-z0-9_]`; `sats = 0`, `nostr_attempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded so fan-out is not starved, includes null / non-string content, `created_at ASC, id ASC`; optional extras map lists rows whose kind:1 also lacks that account's location token; one-arg still bitcoin/21gifts only; optional `excludeIds` applied before the limit so profile notes cannot fill the batch); `resetSignedEvent` nulls `event_id` / `nostr_event` / `claimed_until`, parks `pending`, clears the epoch, increments `nostr_attempts`, and stamps `nostr_first_attempt_at` once, only when `event_id` still matches, `sats` is 0, and no child reply exists (`NOT EXISTS`); `updateSignedEvent` (false on `event_id` collision); `updatePublishState`; `addSats`; `recordZapReceipt` (one statement: `INSERT nostr_zap_receipt ON CONFLICT DO NOTHING` plus `UPDATE message.sats`); `recordInvoiceAttempt` / `listInvoiceAttempts` (each attempt includes `lnurlResponse`: raw LNURL callback JSON object or null); `findOkInvoiceByPaymentHash` / `findOkInvoiceByPr` (newest `result = 'ok'` row, `ORDER BY created_at DESC, id DESC LIMIT 1`); `listOpenConversationZapEventIds` (returns `{ eventId, conversationMessageId }[]`, one row per ok invoice so the same event id may repeat; SQL requires non-null `conversation_id` and `conversation_message_id` and `NOT EXISTS` on `conversation_message`); `updateZapReceiptGift` (`UPDATE nostr_zap_receipt` payer / gift-reply / `comment` columns; omitted patch fields are left unchanged; missing event id is a no-op); `getZapReceiptGift` (one receipt by `event_id`); `listZapReceiptsAwaitingGiftReply` (`(payer_account_id IS NOT NULL OR payer_pubkey IS NOT NULL) AND gift_reply_id IS NULL`, `ORDER BY event_id ASC`, includes `comment`); `recordZapIngest` / `listZapIngests`; `listInvoiceAttemptsForPayer` (uncapped `WHERE payer_account_id = $1`, newest-first); `listIndexedZapIngests` (uncapped `WHERE outcome = 'indexed'`); `updateText` (`UPDATE message SET text = $2 WHERE id = $1 RETURNING …`; sats / photos / event ids unchanged; missing id → no row); `listAuthoredMessages` (`WHERE account_id = $1`, including hidden, no LIMIT). `mapMessageRow` keeps `nostr_publish_state` `skipped` (gift-only replies). - **External-zapper storage:** `nostr_zap_receipt` adds nullable `payer_pubkey text` and `zap_request_id text`, with partial unique index `nostr_zap_receipt_request_uidx` on `zap_request_id WHERE zap_request_id IS NOT NULL`. `nostr_zapper` stores durable visibility entitlement as `pubkey` (primary key), `receipt_event_id`, and `created_at`; it is independent of receipt queue state and is not cleared by `deleteById`. `nostr_blocked_pubkey` is the staff kill-switch table with `pubkey` (primary key), `blocked_at`, `blocked_by`, and `message_id`. - **External-zapper methods:** `attributeZapReceipt(receiptEventId, { payerPubkey, zapRequestId, comment })` lowercases and stores the payer pubkey, request id, and comment only when the receipt exists, its current request id is null or the same id, and a `NOT EXISTS` check finds no other receipt with that request id. A retry with the same request id on the same receipt is idempotent `true`; a different request id on an already-attributed receipt, reuse by another receipt, or a concurrent partial-index unique violation returns `false`. `recordZapper(pubkey, receiptEventId, at)` lowercases and inserts an entitlement with `ON CONFLICT (pubkey) DO NOTHING`; `listZapperPubkeys()` returns every entitled pubkey; `listZappers(limit)` returns entitlement rows by `created_at DESC, pubkey DESC`. `blockPubkeyAndHideRows(pubkey, at, byAccountId, messageId)` performs that insert-or-skip and case-insensitively updates every live null-account row from the pubkey in one data-modifying CTE query, returning the number hidden; `unblockPubkeyByMessage(messageId)` deletes block rows with that `message_id` and reports whether any row was deleted; `isPubkeyBlocked(pubkey)` lowercases its input and performs a single-row `SELECT 1` lookup; `isZapperPubkey(pubkey)` lowercases its input and performs a single-row `SELECT 1 FROM nostr_zapper` lookup; `listBlockedPubkeys()` returns every blocked pubkey; `listBlockedPubkeyRows(limit)` returns block rows by `blocked_at DESC, pubkey DESC`. `listUnattributedIndexedReceipts(limit, before?)` joins each otherwise-unattributed receipt to its newest indexed `nostr_zap_ingest` frame (`payer_account_id`, `payer_pubkey`, `zap_request_id`, and `gift_reply_id` all null), orders by immutable ingest `created_at DESC, event_id DESC`, and applies an optional strict `{ createdAt, eventId }` keyset cursor. Unlike an `OFFSET` over a result set whose membership changes as receipts are attributed, the cursor cannot skip or repeat rows for that reason. - **Payment claims:** `claimZapPayment` inserts into `nostr_zap_payment` with `ON CONFLICT (payment_hash) DO NOTHING` and then compares the stored `receipt_event_id`: a new row or the same owner returns `true`, another owner `false`. The table has no foreign key to `message` and is not part of the `deleteById` statement, so the claim outlives the forum row. Insert and lookup failures propagate. @@ -259,9 +259,9 @@ ## Function: PostgresConversationStore -- **Purpose:** Durable `ConversationStore` over Postgres (`conversation` + `conversation_message` + `conversation_read`). Open-or-create per counterpart kind, list visible threads, `hasInboundMessage` (EXISTS matching inbound = `conversationIsInbound`), `hasUnread` (parameter-bound EXISTS over `conversation_message` joined to `conversation_read`: `created_at` strictly greater than `last_read_at`, or no last-read row), `countUnread` (parameter-bound `COUNT(*)::bigint` with the same JOIN and inbound/last-read rules; maps bigint/string/number, else 0), `markRead` (`INSERT … ON CONFLICT … DO UPDATE` on `(account_id, conversation_id)`), append messages, claim unsigned/unpublished wraps, unique `event_id`. `openMemberPlatform` updates `account_b` when an existing member→platform thread points at a different platform id. `retargetMemberPlatform` bulk-updates `account_b` on every `member_platform` row whose `account_a` is not the new platform id. `ensureModeratorGroup` opens or inserts the closed `moderator_group` singleton. Unique partial index `conversation_moderator_group_uidx`. `listVisible` binds `$5` moderator flag. `mapMessage` keeps `skipped`. -- **Inputs:** Constructor takes a shared boot `SqlClient` (already migrated). `hasInboundMessage(conversationId, viewerId, staff, platformId)` is parameter-bound EXISTS over `conversation_message`. `countUnread(conversationId, viewerId, staff, platformId)` is parameter-bound `COUNT(*)::bigint` with the same JOIN as `hasUnread`. `listVisible(accountId, staff, platformId, limit, moderator = false)` passes `$5` as the moderator flag. `getModeratorGroup` selects `kind = 'moderator_group'`. `unreadCount` forwards the optional 4th `moderator` flag to `listVisible` and pins an existing `moderator_group` first. -- **Returns / side effects:** Parameter-bound SQL; maps snake_case rows to `ConversationThread` / `ConversationMessageRow`. Unique violations on open/append are swallowed as idempotent. Errors otherwise propagate to the route (503). `mapMessage` keeps `nostr_publish_state` `skipped` (does not remap to `pending`). +- **Purpose:** Durable `ConversationStore` over Postgres (`conversation` + `conversation_message` + `conversation_read` + `conversation_message_extra_photo`). Open-or-create per counterpart kind, list visible threads, `hasInboundMessage` (EXISTS matching inbound = `conversationIsInbound`), `hasUnread` (parameter-bound EXISTS over `conversation_message` joined to `conversation_read`: `created_at` strictly greater than `last_read_at`, or no last-read row), `countUnread` (parameter-bound `COUNT(*)::bigint` with the same JOIN and inbound/last-read rules; maps bigint/string/number, else 0), `markRead` (`INSERT … ON CONFLICT … DO UPDATE` on `(account_id, conversation_id)`), `appendMessage(row, photo?, extraPhotos?)`, `getPhoto(id)`, `getExtraPhoto(id, index 1–9)`, claim unsigned/unpublished wraps, unique `event_id`. `listThreadPage` selects the newest page with `created_at DESC, id DESC`, applies an exclusive older `created_at` + `id` cursor when present, then reverses a copy so the page is returned oldest-first. `listMessages` remains oldest-first. `openMemberPlatform` updates `account_b` when an existing member→platform thread points at a different platform id. `retargetMemberPlatform` bulk-updates `account_b` on every `member_platform` row whose `account_a` is not the new platform id. `ensureModeratorGroup` opens or inserts the closed `moderator_group` singleton. Unique partial index `conversation_moderator_group_uidx`. `listVisible` binds `$5` moderator flag. `mapMessage` keeps `skipped`. `MESSAGE_SELECT` uses computed `has_photo` / `photo_count` (never lists photo bytea). Extra stills live in `conversation_message_extra_photo`. +- **Inputs:** Constructor takes a shared boot `SqlClient` (already migrated). `hasInboundMessage(conversationId, viewerId, staff, platformId)` is parameter-bound EXISTS over `conversation_message`. `countUnread(conversationId, viewerId, staff, platformId)` is parameter-bound `COUNT(*)::bigint` with the same JOIN as `hasUnread`. `listVisible(accountId, staff, platformId, limit, moderator = false)` passes `$5` as the moderator flag. `getModeratorGroup` selects `kind = 'moderator_group'`. `unreadCount` forwards the optional 4th `moderator` flag to `listVisible` and pins an existing `moderator_group` first. `appendMessage(row, photo?, extraPhotos?)` binds photo 0 on the message row and extras at indices 1–9. `getPhoto(id)` / `getExtraPhoto(id, index 1–9)` return byte copies or null (index outside 1–9 is null with no query). +- **Returns / side effects:** Parameter-bound SQL; maps snake_case rows to `ConversationThread` / `ConversationMessageRow`. Unique violations on open/append are swallowed as idempotent (duplicate `id` / `eventId` returns the existing row without inserting extras). Extra-photo insert failure after a successful message insert `DELETE`s that message and rethrows. Errors otherwise propagate to the route (503). `mapMessage` keeps `nostr_publish_state` `skipped` (does not remap to `pending`). - **Used by:** `openBootStores` when `DATABASE_URL` is set. ## Function: fillRatesForGiftRange @@ -322,9 +322,9 @@ ## Function: openBootStores -- **Purpose:** Shared `DATABASE_URL` wiring: one `SqlClient` for durable auth, FX tables, `QueryGiftStore`, `SqlGiftRecorder`, `PostgresBtcUsdStore`, `PostgresFiatStore`, `migrateMessageSchema`, `PostgresMessageStore`, `migrateContactSchema`, `PostgresContactStore`, `migrateConversationSchema`, `PostgresConversationStore`, `migratePushSchema`, `PostgresPushStore`, `migrateNotificationSchema`, `PostgresNotificationStore`, `migrateTrustSchema`, `PostgresTrustStore`, `migrateApiLogSchema`, `PostgresApiLogStore`, `migrateDbChangeSchema`, and parsed `NOSTR_NSEC_KEK`; or in-memory auth, `giftStore`/`giftRecorder`/`messageStore`/`contactStore`/`conversationStore`/`notificationStore`/`pushStore`/`trustStore`/`apiLogStore` undefined, `nostrKek` undefined, empty `InMemoryBtcUsdStore`, and empty `InMemoryFiatStore` when unset. +- **Purpose:** Shared `DATABASE_URL` wiring: one `SqlClient` for durable auth, FX tables, `QueryGiftStore`, `SqlGiftRecorder`, `PostgresBtcUsdStore`, `PostgresFiatStore`, `migrateMessageSchema`, `PostgresMessageStore`, `migrateContactSchema`, `PostgresContactStore`, `migrateConversationSchema`, `PostgresConversationStore`, `migratePushSchema`, `PostgresPushStore`, `migrateNotificationSchema`, `PostgresNotificationStore`, `migrateTrustSchema`, `migrateFundingSchema`, `PostgresTrustStore`, `PostgresFundingStore`, `migrateApiLogSchema`, `PostgresApiLogStore`, `migrateDbChangeSchema`, and parsed `NOSTR_NSEC_KEK`; or in-memory auth, `giftStore`/`giftRecorder`/`messageStore`/`contactStore`/`conversationStore`/`notificationStore`/`pushStore`/`trustStore`/`fundingStore`/`apiLogStore` undefined, `nostrKek` undefined, empty `InMemoryBtcUsdStore`, and empty `InMemoryFiatStore` when unset. - **Inputs:** `databaseUrl`; optional `createClient` (required when URL set); optional `fx: { fetchImpl, candlesUrl, frankfurterUrl, now, nostrQuerier, zapRelayUrls, nostrRelayTimeoutMs }` so tests avoid the network (`candlesUrl` defaults via `resolveCandlesUrl(process.env)`; `frankfurterUrl` defaults via `resolveFrankfurterUrl(process.env)`; the last three feed `backfillExternalZappers` and default to a `WebsocketNostrQuerier`, `resolveZapRelays(process.env)` and a 5000 ms per-relay timeout). SQL path reads `process.env.NOSTR_NSEC_KEK`. -- **Returns / side effects:** `{ authStore, giftStore, giftRecorder, btcUsdRates, fiatRates, messageStore, contactStore, conversationStore, notificationStore, pushStore, trustStore, apiLogStore, nostrKek }`. Migrates `btc_usd_daily` then `usd_fiat_daily`, `message`, `contact`, `conversation` (via `migrateConversationSchema`), `push_subscription`/`push_outbox` (via `migratePushSchema`), `notification` (via `migrateNotificationSchema` after push before `db_change`), then `trust_edge` (via `migrateTrustSchema`) after notification, then `api_log` (via `migrateApiLogSchema`) immediately before `migrateDbChangeSchema` so `trg_db_change` attaches to `trust_edge` and `api_log`, then `db_change` after auth migrate; best-effort `fillRatesForGiftRange` logs `gifts.fx.boot_fill.failed` and does not throw; best-effort `fillFiatRatesForGiftRange` logs `gifts.fx.fiat_boot_fill.failed` and does not throw. Throws if the URL is set without a factory, or if the SQL path has a missing/malformed KEK. SQL path returns `SqlGiftRecorder`, `PostgresMessageStore`, `PostgresContactStore`, `PostgresConversationStore`, `PostgresNotificationStore`, `PostgresPushStore`, `PostgresFiatStore`, `PostgresTrustStore`, and `PostgresApiLogStore`; memory path returns `giftRecorder`/`messageStore`/`contactStore`/`conversationStore`/`notificationStore`/`pushStore`/`trustStore`/`apiLogStore`/`nostrKek` undefined and skips migrates including `migrateConversationSchema` / `migratePushSchema` / `migrateNotificationSchema` / `migrateTrustSchema` / `migrateApiLogSchema` / `migrateDbChangeSchema`. +- **Returns / side effects:** `{ authStore, giftStore, giftRecorder, btcUsdRates, fiatRates, messageStore, contactStore, conversationStore, notificationStore, pushStore, trustStore, fundingStore, apiLogStore, nostrKek }`. Migrates `btc_usd_daily` then `usd_fiat_daily`, `message`, `contact`, `conversation` (via `migrateConversationSchema`), `push_subscription`/`push_outbox` (via `migratePushSchema`), `notification` (via `migrateNotificationSchema` after push before `db_change`), then `trust_edge` (via `migrateTrustSchema`) after notification, then `funding_grant` (via `migrateFundingSchema`), then `api_log` (via `migrateApiLogSchema`) immediately before `migrateDbChangeSchema` so `trg_db_change` attaches to `trust_edge`, `funding_grant`, and `api_log`, then `db_change` after auth migrate; best-effort `fillRatesForGiftRange` logs `gifts.fx.boot_fill.failed` and does not throw; best-effort `fillFiatRatesForGiftRange` logs `gifts.fx.fiat_boot_fill.failed` and does not throw. Throws if the URL is set without a factory, or if the SQL path has a missing/malformed KEK. SQL path returns `SqlGiftRecorder`, `PostgresMessageStore`, `PostgresContactStore`, `PostgresConversationStore`, `PostgresNotificationStore`, `PostgresPushStore`, `PostgresFiatStore`, `PostgresTrustStore`, `PostgresFundingStore`, and `PostgresApiLogStore`; memory path returns `giftRecorder`/`messageStore`/`contactStore`/`conversationStore`/`notificationStore`/`pushStore`/`trustStore`/`fundingStore`/`apiLogStore`/`nostrKek` undefined and skips migrates including `migrateConversationSchema` / `migratePushSchema` / `migrateNotificationSchema` / `migrateTrustSchema` / `migrateFundingSchema` / `migrateApiLogSchema` / `migrateDbChangeSchema`. - **Payment and external-zapper backfills:** Only after `migrateDbChangeSchema` has attached `trg_db_change` to every public table (so the payment backfill's `nostr_zap_payment` inserts are logged), constructs `PostgresMessageStore`, runs `backfillZapPayments`, and immediately runs `backfillExternalZappers` before constructing the remaining Postgres stores and returning. The external-zapper backfill pages through unattributed receipts with a 10,000-row ceiling and logs `nostr.zapper.backfill.done` with its aggregate counts; a failure logs `nostr.zapper.backfill.failed` and boot continues. Payment-backfill failures still propagate. In-memory boots call neither backfill. - **Used by:** `src/index.ts` boot. @@ -603,10 +603,10 @@ ## Function: InMemoryMessageStore -- **Purpose:** Process-local `MessageStore` for the public member forum. Default empty so the process boots without a database. Photos live in a private map, not on listed rows. Extra stills (indices 1–9) live in a second private map (`getExtraPhoto` / `listExtraPhotos`); `create(row, photo?, video?, extraPhotos?)` stores extras (indices 1..n max 9, ignored when `video` is set, require photo 0 when non-empty); `photoCount` is (photo 0 ? 1 : 0) + extras length. Same port as Postgres: `getById` (still returns soft-hidden rows), `deleteById` (row, direct replies, photos, invoices, zap receipt ids, on-disk videos), `markDeleted` (stamps `deletedAt` / `deletedBy` on the target and untagged direct replies; never removes media/invoices), `markUndeleted` (clears `deletedAt` / `deletedBy` on the hidden target and stamp-matched direct children; already-live is a no-op for children; never removes media/invoices), `listDirectChildren` (direct children including hidden, createdAt then id), `getByEventId`, `findLiveByAccountContent` (oldest live account+parent+`contentFp`), `accountHasLiveTopLevelPost` (`parentId === null`, exclude profile id, replies do not count), live-only `listLatest` (top-level, `parentId` null and `deletedAt` null, each row has live `replyCount` of children with an account or a recorded zapper pubkey), live-only `listFeed` (GET `/messages` keyset page), `listReplies` (children with an account or a recorded zapper pubkey; live-only unless `includeHidden === true`), `listChildIds` (direct child ids, any `deletedAt`), `countByAccount` (uncapped live post/reply totals for one account), live-only `listPostsByAccount` (newest-first top-level for one account, cap, live `replyCount` of children with an account or a recorded zapper pubkey), live-only `listRepliesByAccount` (newest-first replies for one account, cap, no `replyCount`), `listDebug` (operator newest-first **all** rows: top-level and replies, live and soft-hidden), `listHidden` (staff newest-hidden-first hidden rows only, `deletedAt` desc then `id` desc), `listDirectChildren` (direct children including hidden, createdAt then id), live-only `listPublishedEventIds`, claim/sign/publish (`claimUnsigned` / `claimUnpublished` skip soft-hidden; unsigned is pending + null `eventId`; lease expires at `claimedUntil`), live-only `listPendingSigned` (pending, no `t=bitcoin`, oldest-first), `clearSignedEvent` (pending and `eventId` still matches `expectedEventId` and the note has no child replies, then nulls `eventId` / `nostrEvent` / `claimedUntil`), live-only `listSignedMissingPhoto` (top-level only, no children, published + photo, kind:1 content lacks `/messages/:id/photo.` plus extension, oldest-first, `sats === 0`, `nostrAttempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded, video rows excluded so posters are not treated as missing photos), live-only `listSignedMissingVideo` (top-level only, no children, published + video MIME, kind:1 content lacks `/messages/:id/video.`, oldest-first, `sats === 0`, `nostrAttempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded), live-only `listSignedMissingHashtags` (top-level only, no children, published unpaid, kind:1 content lacks a `#bitcoin` or `#21gifts` token, oldest-first, `sats === 0`, `nostrAttempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded so fan-out is not starved; optional extras map lists rows whose kind:1 also lacks that account's location token; one-arg still bitcoin/21gifts only; optional `excludeIds` applied before the limit so profile notes cannot fill the batch), `resetSignedEvent` (nulls `eventId` / `nostrEvent` / `claimedUntil`, parks `pending`, clears `nostrPublishEpoch`, increments `nostrAttempts`, and stamps `nostrFirstAttemptAt` once, no-op unless `eventId` still matches, `sats` is 0, and the note has no child replies), `addSats`, `recordZapReceipt` (duplicate receipt id does not add sats; ids are released on `deleteById` so the same receipt can be recorded again), `recordInvoiceAttempt` / `listInvoiceAttempts` (each attempt includes `lnurlResponse` object or null), `findOkInvoiceByPaymentHash` / `findOkInvoiceByPr` (newest `result === 'ok'` by payment hash / BOLT11 `pr`), `listOpenConversationZapEventIds` (returns `{ eventId, conversationMessageId }[]`, one row per ok invoice with both conversation id and conversation message id so the same event id may repeat; no `conversation_message` join — existence filter is in `indexOpenZapReceipts`), `updateZapReceiptGift` (patch payer / gift-reply id / comment; missing receipt is a no-op; omitted patch fields stay), `getZapReceiptGift` (one receipt including comment and gift-reply id), `listZapReceiptsAwaitingGiftReply` (`payerAccountId` or `payerPubkey` set and no gift reply yet, cap, `receiptEventId` ASC, includes `comment`), `recordZapIngest` / `listZapIngests`, `listInvoiceAttemptsForPayer` (uncapped payer filter, newest-first), `listIndexedZapIngests` (uncapped, `outcome = indexed` only), `listAuthoredMessages` (all rows for one account including hidden, no cap), `updateText(id, text)` (mutates `text` only and returns a copy; sats / photos / event ids unchanged; missing id → `undefined`); `create` returns the existing row when `id` is already stored (including after that row's parent was later deleted); a non-null `parentId` requires a live parent (`deletedAt` null), stores `goalSats` null even if the row carried a positive ask, and throws without appending when the parent is missing or soft-hidden; `updateSignedEvent` returns false on duplicate `eventId`. Store/HTTP order is newest-first; product UX is a messenger group (clients reverse). +- **Purpose:** Process-local `MessageStore` for the public member forum. Default empty so the process boots without a database. Photos live in a private map, not on listed rows. Extra stills (indices 1–9) live in a second private map (`getExtraPhoto` / `listExtraPhotos`); `create(row, photo?, video?, extraPhotos?)` stores extras (indices 1..n max 9, ignored when `video` is set, require photo 0 when non-empty); `photoCount` is (photo 0 ? 1 : 0) + extras length. Same port as Postgres: `getById` (still returns soft-hidden rows), `deleteById` (row, direct replies, photos, invoices, zap receipt ids, on-disk videos), `markDeleted` (stamps `deletedAt` / `deletedBy` on the target and untagged direct replies; never removes media/invoices), `markUndeleted` (clears `deletedAt` / `deletedBy` on the hidden target and stamp-matched direct children; already-live is a no-op for children; never removes media/invoices), `listDirectChildren` (direct children including hidden, createdAt then id), `getByEventId`, `findLiveByAccountContent` (oldest live account+parent+`contentFp`), `accountHasLiveTopLevelPost` (`parentId === null`, exclude profile id, replies do not count), live-only `listLatest` (top-level, `parentId` null and `deletedAt` null, each row has live `replyCount` of children with an account or a recorded zapper pubkey), live-only `listFeed` (GET `/messages` keyset page, optional `hashtag`), `listReplies` (children with an account or a recorded zapper pubkey; live-only unless `includeHidden === true`), `listChildIds` (direct child ids, any `deletedAt`), `countByAccount` (uncapped live post/reply totals for one account), live-only `listPostsByAccount` (newest-first top-level for one account, cap, live `replyCount` of children with an account or a recorded zapper pubkey), live-only `listRepliesByAccount` (newest-first replies for one account, cap, no `replyCount`), `listDebug` (operator newest-first **all** rows: top-level and replies, live and soft-hidden), `listHidden` (staff newest-hidden-first hidden rows only, `deletedAt` desc then `id` desc), `listDirectChildren` (direct children including hidden, createdAt then id), live-only `listPublishedEventIds`, claim/sign/publish (`claimUnsigned` / `claimUnpublished` skip soft-hidden; unsigned is pending + null `eventId`; lease expires at `claimedUntil`), live-only `listPendingSigned` (pending, no `t=bitcoin`, oldest-first), `clearSignedEvent` (pending and `eventId` still matches `expectedEventId` and the note has no child replies, then nulls `eventId` / `nostrEvent` / `claimedUntil`), live-only `listSignedMissingPhoto` (top-level only, no children, published + photo, kind:1 content lacks `/messages/:id/photo.` plus extension, oldest-first, `sats === 0`, `nostrAttempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded, video rows excluded so posters are not treated as missing photos), live-only `listSignedMissingVideo` (top-level only, no children, published + video MIME, kind:1 content lacks `/messages/:id/video.`, oldest-first, `sats === 0`, `nostrAttempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded), live-only `listSignedMissingHashtags` (top-level only, no children, published unpaid, kind:1 content lacks a `#bitcoin` or `#21gifts` token, oldest-first, `sats === 0`, `nostrAttempts < MAX_PUBLISH_ATTEMPTS` (5, preventing a row that can never satisfy a repair scan from being reset forever), pending excluded so fan-out is not starved; optional extras map lists rows whose kind:1 also lacks that account's location token; one-arg still bitcoin/21gifts only; optional `excludeIds` applied before the limit so profile notes cannot fill the batch), `resetSignedEvent` (nulls `eventId` / `nostrEvent` / `claimedUntil`, parks `pending`, clears `nostrPublishEpoch`, increments `nostrAttempts`, and stamps `nostrFirstAttemptAt` once, no-op unless `eventId` still matches, `sats` is 0, and the note has no child replies), `addSats`, `recordZapReceipt` (duplicate receipt id does not add sats; ids are released on `deleteById` so the same receipt can be recorded again), `recordInvoiceAttempt` / `listInvoiceAttempts` (each attempt includes `lnurlResponse` object or null), `findOkInvoiceByPaymentHash` / `findOkInvoiceByPr` (newest `result === 'ok'` by payment hash / BOLT11 `pr`), `listOpenConversationZapEventIds` (returns `{ eventId, conversationMessageId }[]`, one row per ok invoice with both conversation id and conversation message id so the same event id may repeat; no `conversation_message` join — existence filter is in `indexOpenZapReceipts`), `updateZapReceiptGift` (patch payer / gift-reply id / comment; missing receipt is a no-op; omitted patch fields stay), `getZapReceiptGift` (one receipt including comment and gift-reply id), `listZapReceiptsAwaitingGiftReply` (`payerAccountId` or `payerPubkey` set and no gift reply yet, cap, `receiptEventId` ASC, includes `comment`), `recordZapIngest` / `listZapIngests`, `listInvoiceAttemptsForPayer` (uncapped payer filter, newest-first), `listIndexedZapIngests` (uncapped, `outcome = indexed` only), `listAuthoredMessages` (all rows for one account including hidden, no cap), `updateText(id, text)` (mutates `text` only and returns a copy; sats / photos / event ids unchanged; missing id → `undefined`); `create` returns the existing row when `id` is already stored (including after that row's parent was later deleted); a non-null `parentId` requires a live parent (`deletedAt` null), stores `goalSats` null even if the row carried a positive ask, and throws without appending when the parent is missing or soft-hidden; `updateSignedEvent` returns false on duplicate `eventId`. Store/HTTP order is newest-first; product UX is a messenger group (clients reverse). - **External-zapper methods:** `attributeZapReceipt(receiptEventId, { payerPubkey, zapRequestId, comment })` returns `false` when the receipt is missing, when that receipt already has a different request id, or when another receipt in the map already has that request id. A retry with the same request id on the same receipt is idempotent `true`; otherwise it lowercases and stores the payer pubkey, request id, and comment. `recordZapper(pubkey, receiptEventId, at)` lowercases the pubkey and stores the first row in a private map that `deleteById` and receipt queue updates do not clear; `listZapperPubkeys()` returns its keys; `listZappers(limit)` sorts copied rows by `createdAt DESC, pubkey DESC` and caps them. `blockPubkeyAndHideRows(pubkey, at, byAccountId, messageId)` performs the same insert-or-skip and synchronously scans every live null-account row for a case-insensitive author match, stamps it, and returns the hidden count as one store operation; `unblockPubkeyByMessage(messageId)` removes the first matching map entry and reports whether one was found; `isPubkeyBlocked(pubkey)` lowercases its input and checks that map; `isZapperPubkey(pubkey)` lowercases its input and checks the zapper map; `listBlockedPubkeys()` returns the map keys; `listBlockedPubkeyRows(limit)` sorts copied rows by `blockedAt DESC, pubkey DESC` and caps them. `listUnattributedIndexedReceipts(limit, before?)` returns one row per receipt-map entry whose `payerAccountId`, `payerPubkey`, `zapRequestId`, and `giftReplyId` are all null, paired with its newest indexed ingest frame (`createdAt` DESC, then `id` DESC, matching the SQL `JOIN LATERAL … LIMIT 1`), sorts by immutable ingest `createdAt DESC, receiptEventId DESC`, applies an optional strict `{ createdAt, eventId }` keyset cursor and the cap, and returns copies. Unlike an offset over a changing unattributed set, the cursor cannot skip or repeat rows as attribution removes entries. - **Payment claims:** `claimZapPayment` keeps one owner receipt id per lowercase payment hash in a process-local map. The same receipt id may claim again; another id is refused. `deleteById` does not remove the claim, so a re-created message id cannot be credited twice for one payment. -- **Inputs:** Optional seed `MessageRow[]` (copied; `hasPhoto` defaults false; missing `deletedAt` / `deletedBy` become null). `listLatest(limit)` is live top-level only with live `replyCount` of children with an `accountId`, or with an `authorPubkey` that is a recorded zapper. `listFeed(query)` is a live top-level keyset page (`mode` / `limit` / exclusive `cursor` / `staffAccountIds` (`active` only), cap 1–200, same live `replyCount` as `listLatest`). `listReplies(parentId, limit?, includeHidden?)` is oldest-first children with an `accountId`, or with an `authorPubkey` that is a recorded zapper (default 200; live-only unless `includeHidden === true`). `listChildIds(parentId)` returns direct child ids (any `deletedAt`). `countByAccount(accountId)` is uncapped live `{ postCount, replyCount }` for that author. `listPostsByAccount(accountId, limit)` is newest-first live top-level for that author with live `replyCount` of children with an `accountId`, or with an `authorPubkey` that is a recorded zapper (cap). `listRepliesByAccount(accountId, limit)` is newest-first live replies for that author (cap, no `replyCount`). `listDebug(limit)` is newest-first all rows including hidden and replies. `listHidden(limit)` is newest-hidden-first hidden rows only (`deletedAt` desc, then `id` desc). `listPublishedEventIds(limit)` is newest-first non-null live top-level `eventId`s. `create(row, photo?, video?, extraPhotos?)` returns the stored row when `id` is already present (no append, no second video write) even if that row's parent was later deleted; a non-null `parentId` requires a live parent (`deletedAt` null), stores `goalSats` null even if the row carried a positive ask, and throws without appending when the parent is missing or soft-hidden; otherwise appends a copy, or returns the existing live media match without a second video write; extras indices 1..n max 9, ignored when `video` is set, require photo 0 when non-empty; `getPhoto(id)` returns a photo copy or null; `getExtraPhoto(id, index)` / `listExtraPhotos(id)` return extra stills from the private map; `photoCount` is (photo 0 ? 1 : 0) + extras length; `markDeleted(id, at, byAccountId)` returns false when missing; `markUndeleted(id)` returns false when missing. +- **Inputs:** Optional seed `MessageRow[]` (copied; `hasPhoto` defaults false; missing `deletedAt` / `deletedBy` become null). `listLatest(limit)` is live top-level only with live `replyCount` of children with an `accountId`, or with an `authorPubkey` that is a recorded zapper. `listFeed(query)` is a live top-level keyset page (`mode` / `limit` / exclusive `cursor` / `staffAccountIds` (`active` only) / optional `hashtag` token filter on `text`, cap 1–200, same live `replyCount` as `listLatest`). `listReplies(parentId, limit?, includeHidden?)` is oldest-first children with an `accountId`, or with an `authorPubkey` that is a recorded zapper (default 200; live-only unless `includeHidden === true`). `listChildIds(parentId)` returns direct child ids (any `deletedAt`). `countByAccount(accountId)` is uncapped live `{ postCount, replyCount }` for that author. `listPostsByAccount(accountId, limit)` is newest-first live top-level for that author with live `replyCount` of children with an `accountId`, or with an `authorPubkey` that is a recorded zapper (cap). `listRepliesByAccount(accountId, limit)` is newest-first live replies for that author (cap, no `replyCount`). `listDebug(limit)` is newest-first all rows including hidden and replies. `listHidden(limit)` is newest-hidden-first hidden rows only (`deletedAt` desc, then `id` desc). `listPublishedEventIds(limit)` is newest-first non-null live top-level `eventId`s. `create(row, photo?, video?, extraPhotos?)` returns the stored row when `id` is already present (no append, no second video write) even if that row's parent was later deleted; a non-null `parentId` requires a live parent (`deletedAt` null), stores `goalSats` null even if the row carried a positive ask, and throws without appending when the parent is missing or soft-hidden; otherwise appends a copy, or returns the existing live media match without a second video write; extras indices 1..n max 9, ignored when `video` is set, require photo 0 when non-empty; `getPhoto(id)` returns a photo copy or null; `getExtraPhoto(id, index)` / `listExtraPhotos(id)` return extra stills from the private map; `photoCount` is (photo 0 ? 1 : 0) + extras length; `markDeleted(id, at, byAccountId)` returns false when missing; `markUndeleted(id)` returns false when missing. - **Returns / side effects:** Promise of row/photo copies; mutating results does not change the store. Listed objects never expose bytes or `contentFp`. When `id` is new, `video` is set, and no live fingerprint match exists, `create` awaits `writeForumVideo` (disk under `MEDIA_DIR`); if that write throws, the row is never pushed (no unlink). - **Used by:** `createApp` default `messageStore`. @@ -619,9 +619,9 @@ ## Function: InMemoryConversationStore -- **Purpose:** Process-local `ConversationStore` for member↔member, member↔platform, member↔Damus, and closed `moderator_group` singleton threads. Default empty so the process boots without a database. `hasInboundMessage` is inbound = `conversationIsInbound`. `hasUnread` is inbound `conversationIsInbound` with `createdAt` strictly greater than last-read (missing stamp = never read). `countUnread` uses the same inbound/last-read predicate and returns the matching message count (`0` when none). `markRead` upserts a private last-read map keyed by accountId + conversationId (Dates copied on construct and store). `ensureModeratorGroup` opens or inserts the singleton (`accountA` = platform; `accountB` and `counterpartPubkey` null). `listVisible` 5th arg `moderator` defaults false. `visibleTo` returns `moderator === true` for that kind first (the `moderator` flag decides, not the staff/platform-id branch; callers pass `roleAtLeast(role, 'moderator')`). -- **Inputs:** Optional seed threads and messages (copied). Optional third constructor seed of last-read rows is copied. Open helpers are idempotent per unique counterpart. `openMemberPlatform` updates `accountB` when the stored platform id differs. `retargetMemberPlatform` points every member→platform thread at the new official account except rows whose member is that account. `listVisible(accountId, staff, platformId, limit, moderator = false)` is newest `lastMessageAt` then `id` DESC. `hasInboundMessage` is true when any message on that conversation id is inbound for the viewer (`conversationIsInbound`). `countUnread` is the inbound unread message count on that conversation id (`0` when none). `unreadCount(accountId, staff, platformId, moderator = false)` uses the same list filter as GET `/conversations` (including `moderator_group` when the 4th arg is true). -- **Returns / side effects:** Promise of copies; mutating results does not change the store. Duplicate `id` or `eventId` append returns the existing row. No I/O. +- **Purpose:** Process-local `ConversationStore` for member↔member, member↔platform, member↔Damus, and closed `moderator_group` singleton threads. Default empty so the process boots without a database. `listThreadPage` takes the newest page in descending `createdAt` + `id` order, applies an exclusive older cursor when present, then reverses it so the returned page is oldest-first; `listMessages` remains oldest-first. `hasInboundMessage` is inbound = `conversationIsInbound`. `hasUnread` is inbound `conversationIsInbound` with `createdAt` strictly greater than last-read (missing stamp = never read). `countUnread` uses the same inbound/last-read predicate and returns the matching message count (`0` when none). `markRead` upserts a private last-read map keyed by accountId + conversationId (Dates copied on construct and store). `ensureModeratorGroup` opens or inserts the singleton (`accountA` = platform; `accountB` and `counterpartPubkey` null). `listVisible` 5th arg `moderator` defaults false. `visibleTo` returns `moderator === true` for that kind first (the `moderator` flag decides, not the staff/platform-id branch; callers pass `roleAtLeast(role, 'moderator')`). `appendMessage(row, photo?, extraPhotos?)` stores photo 0 and extras (indices 1–9, max 9; extras require photo 0). `getPhoto(id)` / `getExtraPhoto(id, index 1–9)` return copies from private maps (bytes never on listed rows). +- **Inputs:** Optional seed threads and messages (copied). Optional third constructor seed of last-read rows is copied. Open helpers are idempotent per unique counterpart. `openMemberPlatform` updates `accountB` when the stored platform id differs. `retargetMemberPlatform` points every member→platform thread at the new official account except rows whose member is that account. `listVisible(accountId, staff, platformId, limit, moderator = false)` is newest `lastMessageAt` then `id` DESC. `hasInboundMessage` is true when any message on that conversation id is inbound for the viewer (`conversationIsInbound`). `countUnread` is the inbound unread message count on that conversation id (`0` when none). `unreadCount(accountId, staff, platformId, moderator = false)` uses the same list filter as GET `/conversations` (including `moderator_group` when the 4th arg is true). `appendMessage(row, photo?, extraPhotos?)` copies stills; `getPhoto(id)` and `getExtraPhoto(id, index 1–9)` look up those copies (index outside 1–9 is null). +- **Returns / side effects:** Promise of copies; mutating results does not change the store. Duplicate `id` or `eventId` append returns the existing row without inserting extras. Listed rows expose `hasPhoto` / `photoCount` (0–10), never photo bytes. No I/O. - **Used by:** `createApp` default `conversationStore`. ## Function: InMemoryLnAddressCache @@ -703,9 +703,9 @@ ## Function: invoiceRoutes -- **Purpose:** Hono sub-app for spend-worker passkey eligibility (`GET /passkey`), live top-level forum-post eligibility (`GET /posted`), invoice issue (`POST /`, optional `messageId` or `groupMessageId`), and preimage proof (`POST /proof`). Issue refuses addresses without a passkey-backed account (403 before LNURL) and without a live top-level non-profile forum post (403 after passkey, before LNURL). Replies do not count. `groupMessageId` is display-only (stored only for that address's `moderator_group` message when a platform account exists; otherwise ignored and the invoice still issues). When `invoice.messageId` is set, a matching proof inserts a platform-account gift-reply first, then `addSats` (idempotent). Platform gift-replies do not notify (no in-app rows, no Web Push; `messages.reply.notify.failed` is not logged on this path; the nested gift-reply still persists); when that message is already a reply (`parentId` set), persists a deterministic `spendGiftReplyId` marker under that reply, `markDeleted` so live `listReplies` omits it, then `addSats`s the reply (a live existing marker is `markDeleted` only and does not `addSats`; no `notifyForumReply`). When `invoice.groupMessageId` is set, a matching proof inserts a platform stipend message in that closed Moderators group (`attachSpendGroupGift`; `giftForMessageId` set to the triggering message's id; idempotent). -- **Inputs:** `InvoiceRouteDeps`: spend token, invoice `store`, `authStore` (`listAccounts`, `getAccount`, `getNostrPublicKey`, plus account + passkey lookup), `messageStore` (`getById`, `addSats`, `create`, `markDeleted`, `listPostsByAccount`, plus live-post lookup), clock, fetch, optional `giftRecorder` (default `NoopGiftRecorder`), optional `conversationStore` (`getById`, `getMessageById`, `appendMessage`; omitted → `groupMessageId` ignored). -- **Returns / side effects:** Hono app mounted at `/invoices`. `GET /passkey` returns `{ hasPasskey }` (200 even when false). `GET /posted` returns `{ hasPosted, messageId, postedAt }` (200 even when false). `messageId` is the newest live top-level non-profile id, or null. `postedAt` is that row's `createdAt` ISO-8601, or null whenever `messageId` is null (including `hasPosted: true` with `messageId: null`). A matching proof (including the same-preimage idempotent 200) calls `recordOutbound` (description `21gifts moderator` when `groupMessageId` is stored, else `21gifts daily`), inserts the platform gift-reply first, then `addSats`, then the Moderators-group stipend message when `groupMessageId` is set (`giftForMessageId` = that triggering id). When `messageId` is already a reply (`parentId` set), attach persists a deterministic `spendGiftReplyId` marker under that reply, `markDeleted` so live `listReplies` omits it, then `addSats`s the reply (a live existing marker is `markDeleted` only and does not `addSats`). Insert failures log `gifts.record_failed` and still return 200. Gift-reply attach skips and logs `invoice.gift_reply.failed` when the parent or platform account is missing; still 200. Group-stipend attach skips and logs `invoice.group_gift.failed` when the triggering row, thread, or platform is missing; still 200. +- **Purpose:** Hono sub-app for spend-worker passkey eligibility (`GET /passkey`), funding-grant eligibility (`GET /eligible`), live top-level forum-post eligibility (`GET /posted`), invoice issue (`POST /`, optional `messageId` or `groupMessageId`), and preimage proof (`POST /proof`). Issue refuses addresses without a passkey-backed account (403 before LNURL), without `eligibleToday` (403 after passkey, before the living-room post check), and without a live top-level non-profile forum post (403 after grant, before LNURL). Replies do not count. `groupMessageId` is display-only (stored only for that address's `moderator_group` message when a platform account exists; otherwise ignored and the invoice still issues). When `invoice.messageId` is set, a matching proof inserts a platform-account gift-reply first, then `addSats` (idempotent). Platform gift-replies do not notify (no in-app rows, no Web Push; `messages.reply.notify.failed` is not logged on this path; the nested gift-reply still persists); when that message is already a reply (`parentId` set), persists a deterministic `spendGiftReplyId` marker under that reply, `markDeleted` so live `listReplies` omits it, then `addSats`s the reply (a live existing marker is `markDeleted` only and does not `addSats`; no `notifyForumReply`). When `invoice.groupMessageId` is set, a matching proof inserts a platform stipend message in that closed Moderators group (`attachSpendGroupGift`; `giftForMessageId` set to the triggering message's id; idempotent). +- **Inputs:** `InvoiceRouteDeps`: spend token, invoice `store`, `authStore` (`listAccounts`, `getAccount`, `getNostrPublicKey`, plus account + passkey lookup), `messageStore` (`getById`, `addSats`, `create`, `markDeleted`, `listPostsByAccount`, plus live-post lookup), clock, fetch, optional `giftRecorder` (default `NoopGiftRecorder`), optional `conversationStore` (`getById`, `getMessageById`, `appendMessage`; omitted → `groupMessageId` ignored), optional `fundingStore` (default empty `InMemoryFundingStore`; grant lookup for `GET /eligible` and `POST /`). +- **Returns / side effects:** Hono app mounted at `/invoices`. `GET /passkey` returns `{ hasPasskey }` (200 even when false). `GET /eligible` returns `{ eligible }` (200 even when false). `GET /posted` returns `{ hasPosted, messageId, postedAt }` (200 even when false). `messageId` is the newest live top-level non-profile id, or null. `postedAt` is that row's `createdAt` ISO-8601, or null whenever `messageId` is null (including `hasPosted: true` with `messageId: null`). A matching proof (including the same-preimage idempotent 200) calls `recordOutbound` (description `21gifts moderator` when `groupMessageId` is stored, else `21gifts daily`), inserts the platform gift-reply first, then `addSats`, then the Moderators-group stipend message when `groupMessageId` is set (`giftForMessageId` = that triggering id). When `messageId` is already a reply (`parentId` set), attach persists a deterministic `spendGiftReplyId` marker under that reply, `markDeleted` so live `listReplies` omits it, then `addSats`s the reply (a live existing marker is `markDeleted` only and does not `addSats`). Insert failures log `gifts.record_failed` and still return 200. Gift-reply attach skips and logs `invoice.gift_reply.failed` when the parent or platform account is missing; still 200. Group-stipend attach skips and logs `invoice.group_gift.failed` when the triggering row, thread, or platform is missing; still 200. - **Used by:** `createApp`. ## Function: NoopGiftRecorder @@ -760,7 +760,7 @@ ## Function: authRoutes - **Purpose:** Hono sub-app for passkey register and authenticate. Register begin accepts an optional `{ viewKey }` to claim a provisioned account; empty begin still mints a pending new account. Passes optional `nostrKek` / `nostrKeygen` into finish so new logins get a custodial nsec. -- **Inputs:** `AuthRouteDeps`: store, `messages`, now, allowedOrigins, webAuthnRpId, webAuthnRpName, passkeyCeremony, optional `nostrKek` and `nostrKeygen`. +- **Inputs:** `AuthRouteDeps`: store, `messages`, now, allowedOrigins, webAuthnRpId, webAuthnRpName, passkeyCeremony, optional `nostrKek` and `nostrKeygen`, optional `fundingStore` (default empty `InMemoryFundingStore`; owner JSON `funding` on finish). - **Returns / side effects:** Hono app mounted at `/auth`. Begin with viewKey maps claim errors to 404/409; unwraps `{ challengeId, options }` on success. - **Used by:** `createApp`. @@ -787,8 +787,8 @@ ## Function: createApp -- **Purpose:** Wires CORS, requestLog, brand, health, info, auth, me, `/view`, lightning-address, `/debug/accounts`, `/debug/contacts`, `/debug/api-log`, `/debug/external-pubkeys`, `/debug/messages`, `/debug/invoices`, `/debug/invoices/settle`, `/debug/zap-ingests`, `/debug/push-ping`, `/debug/trust-edges`, `/trust-chain`, `/trust` (verify / propose-moderator / confirm-moderator / appoint-moderator), Web Push subscription routes, `/gifts`, `/gifts/stats`, `/messages` (incl. invoice), `/members/:accountId`, `/.well-known` NIP-05 `nostr.json` (CORS `*`), `/contact`, `/conversations`, `/notifications`, and invoices. -- **Inputs:** Optional `AppDeps` (store, clock, payer, fetch, cache, readBrand, origins, `debugToken`, giftStore, `giftRecorder`, `btcUsdRates`, `fiatRates`, `messageStore`, `contactStore`, optional `conversationStore` (default `InMemoryConversationStore`), optional `notificationStore` (default `InMemoryNotificationStore`), optional `apiLogStore` (default `InMemoryApiLogStore`), `pushStore`, `trustStore`, `vapidPublicKey`, `nostrKek`, optional `nostrPublisher` (without `nostrKek` staff hide skips NIP-09), optional `env` (default `process.env`; relays / `PUBLIC_BASE_URL` / Cloudflare on `DELETE /messages/:id`), spendApiToken, `spendPing` (default `resolveSpendPing(process.env, fetchImpl)`; unset/blank `SPEND_URL` or `SPEND_API_TOKEN` omits it; `POST /messages` still 200; daily/omitted kind body `{ address, messageId }`; `conversationRoutes` gets the same `spendPing`; moderator-group POST body `{ address, kind: "moderator", groupMessageId }` without `messageId`; forum `POST /messages` still two-arg daily ping), invoiceStore, `webAuthnRpId`, `webAuthnRpName`, `passkeyCeremony`). Omitted `giftRecorder` → `invoiceRoutes` uses `NoopGiftRecorder`; omitted `messageStore` → `InMemoryMessageStore`; omitted `contactStore` → `InMemoryContactStore`; omitted `conversationStore` → `InMemoryConversationStore`; omitted `notificationStore` → `InMemoryNotificationStore`; omitted `pushStore` → `InMemoryPushStore`; omitted `trustStore` → `InMemoryTrustStore`; omitted `apiLogStore` → `InMemoryApiLogStore`; omitted/blank `vapidPublicKey` → push HTTP 503 after session; omitted `nostrKek` → unsigned forum + invoice 503; SQL boot injects `SqlGiftRecorder`, `PostgresMessageStore`, `PostgresContactStore`, `PostgresConversationStore`, `PostgresNotificationStore`, `PostgresPushStore`, `PostgresTrustStore`, `PostgresApiLogStore`, and parsed KEK. `messagesRoutes`, `meRoutes`, `invoiceRoutes`, and `trustRoutes` receive `conversationStore`. `contactRoutes` and `conversationRoutes` receive `pushStore` plus `notificationStore`. Mounts `notificationRoutes` at `/notifications`. Does not take a push sender (worker owns delivery). +- **Purpose:** Wires CORS, requestLog, brand, health, info, auth, me, `/view`, lightning-address, `/debug/accounts`, `/debug/contacts`, `/debug/api-log`, `/debug/external-pubkeys`, `/debug/messages`, `/debug/invoices`, `/debug/invoices/settle`, `/debug/zap-ingests`, `/debug/push-ping`, `/debug/trust-edges`, `/trust-chain`, `/trust` (verify / propose-moderator / confirm-moderator / appoint-moderator), `/funding` (apply / applications / trial / admit / reject), Web Push subscription routes, `/gifts`, `/gifts/stats`, `/messages` (incl. invoice), `/members/:accountId`, `/.well-known` NIP-05 `nostr.json` (CORS `*`), `/contact`, `/conversations`, `/notifications`, and invoices. +- **Inputs:** Optional `AppDeps` (store, clock, payer, fetch, cache, readBrand, origins, `debugToken`, giftStore, `giftRecorder`, `btcUsdRates`, `fiatRates`, `messageStore`, `contactStore`, optional `conversationStore` (default `InMemoryConversationStore`), optional `notificationStore` (default `InMemoryNotificationStore`), optional `apiLogStore` (default `InMemoryApiLogStore`), `pushStore`, `trustStore`, optional `fundingStore` (default `InMemoryFundingStore`), `vapidPublicKey`, `nostrKek`, optional `nostrPublisher` (without `nostrKek` staff hide skips NIP-09), optional `env` (default `process.env`; relays / `PUBLIC_BASE_URL` / Cloudflare on `DELETE /messages/:id`), spendApiToken, `spendPing` (default `resolveSpendPing(process.env, fetchImpl)`; unset/blank `SPEND_URL` or `SPEND_API_TOKEN` omits it; `POST /messages` still 200; daily/omitted kind body `{ address, messageId }`; `conversationRoutes` gets the same `spendPing`; moderator-group POST body `{ address, kind: "moderator", groupMessageId }` without `messageId`; forum `POST /messages` still two-arg daily ping), invoiceStore, `webAuthnRpId`, `webAuthnRpName`, `passkeyCeremony`). Omitted `giftRecorder` → `invoiceRoutes` uses `NoopGiftRecorder`; omitted `messageStore` → `InMemoryMessageStore`; omitted `contactStore` → `InMemoryContactStore`; omitted `conversationStore` → `InMemoryConversationStore`; omitted `notificationStore` → `InMemoryNotificationStore`; omitted `pushStore` → `InMemoryPushStore`; omitted `trustStore` → `InMemoryTrustStore`; omitted `fundingStore` → `InMemoryFundingStore`; omitted `apiLogStore` → `InMemoryApiLogStore`; omitted/blank `vapidPublicKey` → push HTTP 503 after session; omitted `nostrKek` → unsigned forum + invoice 503; SQL boot injects `SqlGiftRecorder`, `PostgresMessageStore`, `PostgresContactStore`, `PostgresConversationStore`, `PostgresNotificationStore`, `PostgresPushStore`, `PostgresTrustStore`, `PostgresFundingStore`, `PostgresApiLogStore`, and parsed KEK. `messagesRoutes`, `meRoutes`, `invoiceRoutes`, and `trustRoutes` receive `conversationStore`. `fundingRoutes`, `invoiceRoutes`, `messagesRoutes`, `conversationRoutes`, `meRoutes`, `membersRoutes`, and auth finish receive `fundingStore`. `contactRoutes` and `conversationRoutes` receive `pushStore` plus `notificationStore`. Mounts `notificationRoutes` at `/notifications`. Does not take a push sender (worker owns delivery). - **Returns / side effects:** Hono app. Default `btcUsdRates` is an empty `InMemoryBtcUsdStore`. Default `fiatRates` is an empty `InMemoryFiatStore`. `createApp` passes the same `fiatRates` object into `/gifts`, `/gifts/stats`, `/me`, `/members`, and `/view`. Used by Bun.serve in `index.ts` and by tests via `app.request()`. - **Used by:** Boot path and every HTTP test. @@ -830,8 +830,8 @@ ## Function: meRoutes - **Purpose:** Authenticated account routes (`GET /`, `GET /activity`, `POST /setup/skip`, name with `ensureProfileMessage` (no-op without LN) and username auto-assign from the display name when the handle is blank and free, `POST /username` (LUD-16 local-part, 409 when taken), `POST /location` (optional free-text; empty/whitespace stores `null`; does not call `ensureProfileMessage`), PUT `/about` About me on the profile note (`{ text, photo? }`: omitted photo keeps, `null` clears, object sets the same JPEG/PNG/WebP as a forum post; creates without LN, including photo-only empty text), `GET /about/photo` (Bearer profile-note bytes), forum-laws dismiss, `POST /notification-level` (`{ level: all|active|mentions }`, 200 owner JSON, log `account.notification_level.set`), living-room rules agreement, Lightning Address link with live LNURL resolve + zap metadata check then NIP-57 mint probe `probeNip57Mint` then `ensureProfileMessage`, verification). Unlink clears `lightningAddressSkippedAt`. `POST /lightning-address` returns 409 `{ error: 'Lightning Address is already in use' }` when another account owns the address. `GET /activity` is Bearer-only (no rules gate) and returns given/received sats for the session account. -- **Inputs:** `MeRouteDeps` store, `messages`, now, payer, fetchImpl, optional `pushStore`, optional `notificationStore` (profile-note `notifyForumPost`), optional `conversationStore` (inbox unread on profile-note push), optional `nostrKek` (required to sign the mint probe), optional `giftStore`, `rates`, and `fiatRates` (defaults empty in-memory; used by `GET /activity`; missing fiat never 503). -- **Returns / side effects:** Hono at `/me`. Owner JSON includes `setup` + `missing` + `hasPosted` + `aboutMe` + `aboutMeHasPhoto` + `notificationLevel`. `GET /activity` is 200 activity JSON (zeros without Coinbase / Frankfurter when empty) or 503 `{ error: 'Gift stats are unavailable' }` on store throw or missing BTC-USD. Missing fiat never 503. 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 }`. Logs `account.about.set` / `account.about.failed` on PUT `/about`; `GET /about/photo` 503 logs `account.about.photo.failed`. A won PUT `/about` inline claim create calls `notifyForumPost` after the text/photo writes (best-effort; no-op when the actor is the official platform account). PUT `/about` does not call `ensureProfileMessage`. Updating an already-live note does not notify. Activity 503 logs `account.activity.failed` / `account.activity.fx_incomplete`. +- **Inputs:** `MeRouteDeps` store, `messages`, now, payer, fetchImpl, optional `pushStore`, optional `notificationStore` (profile-note `notifyForumPost`), optional `conversationStore` (inbox unread on profile-note push), optional `nostrKek` (required to sign the mint probe), optional `giftStore`, `rates`, and `fiatRates` (defaults empty in-memory; used by `GET /activity`; missing fiat never 503), optional `fundingStore` (default empty `InMemoryFundingStore`; owner JSON `funding`). +- **Returns / side effects:** Hono at `/me`. Owner JSON includes `setup` + `missing` + `hasPosted` + `aboutMe` + `aboutMeHasPhoto` + `notificationLevel` + `funding`. `GET /activity` is 200 activity JSON (zeros without Coinbase / Frankfurter when empty) or 503 `{ error: 'Gift stats are unavailable' }` on store throw or missing BTC-USD. Missing fiat never 503. 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 }`. Logs `account.about.set` / `account.about.failed` on PUT `/about`; `GET /about/photo` 503 logs `account.about.photo.failed`. A won PUT `/about` inline claim create calls `notifyForumPost` after the text/photo writes (best-effort; no-op when the actor is the official platform account). PUT `/about` does not call `ensureProfileMessage`. Updating an already-live note does not notify. Activity 503 logs `account.activity.failed` / `account.activity.fx_incomplete`. - **Used by:** `createApp`. ## Function: viewRoutes @@ -843,10 +843,10 @@ ## Function: messagesRoutes -- **Purpose:** Hono sub-app for the public member forum. After Bearer auth, `requireAction` gates `GET /` (`forum.read` → rules), `POST /` (`forum.post` → rules + name + username + Lightning Address), and `POST /:id/invoice` (`forum.pay` → payer rules only). Bearer `GET /` lists **live top-level** notes via `listFeed` (query `mode`/`limit`/`cursor`, default cap 200, optional `nextCursor` when the page is full; `hasPhoto`, `hasVideo`, `videoContentType`, `sats`, `payable`, live `role`, live `replyCount` of children with an account or a recorded zapper pubkey); soft-hidden rows are omitted; missing-file `hasVideo` rows are deleted (`messages.video.dropped`); `POST /` creates text/photo/video after parse/normalize/decode — JSON `photos` max 10, non-empty wins over singular `photo`, `photos.length > 10` is 400 `{ error: 'At most 10 photos' }`; optional `goalSats` is a whole-sat ask (1..10_000_000) on a top-level note (JSON number or multipart digits; omitted/null/empty = no goal); a positive `goalSats` with `inReplyTo` is 400 `{ error: 'A reply cannot ask for a goal' }`; public JSON omits the key when unset; `GET /:id/photo/:file` serves extras 1–9; identical live media from the same account+parent collapses to the existing row (200, no limiter, no second push); text-only still uses the 1/10s burst then inserts; unpaid replies from anyone except the parent author or `verified` are 403 `A reply needs a Bitcoin payment`; soft-hidden `inReplyTo` parents are 404; public `GET /:id` stays unauthenticated without `accountId` for a live row (a reply with null `accountId` is 200 with `via: 'nostr'` only when `authorPubkey` is set and recorded as a zapper (`isZapperPubkey`); otherwise (no `authorPubkey`, or one that is not yet a recorded zapper) it is 404; external top-level notes stay 200); optional `?sinceSats=` (non-negative integer) long-polls until `sats` is strictly greater (timeout still 200 with the current body; invalid value 400); unsigned/non-staff GET of a hidden row is still 404 `{ error: 'Not found' }` (no `deletedAt` in the 404 body); a founder/moderator Bearer (`roleAtLeast(..., 'moderator')`, no `forum.read`) is 200 public JSON plus `deletedAt` ISO, `deletedBy.{id,name,role}`, `payable: false`, and `accountId` for 21gifts authors (skip missing-video drop; do not long-poll `sinceSats` on hidden rows); live public JSON still omits hide stamps; public `GET /:id/replies` lists children with an account or a recorded zapper pubkey (optional Bearer for `accountId`; rows with neither identity are skipped); unsigned/non-staff 404s hidden/missing parents; staff Bearer is 200 `{ messages }` from `listReplies(id, limit, true)` including hidden attributed children with hide stamps and `payable: false` (live children stay live serialize); a child whose author lookup or serialize throws (invalid `createdAt`, author lookup) is omitted and siblings still 200 `{ messages }`; 503 `messages.replies.failed` only for `getById` / `listReplies` throws and for `dropMissingVideoRow` store/I/O (non-ENOENT video I/O or `deleteById`); missing-file drop (`null` → omit) still 200; photo/video byte routes 404 hidden ids for public/Damus (no staff bearer); founder/moderator Bearer serves hidden-row bytes with `Cache-Control: private, no-store` and `Vary: Authorization`; staff `DELETE /:id` soft-hides via `markDeleted` (moderator → 204; basis/verified → 403) then best-effort `retractHiddenForumNotes` when `nostrPublisher` and `nostrKek` are set (NIP-09 + optional Cloudflare purge; failure still 204) and best-effort retracts in-app notifications whose `parentId` or `replyId` is the note or a direct child (`listChildIds` + `deleteByMessageIds`; failure logs `messages.delete.notifications_failed` and still 204, never 503); staff `GET /hidden` lists soft-hidden notes newest-hidden-first (moderator session, not `DEBUG_TOKEN`, no `forum.read`; 200 `{ messages }` via `listHidden` / `serializeHiddenMessage`; logs `messages.hidden.listed` with `count` only); invoice returns `{ pr, amountSats }` only for NIP-57 invoices and 404s soft-hidden notes (author LN / unsigned stay 400 resource errors, never 409 `lightning-address` for the payer). Optional `notificationStore` fans out via `notifyForumPost` / `notifyForumReply` to every account except the actor (no-op when the actor is the official platform account), then filtered by each account's `notificationLevel` (no inbox copy; missing `pushStore` still writes in-app rows; Web Push only to bell subscribers, same filter). Optional `spendPing`: after a **new** top-level persist the route POSTs `{ address, messageId }` to `{SPEND_URL}/ping` with Bearer `SPEND_API_TOKEN` (fire-and-await, errors logged, POST still 200). Replies and media replays skip. Omitted `spendPing` skips. Notification or push failure still returns 200. +- **Purpose:** Hono sub-app for the public member forum. After Bearer auth, `requireAction` gates `GET /` (`forum.read` → rules), `POST /` (`forum.post` → rules + name + username + Lightning Address), and `POST /:id/invoice` (`forum.pay` → payer rules only). Bearer `GET /` lists **live top-level** notes via `listFeed` (query `mode`/`limit`/`cursor`/optional `hashtag` (name without `#`; token match on `text`), default cap 200, optional `nextCursor` when the page is full; `hasPhoto`, `hasVideo`, `videoContentType`, `sats`, `payable`, live `role`, live `replyCount` of children with an account or a recorded zapper pubkey); soft-hidden rows are omitted; missing-file `hasVideo` rows are deleted (`messages.video.dropped`); `POST /` creates text/photo/video after parse/normalize/decode — JSON `photos` max 10, non-empty wins over singular `photo`, `photos.length > 10` is 400 `{ error: 'At most 10 photos' }`; optional `goalSats` is a whole-sat ask (1..10_000_000) on a top-level note (JSON number or multipart digits; omitted/null/empty = no goal); a positive `goalSats` with `inReplyTo` is 400 `{ error: 'A reply cannot ask for a goal' }`; public JSON omits the key when unset; `GET /:id/photo/:file` serves extras 1–9; identical live media from the same account+parent collapses to the existing row (200, no limiter, no second push); text-only still uses the 1/10s burst then inserts; unpaid replies from anyone except the parent author or `verified` are 403 `A reply needs a Bitcoin payment`; soft-hidden `inReplyTo` parents are 404; public `GET /:id` stays unauthenticated without `accountId` for a live row (a reply with null `accountId` is 200 with `via: 'nostr'` only when `authorPubkey` is set and recorded as a zapper (`isZapperPubkey`); otherwise (no `authorPubkey`, or one that is not yet a recorded zapper) it is 404; external top-level notes stay 200); optional `?sinceSats=` (non-negative integer) long-polls until `sats` is strictly greater (timeout still 200 with the current body; invalid value 400); unsigned/non-staff GET of a hidden row is still 404 `{ error: 'Not found' }` (no `deletedAt` in the 404 body); a founder/moderator Bearer (`roleAtLeast(..., 'moderator')`, no `forum.read`) is 200 public JSON plus `deletedAt` ISO, `deletedBy.{id,name,role}`, `payable: false`, and `accountId` for 21gifts authors (skip missing-video drop; do not long-poll `sinceSats` on hidden rows); live public JSON still omits hide stamps; public `GET /:id/replies` lists children with an account or a recorded zapper pubkey (optional Bearer for `accountId`; rows with neither identity are skipped); unsigned/non-staff 404s hidden/missing parents; staff Bearer is 200 `{ messages }` from `listReplies(id, limit, true)` including hidden attributed children with hide stamps and `payable: false` (live children stay live serialize); a child whose author lookup or serialize throws (invalid `createdAt`, author lookup) is omitted and siblings still 200 `{ messages }`; 503 `messages.replies.failed` only for `getById` / `listReplies` throws and for `dropMissingVideoRow` store/I/O (non-ENOENT video I/O or `deleteById`); missing-file drop (`null` → omit) still 200; photo/video byte routes 404 hidden ids for public/Damus (no staff bearer); founder/moderator Bearer serves hidden-row bytes with `Cache-Control: private, no-store` and `Vary: Authorization`; staff `DELETE /:id` soft-hides via `markDeleted` (moderator → 204; basis/verified → 403) then best-effort `retractHiddenForumNotes` when `nostrPublisher` and `nostrKek` are set (NIP-09 + optional Cloudflare purge; failure still 204) and best-effort retracts in-app notifications whose `parentId` or `replyId` is the note or a direct child (`listChildIds` + `deleteByMessageIds`; failure logs `messages.delete.notifications_failed` and still 204, never 503); staff `GET /hidden` lists soft-hidden notes newest-hidden-first (moderator session, not `DEBUG_TOKEN`, no `forum.read`; 200 `{ messages }` via `listHidden` / `serializeHiddenMessage`; logs `messages.hidden.listed` with `count` only); invoice returns `{ pr, amountSats }` only for NIP-57 invoices and 404s soft-hidden notes (author LN / unsigned stay 400 resource errors, never 409 `lightning-address` for the payer). Optional `notificationStore` fans out via `notifyForumPost` / `notifyForumReply` to every account except the actor (no-op when the actor is the official platform account), then filtered by each account's `notificationLevel` (no inbox copy; missing `pushStore` still writes in-app rows; Web Push only to bell subscribers, same filter). Optional `spendPing`: after a **new** top-level persist the route POSTs `{ address, messageId }` to `{SPEND_URL}/ping` with Bearer `SPEND_API_TOKEN` only when `eligibleToday` (fire-and-await, errors logged, POST still 200; ineligible logs `spend.ping.skipped` / `not_eligible`). Replies and media replays skip. Omitted `spendPing` skips. Notification or push failure still returns 200. - **External DELETE cascade:** When the target has `accountId === null` and a recorded `authorPubkey`, a successful `markDeleted` is followed by the single atomic `blockPubkeyAndHideRows` operation, which records the block and hides that pubkey's other live external rows. It logs `messages.external.blocked` with `{ messageId, hidden: cascaded + 1 }`; deleting a member row does not trigger this author-wide cascade. -- **Inputs:** `MessagesRouteDeps`: message `store`, shared `authStore`, `now`, optional `nostrKek`, optional `nostrPublisher`, optional `env` (relays / `PUBLIC_BASE_URL` / Cloudflare; default `{}` on the retract path), `fetchImpl`, `postLimiter`, `invoiceLimiter`, optional `pushStore`, optional `spendPing`, optional `notificationStore`, optional `conversationStore`, optional `waitSatsSleep` (test inject; default `defaultWaitSatsSleep`), optional `waitSatsTimeoutMs` (test inject; default `WAIT_SATS_TIMEOUT_MS`), optional `waitSatsPollMs` (test inject; default `WAIT_SATS_POLL_MS`). +- **Inputs:** `MessagesRouteDeps`: message `store`, shared `authStore`, `now`, optional `nostrKek`, optional `nostrPublisher`, optional `env` (relays / `PUBLIC_BASE_URL` / Cloudflare; default `{}` on the retract path), `fetchImpl`, `postLimiter`, `invoiceLimiter`, optional `pushStore`, optional `spendPing`, optional `fundingStore` (default empty `InMemoryFundingStore`), optional `notificationStore`, optional `conversationStore`, optional `waitSatsSleep` (test inject; default `defaultWaitSatsSleep`), optional `waitSatsTimeoutMs` (test inject; default `WAIT_SATS_TIMEOUT_MS`), optional `waitSatsPollMs` (test inject; default `WAIT_SATS_POLL_MS`). - **Returns / side effects:** Hono app mounted at `/messages`. 401 without session on list/create/DELETE/GET `/hidden`/invoice; 403 on DELETE and GET `/hidden` when not at least moderator and on unpaid `inReplyTo` from basis non-authors; 409 `{ error: 'missing_requirements', missing }` when action gates fail (GET `/hidden` and staff GET of a hidden permalink / replies / photo / video have no `forum.read` gate); 400 on bad body / invalid text / bad media / unpaid note / author's-wallet / LNURL failures / reply+goalSats / non-integer multipart `goalSats`; 404 for bad `inReplyTo` / missing rows / unsigned or non-staff GET of a hidden row; 204 empty body on successful DELETE (NIP-09 / purge / notification retract failure still 204); 200 staff hidden log `{ messages }` (no `forum.read`); 200 staff GET of a hidden permalink / replies / photo / video; 429 rate limits; 503 on store/KEK/sign failure. Signed-in list/replies/create may include `accountId`; live public JSON still omits `accountId`, `deletedAt`, and `deletedBy`; staff hidden GET includes hide stamps and `accountId` for 21gifts authors. Post and reply notify call `notifyForumPost` / `notifyForumReply` best-effort (in-app rows for every account except the actor (no-op when the actor is the official platform account), then filtered by each account's `notificationLevel`; Web Push for bell subscribers, same filter; failure still 200. - **Used by:** `createApp`. @@ -859,9 +859,9 @@ ## Function: conversationRoutes -- **Purpose:** Hono sub-app for the signed-in PN channel: `GET /` lists `{ conversations, unreadCount }` (`unreadCount` = listed rows with `unread` true) for visible inbox threads and always passes `moderator: false` into `listVisible` (never ensures, pins, or returns `moderator_group`); each list/open row gets `unreadMessageCount` from `countUnread` (`unread` = count > 0; those paths do not also call `hasUnread`); `POST /` opens a thread from `{ forumMessageId }`; `GET /moderator-group` (before `GET /:id`) is the closed-group tool for `roleAtLeast(..., 'moderator')`: `ensureModeratorGroup` then `{ conversation }` (with `unread` / `unreadMessageCount` from `countUnread`); verified/basis 404; missing platform / store failure 503 `conversations.moderator_group.failed`; `GET /:id` lists messages oldest-first (`?sinceMessageId=` long-poll); `POST /:id/read` stamps last-read (mount before `POST /:id`); `POST /:id` appends `{ text }`; `POST /:id/invoice` issues a NIP-57 gift invoice. Moderators see all platform threads. Staff replies on a platform thread persist the platform sender (worker signs with the platform nsec) and store `actorAccountId`/`actorName` as the logged-in staff. Staff JSON `name`/`accountId` use the actor when set; members still see the sender (`21.gifts`). `fromMe` is the actor, else the sender — no staff-as-platform shortcut. `moderator_group` ACL is `roleAtLeast(..., 'moderator')` (moderator 200; verified/basis 404). `POST /:id` on this kind persists as the caller (moderator, not staff-as-platform) with `nostrPublishState: 'skipped'`, then `spendPing.ping(address, created.id, 'moderator')` only when Lightning Address is non-empty after trim **and** a live living-room top-level post exists on this UTC day; no living-room post today → 200, no ping, `spend.ping.skipped` / `no_public_post`; living-room lookup failure after persist → 200, no ping, `spend.ping.skipped` / `posted_unreachable`; ping throw still 200. -- **Inputs:** `ConversationRouteDeps`: conversation `store`, shared `authStore`, forum `messageStore`, `now`, optional `spendPing`, optional `fetchImpl` / `nostrKek` / `invoiceLimiter` / wait injects, optional `pushStore` and `notificationStore`. -- **Returns / side effects:** Hono app mounted at `/conversations`. 401 without session; 400 on bad body / self-PM / missing name / invalid text / author wallet; 404 when not allowed; 429 Too many payments; 503 `{ error: 'Messages are unavailable' }` for missing KEK / sign failure; 503 `{ error: 'Conversations are unavailable' }` for store/catch including ok-path `recordInvoiceAttempt` throw (`conversations.list.failed` / `conversations.read.failed`). After a successful `POST /:id` append, `notifyConversationMessage` is void-caught (`conversations.push.failed`) so 200 is unchanged. Public list/open JSON includes `unread`, `unreadMessageCount`, and `lastSats` and may include optional counterpart `accountId`; thread messages may include optional `accountId` (actor for staff when set, otherwise sender). Omits event ids and npubs (Damus-only `name` may be a truncated npub; Damus-only counterparts and Damus inbound omit `accountId`). List rows include `lastSats`; messages include `sats`. Envelope `unreadCount` remains the number of listed rows with `unread` true. +- **Purpose:** Hono sub-app for the signed-in PN channel: `GET /` lists `{ conversations, unreadCount }` (`unreadCount` = listed rows with `unread` true) for visible inbox threads and always passes `moderator: false` into `listVisible` (never ensures, pins, or returns `moderator_group`); each list/open row gets `unreadMessageCount` from `countUnread` (`unread` = count > 0; those paths do not also call `hasUnread`); `POST /` opens a thread from `{ forumMessageId }`; `GET /moderator-group` (before `GET /:id`) is the closed-group tool for `roleAtLeast(..., 'moderator')`: `ensureModeratorGroup` then `{ conversation }` (with `unread` / `unreadMessageCount` from `countUnread`); verified/basis 404; missing platform / store failure 503 `conversations.moderator_group.failed`; `GET /:id` returns a messenger-style newest page (default/max 200) oldest-first within the page, with `?limit=` and an exclusive older keyset `?cursor=` plus optional `nextCursor`, including `hasPhoto` / `photoCount` (never bytes); `?sinceMessageId=` keeps its long-poll and then returns the newest page regardless of a valid cursor; `GET /:id/messages/:messageId/photo` and `GET /:id/messages/:messageId/photo/:file` (indices 1–9, registered before `GET /:id`) serve private stills (`conversations.photo.failed` on store throw); `POST /:id/read` stamps last-read (mount before `POST /:id`); `POST /:id` appends `{ text?, photo?, photos? }` (max 10 stills; non-empty `photos` wins over singular `photo`; photos only on `moderator_group`; empty text allowed there when a still is present); `POST /:id/invoice` issues a NIP-57 gift invoice. Moderators see all platform threads. Staff replies on a platform thread persist the platform sender (worker signs with the platform nsec) and store `actorAccountId`/`actorName` as the logged-in staff. Staff JSON `name`/`accountId` use the actor when set; members still see the sender (`21.gifts`). `fromMe` is the actor, else the sender — no staff-as-platform shortcut. `moderator_group` ACL is `roleAtLeast(..., 'moderator')` (moderator 200; verified/basis 404). `POST /:id` on this kind persists as the caller (moderator, not staff-as-platform) with `nostrPublishState: 'skipped'`, then `spendPing.ping(address, created.id, 'moderator')` only when Lightning Address is non-empty after trim **and** a live living-room top-level post exists on this UTC day **and** `eligibleToday`; no living-room post today → 200, no ping, `spend.ping.skipped` / `no_public_post`; living-room lookup failure after persist → 200, no ping, `spend.ping.skipped` / `posted_unreachable`; ineligible grant → 200, no ping, `spend.ping.skipped` / `not_eligible`; ping throw still 200. +- **Inputs:** `ConversationRouteDeps`: conversation `store`, shared `authStore`, forum `messageStore`, `now`, optional `spendPing`, optional `fundingStore` (default empty `InMemoryFundingStore`), optional `fetchImpl` / `nostrKek` / `invoiceLimiter` / wait injects, optional `pushStore` and `notificationStore`. +- **Returns / side effects:** Hono app mounted at `/conversations`. 401 without session; 400 on bad body / self-PM / missing name / invalid text / author wallet / photos on a non-group thread / invalid still; 404 when not allowed; 429 Too many payments; 503 `{ error: 'Messages are unavailable' }` for missing KEK / sign failure; 503 `{ error: 'Conversations are unavailable' }` for store/catch including ok-path `recordInvoiceAttempt` throw (`conversations.list.failed` / `conversations.read.failed` / `conversations.photo.failed`). After a successful `POST /:id` append, `notifyConversationMessage` is void-caught (`conversations.push.failed`) so 200 is unchanged. Public list/open JSON includes `unread`, `unreadMessageCount`, and `lastSats` and may include optional counterpart `accountId`; thread messages may include optional `accountId` (actor for staff when set, otherwise sender), `hasPhoto`, and `photoCount` (0–10; never bytes). Omits event ids and npubs (Damus-only `name` may be a truncated npub; Damus-only counterparts and Damus inbound omit `accountId`). List rows include `lastSats`; messages include `sats`. Envelope `unreadCount` remains the number of listed rows with `unread` true. - **Used by:** `createApp`. ## Function: notificationRoutes @@ -932,7 +932,7 @@ - **Purpose:** Decode a base64 forum photo, enforce the 1 MiB cap, and set MIME from magic bytes (declared `contentType` is ignored). - **Inputs:** Declared `contentType` string (non-authoritative) and standard base64 `data`. - **Returns / side effects:** `{ contentType, bytes }` with a copied `Uint8Array`, or `null` on invalid base64, empty, oversize, or unrecognized magic. No I/O. -- **Used by:** `POST /messages`, `PUT /me/about`. +- **Used by:** `POST /messages`, `PUT /me/about`, `POST /conversations/:id`. ## Function: encodeMessageFeedCursor @@ -950,10 +950,10 @@ ## Function: forumPhotoResponse -- **Purpose:** Build the public photo HTTP response used by `GET /messages/:id/photo`, `GET /me/about/photo`, and `GET /view/:viewKey/about/photo`. Sets jpeg/png/webp `Content-Type`, `Cache-Control: public, max-age=86400`, `Access-Control-Allow-Origin: *`, and inline `Content-Disposition` `photo.jpg|png|webp`. +- **Purpose:** Build the public photo HTTP response used by `GET /messages/:id/photo`, `GET /me/about/photo`, and `GET /view/:viewKey/about/photo`. Sets jpeg/png/webp `Content-Type`, `Cache-Control: public, max-age=86400`, `Access-Control-Allow-Origin: *`, and inline `Content-Disposition` `photo.jpg|png|webp`. Conversation photo GETs reuse this helper then override to `Cache-Control: private, no-store` and drop `Access-Control-Allow-Origin`. - **Inputs:** `ForumPhoto` (`contentType` plus `bytes`). - **Returns / side effects:** `200` `Response` whose body is `photo.bytes`. No I/O. -- **Used by:** `serveForumPhoto`, `meRoutes` GET `/about/photo`, `viewRoutes` GET `/:viewKey/about/photo`. +- **Used by:** `serveForumPhoto`, `meRoutes` GET `/about/photo`, `viewRoutes` GET `/:viewKey/about/photo`, `conversationRoutes` GET `/:id/messages/:messageId/photo` and `/:id/messages/:messageId/photo/:file`. ## Function: updatePhoto @@ -1099,7 +1099,7 @@ - **Purpose:** Project a stored conversation message to its public JSON shape. - **Inputs:** `ConversationMessageRow`, `fromMe` boolean, optional `{ staff: true }`. -- **Returns / side effects:** `{ id, name, text, createdAt, fromMe, sats, accountId?, giftFor? }`. `sats` is the message amount (`0` when unpaid). Staff with `actorAccountId` get actor `name`/`accountId`; members get sender fields. `giftFor` is set from `giftForMessageId` when that id is a non-empty string (paid moderator stipend). Omits event ids, `senderAccountId`, and `senderPubkey`. No I/O. +- **Returns / side effects:** `{ id, name, text, createdAt, fromMe, sats, hasPhoto, photoCount, accountId?, giftFor? }`. Always includes `hasPhoto` (boolean) and `photoCount` (0–10); never photo bytes. `sats` is the message amount (`0` when unpaid). Staff with `actorAccountId` get actor `name`/`accountId`; members get sender fields. `giftFor` is set from `giftForMessageId` when that id is a non-empty string (paid moderator stipend). Omits event ids, `senderAccountId`, and `senderPubkey`. No I/O. - **Used by:** `conversationRoutes`. ## Function: conversationFromMe @@ -1447,22 +1447,22 @@ ## Function: serializeOwnerAccount -- **Purpose:** Owner JSON for authenticated account responses: the eleven public fields (including username and location) plus `viewKey`, `setup`, `missing`, `hasPosted`, `aboutMe`, `aboutMeHasPhoto`, and `notificationLevel` (`all` / `active` / `mentions`, default `all`, owner-only), so the owner can copy the capability URL and the client can route onboarding, action gates, the introduce-yourself popup, About me photo display, and living-room notify filter. Used by `GET /me`, `/me` writes including `POST /me/username`, `POST /me/rules-agreement`, `POST /me/setup/skip`, `POST /me/location`, `POST /me/notification-level`, and `PUT /me/about`, and passkey finish — never by the debug listing. Does not expose `profileMessageId`. -- **Inputs:** `Account` plus `hasPosted: boolean` plus `aboutMe: string | null` plus `aboutMeHasPhoto: boolean`. -- **Returns / side effects:** `OwnerAccountResponse` (eighteen fields: eleven public including username, plus `viewKey`, `setup`, `missing`, `hasPosted`, `aboutMe`, `aboutMeHasPhoto`, and `notificationLevel`). No I/O. Does not expose `profileMessageId`. +- **Purpose:** Owner JSON for authenticated account responses: the eleven public fields (including username and location) plus `viewKey`, `setup`, `missing`, `hasPosted`, `aboutMe`, `aboutMeHasPhoto`, `notificationLevel` (`all` / `active` / `mentions`, default `all`, owner-only), and `funding` (`null` for `basis`), so the owner can copy the capability URL and the client can route onboarding, action gates, the introduce-yourself popup, About me photo display, living-room notify filter, and funding status. Used by `GET /me`, `/me` writes including `POST /me/username`, `POST /me/rules-agreement`, `POST /me/setup/skip`, `POST /me/location`, `POST /me/notification-level`, and `PUT /me/about`, and passkey finish — never by the debug listing. Does not expose `profileMessageId`. +- **Inputs:** `Account` plus `hasPosted: boolean` plus `aboutMe: string | null` plus `aboutMeHasPhoto: boolean` plus optional `funding` (`OwnerFundingJson | null`, default `null`). +- **Returns / side effects:** `OwnerAccountResponse` (nineteen fields: eleven public + `viewKey`, `setup`, `missing`, `hasPosted`, `aboutMe`, `aboutMeHasPhoto`, `notificationLevel`, `funding`). No I/O. Does not expose `profileMessageId`. - **Used by:** `serializeOwnerAccountWithPosts` (`meRoutes` including `POST /me/username`). ## Function: serializeOwnerAccountWithPosts -- **Purpose:** Async owner JSON with live-post lookup and profile-note About me. Calls `accountHasLivePost(account.id, account.profileMessageId ?? null)`, loads the profile note via `getById` when `profileMessageId` is non-blank, then `serializeOwnerAccount` so HTTP callers cannot drift. `aboutMe` is `null` when the profile note is missing or `deletedAt` is set (`getById` still returns soft-hidden rows; the serializer requires `row.deletedAt === null` — see `src/lib/auth/account-json.ts` 221: `if (row !== undefined && row.deletedAt === null)`). A live row passes `aboutMeFromNote(account.name, row.text, row.name)` so auto name-copy stays unfilled after a display-name rename, and `aboutMeHasPhoto` from `row.hasPhoto === true`. Overlay `hasPosted` (`GET /me`) uses `accountHasLivePost` (replies count) and is **not** the spend/invoice predicate. Spend eligibility is `accountHasLiveTopLevelPost` / `GET /invoices/posted`. -- **Inputs:** `Account`, `Pick`. -- **Returns / side effects:** `OwnerAccountResponse` including `hasPosted`, `aboutMe`, `aboutMeHasPhoto`, and `notificationLevel`. Overlay lookup is `accountHasLivePost`; spend/invoice lookup is `accountHasLiveTopLevelPost`. Store throw is unhandled. +- **Purpose:** Async owner JSON with live-post lookup and profile-note About me. Calls `accountHasLivePost(account.id, account.profileMessageId ?? null)`, loads the profile note via `getById` when `profileMessageId` is non-blank, then `serializeOwnerAccount` so HTTP callers cannot drift. `aboutMe` is `null` when the profile note is missing or `deletedAt` is set (`getById` still returns soft-hidden rows; the serializer requires `row.deletedAt === null` — see `src/lib/auth/account-json.ts` 247: `if (row !== undefined && row.deletedAt === null)`). A live row passes `aboutMeFromNote(account.name, row.text, row.name)` so auto name-copy stays unfilled after a display-name rename, and `aboutMeHasPhoto` from `row.hasPhoto === true`. Overlay `hasPosted` (`GET /me`) uses `accountHasLivePost` (replies count) and is **not** the spend/invoice predicate. Spend eligibility is `accountHasLiveTopLevelPost` / `GET /invoices/posted`. Optional funding lookup loads the grant via `getByAccountId` and admitted `reviewedByName`. +- **Inputs:** `Account`, `Pick`, optional `OwnerFundingLookup` (`store`, `nowMs`, `authStore`). +- **Returns / side effects:** `OwnerAccountResponse` including `hasPosted`, `aboutMe`, `aboutMeHasPhoto`, `notificationLevel`, and `funding`. Overlay lookup is `accountHasLivePost`; spend/invoice lookup is `accountHasLiveTopLevelPost`. Omitted funding lookup is `basis` `null` or `{ status: 'none', … }`. Store throw is unhandled. - **Used by:** `meRoutes` and `authRoutes`. ## Function: membersRoutes -- **Purpose:** Hono sub-app for `GET /members/:accountId`, `GET /members/:accountId/activity`, `GET /members/:accountId/posts`, and `GET /members/:accountId/replies`. Bearer + `requireAction(forum.read)` on all; UUID path. Profile card is live identity plus optional `profileMessage` via `serializeMessage`, derived `aboutMe`, `aboutMeHasPhoto` (true when the live profile note has a stored photo; false when `profileMessage` is null), uncapped live `postCount` / `replyCount` from `countByAccount`, and `trust` via `accountTrust`. Activity is given/received sats for that member (`buildAccountActivity`). Posts is live-only top-level notes newest-first (cap 200, same serialize as signed-in `GET /messages` including `accountId` / `replyCount` / `payable` and optional `goalSats` omitted when unset; omits `parentId`; missing-file `hasVideo` direct replies are deleted and subtracted from `replyCount`). Replies is live-only member replies newest-first (cap 200, `payable` when a non-empty `eventId` and a non-blank Lightning Address are set, optional `parentId`, no `replyCount`; replies never include `goalSats`; a child that cannot serialize is omitted, siblings still 200). -- **Inputs:** `MembersRouteDeps` (`authStore`, `messageStore`, required `trustStore`, `now`, optional `giftStore`, `rates`, and `fiatRates` used by `GET /:accountId/activity`; missing fiat never 503). +- **Purpose:** Hono sub-app for `GET /members/:accountId`, `GET /members/:accountId/activity`, `GET /members/:accountId/posts`, and `GET /members/:accountId/replies`. Bearer + `requireAction(forum.read)` on all; UUID path. Profile card is live identity plus optional `profileMessage` via `serializeMessage`, derived `aboutMe`, `aboutMeHasPhoto` (true when the live profile note has a stored photo; false when `profileMessage` is null), uncapped live `postCount` / `replyCount` from `countByAccount`, and `trust` via `accountTrust`, and `fundingReviewedAt` (`grant.admittedAt` when effective admitted, else `null`). Activity is given/received sats for that member (`buildAccountActivity`). Posts is live-only top-level notes newest-first (cap 200, same serialize as signed-in `GET /messages` including `accountId` / `replyCount` / `payable` and optional `goalSats` omitted when unset; omits `parentId`; missing-file `hasVideo` direct replies are deleted and subtracted from `replyCount`). Replies is live-only member replies newest-first (cap 200, `payable` when a non-empty `eventId` and a non-blank Lightning Address are set, optional `parentId`, no `replyCount`; replies never include `goalSats`; a child that cannot serialize is omitted, siblings still 200). +- **Inputs:** `MembersRouteDeps` (`authStore`, `messageStore`, required `trustStore`, optional `fundingStore` default empty `InMemoryFundingStore`, `now`, optional `giftStore`, `rates`, and `fiatRates` used by `GET /:accountId/activity`; missing fiat never 503). - **Returns / side effects:** Hono app mounted at `/members`. Activity is 200 JSON or 503 `{ error: 'Gift stats are unavailable' }` on store throw or missing BTC-USD. Missing fiat never 503. Logs `members.get.failed`, `members.posts.failed`, `members.replies.failed`, or `account.activity.failed` on 503. Activity 503 logs `account.activity.failed` / `account.activity.fx_incomplete`. GET JSON includes `aboutMe` and `aboutMeHasPhoto`. - **Used by:** `createApp`. @@ -1688,7 +1688,7 @@ - **Purpose:** UTC `YYYY-MM-DD` from epoch ms. - **Inputs:** nowMs. - **Returns / side effects:** Day key. -- **Used by:** `PostRateLimiter`. +- **Used by:** `PostRateLimiter`; funding-grant `effectiveStatus` / `eligibleToday` / trial writes. ## Function: PostRateLimiter @@ -2083,6 +2083,13 @@ - **Returns / side effects:** Hono app mounted at `/trust`. 401/403/400/404/409/503 with the documented `{ error }` strings; GET `/proposals` 200 `{ proposals }` (empty list included); POST 200 `{ id, name, role }`. - **Used by:** `createApp`. +## Function: fundingRoutes + +- **Purpose:** Hono sub-app for member `POST /apply` and staff `GET /applications`, `GET /applications/:accountId`, `POST /trial`, `POST /admit`, and `POST /reject`. `basis` cannot apply (403). Apply from effective none/rejected only. Staff list is effective pending (expired trials after `loadGrantEffective`). Trial from pending; admit from pending or trial; reject from pending or trial. Writes go through `FundingStore.transition` (apply `from` none/rejected; trial pending; admit/reject pending or trial); 0 matching rows is 409 so a concurrent decision cannot overwrite. Staff cannot target themselves (409). UUID check reuses `MESSAGE_ID_RE`. Logs `funding.applied` / `funding.trial` / `funding.admitted` / `funding.rejected` / `funding.applications.listed`. Store throw → 503 `{ error: 'Funding is unavailable' }`. +- **Inputs:** `FundingRouteDeps`: `authStore`, `fundingStore`, `messageStore`, `now`. +- **Returns / side effects:** Hono app mounted at `/funding`. 401/403/400/404/409/503 with the documented `{ error }` strings; apply 200 `{ funding }`; list 200 `{ applications }`; detail 200 `{ account, grant, messages }`; staff POSTs 200 `{ id, name, role, funding }`. +- **Used by:** `createApp`. + ## Function: debugTrustRoutes - **Purpose:** Operator backfill `POST /debug/trust-edges` and undo `DELETE /debug/trust-edges`. Same 503/401 `DEBUG_TOKEN` gate as other debug routes. POST body `{ subjectId, actorId, kind }` inserts; DELETE body `{ subjectId, kind }` removes the unique `(subjectId, kind)` row. Both return `serializeTrustEdge` (ISO `createdAt`) and do **not** change `account.role`. `PATCH /debug/accounts/:id` remains role-only. @@ -2146,3 +2153,66 @@ Builds the operator-only external-pubkey inspection route. - **Authentication:** Requires a bearer equal to `DEBUG_TOKEN`; missing configuration returns 503 and a bad bearer returns 401. - **Response:** Lists entitled zappers and blocked pubkeys newest first with receipt, block, staff, message, and timestamp metadata. - **Bound:** Caps each list at the standard message debug limit and returns 503 when the store cannot be read. + +## Function: effectiveStatus + +- **Purpose:** Effective funding-grant status after lazy trial expiry. A stored `trial` whose `trialUtcDate` is a string strictly before today UTC (`utcDayKey`) becomes `'pending'`. Today's and future trial dates stay `'trial'`. A trial with `trialUtcDate === null` is not expired via the date comparison. Missing grant is `'none'`. Non-trial statuses return `grant.status`. +- **Inputs:** `grant` (`FundingGrant | undefined`) and `nowMs` epoch milliseconds. +- **Returns / side effects:** `EffectiveFundingStatus`. No I/O. +- **Used by:** `serializeOwnerFunding`, `fundingReviewedAt`, `loadGrantEffective`. + +## Function: eligibleToday + +- **Purpose:** Whether the account may receive a spend ping / spend invoice today. True iff the live role is not `basis` and the grant is admitted, or a trial whose `trialUtcDate` equals today's UTC key. Expired, future, pending, rejected, and missing grants are false. `basis` is always false. +- **Inputs:** `role` (`AccountRole`), `grant` (`FundingGrant | undefined`), `nowMs`. +- **Returns / side effects:** `boolean`. No I/O. +- **Used by:** `messagesRoutes` spend ping, `conversationRoutes` moderator ping, `invoiceRoutes` `GET /eligible` and `POST /`. Domain tests cover the matrix. + +## Function: serializeOwnerFunding + +- **Purpose:** Owner `funding` JSON for `GET /me` / passkey finish. `basis` is `null` (do not leak grants). Otherwise always an object; missing row is `'none'`. Trial date and admission fields follow the effective status. +- **Inputs:** `role`, observed `grant`, `nowMs`, live `reviewerName` (used only when admitted). +- **Returns / side effects:** `OwnerFundingJson | null`. No I/O. +- **Used by:** `serializeOwnerAccountWithPosts`, `fundingRoutes`. + +## Function: fundingReviewedAt + +- **Purpose:** Member-card admission stamp: `grant.admittedAt` when effective status is admitted, else `null`. Does not expose pending, trial, or rejected. +- **Inputs:** `grant` (`FundingGrant | undefined`), `nowMs`. +- **Returns / side effects:** Admission epoch ms, or `null`. No I/O. +- **Used by:** `membersRoutes` `GET /:accountId`. + +## Function: expiredTrialAsPending + +- **Purpose:** Pending projection of an expired trial. Keeps `appliedAt` and the last decision actor/time/note; sets `status: 'pending'`, `trialUtcDate: null`, `admittedAt: null`. +- **Inputs:** Stored trial grant (possibly expired). +- **Returns / side effects:** Pending `FundingGrant` to persist. No I/O. +- **Used by:** `loadGrantEffective`. + +## Function: loadGrantEffective + +- **Purpose:** Load one grant and lazily persist expired trials via compare-and-set. Missing row is `undefined` (no upsert). When `effectiveStatus` is `'pending'` and the stored status is still `'trial'`, rewrites pending only if the row is still `status='trial'` with the same expired `trialUtcDate` (InMemory re-read then upsert; Postgres `UPDATE … WHERE account_id AND status='trial' AND trial_utc_date RETURNING *`; 0 rows → `getByAccountId`). Today's and future trials are returned unchanged. +- **Inputs:** `FundingStore`, `accountId`, `nowMs`. +- **Returns / side effects:** Observed grant, or `undefined`. May write pending over an expired trial that still matches. +- **Used by:** `fundingRoutes`. + +## Function: migrateFundingSchema + +- **Purpose:** Applies `FUNDING_SCHEMA_SQL` in order (`CREATE TABLE IF NOT EXISTS funding_grant` with PK/FK to `account`, status CHECK, trial date, decision and admission timestamps). Idempotent. Runs after `migrateTrustSchema` and before `migrateDbChangeSchema` so `trg_db_change` attaches to `funding_grant`. +- **Inputs:** `SqlClient`. +- **Returns / side effects:** Void; idempotent DDL execute matching `docs/schema/funding_grant.sql` (comment header allowed in the `.sql` file only). +- **Used by:** `openBootStores` when SQL opens. + +## Function: InMemoryFundingStore + +- **Purpose:** Process-local `FundingStore` for funding-program grants. Default empty so the process boots without a database. `createApp` uses this when boot leaves `fundingStore` undefined (memory `DATABASE_URL`). `getByAccountId` / `listGrants` / `upsert` / `transition` / `expireTrialIfUnchanged` copy on read and write. `listGrants` sorts oldest `appliedAt` then `accountId` ASC. Second `upsert` for the same account replaces the row. `transition` writes only when the in-memory status is in `from` (`'none'` = no row); otherwise `undefined`. `expireTrialIfUnchanged` writes pending only when the map row is still `status='trial'` with the same `trialUtcDate` (no await between check and set). +- **Inputs:** Optional seed `FundingGrant[]` (copied into a private `Map` keyed by `accountId`). +- **Returns / side effects:** Promise of grant copies; mutating results or the seed does not change the store. No I/O. +- **Used by:** `createApp` default `fundingStore`. + +## Function: PostgresFundingStore + +- **Purpose:** Durable `FundingStore` over Postgres (`funding_grant` table). `getByAccountId` binds `$1`. `listGrants` is `ORDER BY applied_at ASC, account_id ASC`. `upsert` is `INSERT … ON CONFLICT (account_id) DO UPDATE SET` every grant column. `transition` is `INSERT … ON CONFLICT DO UPDATE WHERE status = ANY($9::text[])` when `from` includes `'none'`, else `UPDATE … WHERE account_id=$1 AND status = ANY($9::text[]) RETURNING *` (0 rows → `undefined`). `expireTrialIfUnchanged` is `UPDATE … WHERE account_id=$1 AND status='trial' AND trial_utc_date=$2 RETURNING *` (0 rows → `getByAccountId`). Maps `timestamptz` (Date or ISO string) to epoch ms and `trial_utc_date` Date/string to `YYYY-MM-DD`; `null` stays `null`. +- **Inputs:** Constructor takes a shared boot `SqlClient` (already migrated). +- **Returns / side effects:** Parameter-bound SQL; copies on return. Query and execute errors propagate. +- **Used by:** `openBootStores` when `DATABASE_URL` is set. diff --git a/docs/schema/conversation.sql b/docs/schema/conversation.sql index e4ae03cc..a4818926 100644 --- a/docs/schema/conversation.sql +++ b/docs/schema/conversation.sql @@ -82,3 +82,13 @@ CREATE TABLE IF NOT EXISTS conversation_read ( ); CREATE INDEX IF NOT EXISTS conversation_read_conversation_id_idx ON conversation_read (conversation_id); +ALTER TABLE conversation_message ADD COLUMN IF NOT EXISTS photo bytea; +ALTER TABLE conversation_message ADD COLUMN IF NOT EXISTS photo_content_type text; +CREATE TABLE IF NOT EXISTS conversation_message_extra_photo ( + message_id uuid NOT NULL REFERENCES conversation_message (id) ON DELETE CASCADE, + idx smallint NOT NULL, + photo bytea NOT NULL, + photo_content_type text NOT NULL, + PRIMARY KEY (message_id, idx), + CONSTRAINT conversation_message_extra_photo_idx_range CHECK (idx >= 1 AND idx <= 9) +); diff --git a/docs/schema/funding_grant.sql b/docs/schema/funding_grant.sql new file mode 100644 index 00000000..79557e30 --- /dev/null +++ b/docs/schema/funding_grant.sql @@ -0,0 +1,13 @@ +-- Funding-program grants: one row per account (pending / trial / admitted / rejected). +-- Applied by migrateFundingSchema / FUNDING_SCHEMA_SQL. + +CREATE TABLE IF NOT EXISTS funding_grant ( + account_id uuid PRIMARY KEY REFERENCES account (id), + status text NOT NULL CHECK (status IN ('pending', 'trial', 'admitted', 'rejected')), + applied_at timestamptz NOT NULL, + decided_at timestamptz, + decided_by uuid REFERENCES account (id), + trial_utc_date date, + admitted_at timestamptz, + note text +); diff --git a/e2e/functions.spec.ts b/e2e/functions.spec.ts index 872b865a..5e9ed71d 100644 --- a/e2e/functions.spec.ts +++ b/e2e/functions.spec.ts @@ -1712,6 +1712,38 @@ test('Function: trustRoutes — POST /trust/verify without bearer is 401', async expect(res.status()).toBe(401); }); +test('Function: fundingRoutes — POST /funding/apply without bearer is 401', async ({ request }) => { + const res = await request.post('/funding/apply'); + expect(res.status()).toBe(401); +}); +test('Function: effectiveStatus — default boot has no DATABASE_URL', async ({ request }) => { + expect((await request.get('/healthz')).status()).toBe(200); +}); +test('Function: eligibleToday — default boot has no DATABASE_URL', async ({ request }) => { + expect((await request.get('/healthz')).status()).toBe(200); +}); +test('Function: serializeOwnerFunding — default boot has no DATABASE_URL', async ({ request }) => { + expect((await request.get('/healthz')).status()).toBe(200); +}); +test('Function: fundingReviewedAt — default boot has no DATABASE_URL', async ({ request }) => { + expect((await request.get('/healthz')).status()).toBe(200); +}); +test('Function: expiredTrialAsPending — default boot has no DATABASE_URL', async ({ request }) => { + expect((await request.get('/healthz')).status()).toBe(200); +}); +test('Function: loadGrantEffective — default boot has no DATABASE_URL', async ({ request }) => { + expect((await request.get('/healthz')).status()).toBe(200); +}); +test('Function: migrateFundingSchema — default boot has no DATABASE_URL', async ({ request }) => { + expect((await request.get('/healthz')).status()).toBe(200); +}); +test('Function: InMemoryFundingStore — default boot has no DATABASE_URL', async ({ request }) => { + expect((await request.get('/healthz')).status()).toBe(200); +}); +test('Function: PostgresFundingStore — default boot has no DATABASE_URL', async ({ request }) => { + expect((await request.get('/healthz')).status()).toBe(200); +}); + test('Function: debugTrustRoutes — POST /debug/trust-edges without bearer is 401', async ({ request, }) => { diff --git a/e2e/http.spec.ts b/e2e/http.spec.ts index 3c9dc956..5dd8230f 100644 --- a/e2e/http.spec.ts +++ b/e2e/http.spec.ts @@ -247,6 +247,19 @@ test('POST /conversations/:id/read without bearer is 401', async ({ request }) = expect(res.status()).toBe(401); }); +test('GET /conversations/:id/messages/:messageId/photo without bearer is 401', async ({ + request, +}) => { + expect((await request.get('/conversations/:id/messages/:messageId/photo')).status()).toBe(401); +}); +test('GET /conversations/:id/messages/:messageId/photo/:file without bearer is 401', async ({ + request, +}) => { + expect((await request.get('/conversations/:id/messages/:messageId/photo/:file')).status()).toBe( + 401, + ); +}); + test('POST /messages with a photo without bearer is 401', async ({ request }) => { const res = await request.post('/messages', { data: { @@ -590,6 +603,11 @@ test('GET /invoices/passkey unconfigured is 503', async ({ request }) => { expect(res.status()).toBe(503); }); +test('GET /invoices/eligible unconfigured is 503', async ({ request }) => { + const res = await request.get('/invoices/eligible'); + expect(res.status()).toBe(503); +}); + test('GET /invoices/posted unconfigured is 503', async ({ request }) => { const res = await request.get('/invoices/posted'); expect(res.status()).toBe(503); @@ -697,6 +715,36 @@ test('POST /trust/appoint-moderator without bearer is 401', async ({ request }) expect(res.status()).toBe(401); }); +test('POST /funding/apply without bearer is 401', async ({ request }) => { + const res = await request.post('/funding/apply'); + expect(res.status()).toBe(401); +}); + +test('GET /funding/applications without bearer is 401', async ({ request }) => { + const res = await request.get('/funding/applications'); + expect(res.status()).toBe(401); +}); + +test('GET /funding/applications/:accountId without bearer is 401', async ({ request }) => { + const res = await request.get('/funding/applications/:accountId'); + expect(res.status()).toBe(401); +}); + +test('POST /funding/trial without bearer is 401', async ({ request }) => { + const res = await request.post('/funding/trial', { data: { accountId: 'x' } }); + expect(res.status()).toBe(401); +}); + +test('POST /funding/admit without bearer is 401', async ({ request }) => { + const res = await request.post('/funding/admit', { data: { accountId: 'x' } }); + expect(res.status()).toBe(401); +}); + +test('POST /funding/reject without bearer is 401', async ({ request }) => { + const res = await request.post('/funding/reject', { data: { accountId: 'x' } }); + expect(res.status()).toBe(401); +}); + test('POST /debug/trust-edges without bearer is 401', async ({ request }) => { const res = await request.post('/debug/trust-edges'); expect(res.status()).toBe(401); diff --git a/src/__tests__/lib/auth/account-json.test.ts b/src/__tests__/lib/auth/account-json.test.ts index c9c712e2..23924acb 100644 --- a/src/__tests__/lib/auth/account-json.test.ts +++ b/src/__tests__/lib/auth/account-json.test.ts @@ -6,7 +6,8 @@ import { serializeOwnerAccountWithPosts, serializeViewProfile, } from '@/lib/auth/account-json'; -import type { Account } from '@/lib/auth/store'; +import { InMemoryAuthStore, type Account } from '@/lib/auth/store'; +import { InMemoryFundingStore } from '@/lib/funding-store'; import { unsignedNostrDefaults } from '@/lib/message'; import type { MessageRow } from '@/lib/message'; @@ -98,6 +99,7 @@ describe('serializeOwnerAccount', () => { aboutMe: null, aboutMeHasPhoto: false, notificationLevel: 'all', + funding: null, }); expect(json.viewKey).toBe(account.viewKey); expect(json.setup).toBe('rules'); @@ -137,6 +139,21 @@ describe('serializeOwnerAccount', () => { expect(json.aboutMeHasPhoto).toBe(true); expect(json).not.toHaveProperty('profileMessageId'); }); + + it('includes an explicit funding object when provided', () => { + const json = serializeOwnerAccount(account, false, null, false, { + status: 'none', + trialUtcDate: null, + admittedAt: null, + reviewedByName: null, + }); + expect(json.funding).toEqual({ + status: 'none', + trialUtcDate: null, + admittedAt: null, + reviewedByName: null, + }); + }); }); describe('serializeOwnerAccountWithPosts', () => { @@ -311,6 +328,94 @@ describe('serializeOwnerAccountWithPosts', () => { expect(hidden.aboutMe).toBeNull(); expect(hidden.aboutMeHasPhoto).toBe(false); }); + + it('sets funding null for basis and none for verified without a row', async () => { + const none = await serializeOwnerAccountWithPosts(account, { + accountHasLivePost: async () => false, + getById: async () => undefined, + }); + expect(none.funding).toBeNull(); + + const verified = await serializeOwnerAccountWithPosts( + { ...account, role: 'verified' }, + { + accountHasLivePost: async () => false, + getById: async () => undefined, + }, + ); + expect(verified.funding).toEqual({ + status: 'none', + trialUtcDate: null, + admittedAt: null, + reviewedByName: null, + }); + }); + + it('loads admitted funding and the reviewer name', async () => { + const fundingStore = new InMemoryFundingStore([ + { + accountId: 'acc', + status: 'admitted', + appliedAt: 1, + decidedAt: 2, + decidedBy: 'staff', + trialUtcDate: null, + admittedAt: 3, + note: null, + }, + ]); + const authStore = new InMemoryAuthStore(); + await authStore.createAccount({ + ...account, + id: 'staff', + name: 'Mod', + role: 'moderator', + viewKey: 'b'.repeat(64), + }); + const json = await serializeOwnerAccountWithPosts( + { ...account, role: 'verified' }, + { + accountHasLivePost: async () => false, + getById: async () => undefined, + }, + { store: fundingStore, nowMs: 4, authStore }, + ); + expect(json.funding).toEqual({ + status: 'admitted', + trialUtcDate: null, + admittedAt: 3, + reviewedByName: 'Mod', + }); + }); + + it('uses a null reviewer name when decidedBy is missing', async () => { + const fundingStore = new InMemoryFundingStore([ + { + accountId: 'acc', + status: 'admitted', + appliedAt: 1, + decidedAt: 2, + decidedBy: 'ghost', + trialUtcDate: null, + admittedAt: 3, + note: null, + }, + ]); + const json = await serializeOwnerAccountWithPosts( + { ...account, role: 'verified' }, + { + accountHasLivePost: async () => false, + getById: async () => undefined, + }, + { store: fundingStore, nowMs: 4, authStore: new InMemoryAuthStore() }, + ); + expect(json.funding).toEqual({ + status: 'admitted', + trialUtcDate: null, + admittedAt: 3, + reviewedByName: null, + }); + }); }); describe('serializeViewProfile', () => { diff --git a/src/__tests__/lib/boot-stores.test.ts b/src/__tests__/lib/boot-stores.test.ts index 26ba8a8b..4d2c048e 100644 --- a/src/__tests__/lib/boot-stores.test.ts +++ b/src/__tests__/lib/boot-stores.test.ts @@ -15,6 +15,7 @@ import { RecordingQuerier } from '@/lib/nostr/query'; import { PostgresPushStore } from '@/lib/push-store'; import { PostgresTrustStore } from '@/lib/trust-store'; import { PostgresApiLogStore } from '@/lib/api-log'; +import { PostgresFundingStore } from '@/lib/funding-store'; function unusedClient(): SqlClient { return { @@ -62,6 +63,7 @@ describe('openBootStores', () => { pushStore, trustStore, apiLogStore, + fundingStore, } = await openBootStores(undefined, factory); expect(authStore).toBeInstanceOf(InMemoryAuthStore); expect(giftStore).toBeUndefined(); @@ -73,6 +75,7 @@ describe('openBootStores', () => { expect(pushStore).toBeUndefined(); expect(trustStore).toBeUndefined(); expect(apiLogStore).toBeUndefined(); + expect(fundingStore).toBeUndefined(); expect(btcUsdRates).toBeInstanceOf(InMemoryBtcUsdStore); expect(fiatRates).toBeInstanceOf(InMemoryFiatStore); expect(factory).not.toHaveBeenCalled(); @@ -95,6 +98,7 @@ describe('openBootStores', () => { pushStore, trustStore, apiLogStore, + fundingStore, } = await openBootStores(' ', factory); expect(authStore).toBeInstanceOf(InMemoryAuthStore); expect(giftStore).toBeUndefined(); @@ -106,6 +110,7 @@ describe('openBootStores', () => { expect(pushStore).toBeUndefined(); expect(trustStore).toBeUndefined(); expect(apiLogStore).toBeUndefined(); + expect(fundingStore).toBeUndefined(); expect(btcUsdRates).toBeInstanceOf(InMemoryBtcUsdStore); expect(fiatRates).toBeInstanceOf(InMemoryFiatStore); expect(factory).not.toHaveBeenCalled(); @@ -168,6 +173,7 @@ describe('openBootStores', () => { pushStore, trustStore, apiLogStore, + fundingStore, } = await openBootStores(url, factory, { fetchImpl: async () => new Response('[]', { status: 200 }), candlesUrl: 'https://example.test/candles', @@ -190,6 +196,7 @@ describe('openBootStores', () => { expect(pushStore).toBeInstanceOf(PostgresPushStore); expect(trustStore).toBeInstanceOf(PostgresTrustStore); expect(apiLogStore).toBeInstanceOf(PostgresApiLogStore); + expect(fundingStore).toBeInstanceOf(PostgresFundingStore); expect(btcUsdRates).toBeInstanceOf(PostgresBtcUsdStore); expect(fiatRates).toBeInstanceOf(PostgresFiatStore); expect(executes.length).toBeGreaterThan(0); @@ -199,13 +206,18 @@ describe('openBootStores', () => { expect(executes.some((q) => q.includes('push_subscription'))).toBe(true); expect(executes.some((q) => q.includes('notification'))).toBe(true); expect(executes.some((q) => q.includes('trust_edge'))).toBe(true); + expect(executes.some((q) => q.includes('funding_grant'))).toBe(true); expect(executes.some((q) => q.includes('api_log'))).toBe(true); expect(executes.some((q) => q.includes('db_change'))).toBe(true); const trustIdx = executes.findIndex((q) => /CREATE TABLE IF NOT EXISTS trust_edge/i.test(q)); + const fundingIdx = executes.findIndex((q) => + /CREATE TABLE IF NOT EXISTS funding_grant/i.test(q), + ); const apiLogIdx = executes.findIndex((q) => /CREATE TABLE IF NOT EXISTS api_log/i.test(q)); const dbChangeIdx = executes.findIndex((q) => /CREATE TABLE IF NOT EXISTS db_change/i.test(q)); expect(trustIdx).toBeGreaterThanOrEqual(0); - expect(apiLogIdx).toBeGreaterThan(trustIdx); + expect(fundingIdx).toBeGreaterThan(trustIdx); + expect(apiLogIdx).toBeGreaterThan(fundingIdx); expect(dbChangeIdx).toBeGreaterThan(apiLogIdx); expect(executes.some((q) => /CREATE TABLE/i.test(q))).toBe(true); expect(queries.some((q) => q.includes('min(paid_at)'))).toBe(true); @@ -305,6 +317,7 @@ describe('openBootStores', () => { pushStore, trustStore, apiLogStore, + fundingStore, } = await openBootStores('postgres://gifts21@localhost/gifts21', () => client, { fetchImpl: async () => new Response('[]', { status: 200 }), candlesUrl: 'https://example.test/candles', @@ -319,6 +332,7 @@ describe('openBootStores', () => { expect(pushStore).toBeInstanceOf(PostgresPushStore); expect(trustStore).toBeInstanceOf(PostgresTrustStore); expect(apiLogStore).toBeInstanceOf(PostgresApiLogStore); + expect(fundingStore).toBeInstanceOf(PostgresFundingStore); expect(btcUsdRates).toBeInstanceOf(PostgresBtcUsdStore); expect(fiatRates).toBeInstanceOf(PostgresFiatStore); expect(parsedEvents(warn).some((e) => e['event'] === 'gifts.fx.boot_fill.failed')).toBe(true); diff --git a/src/__tests__/lib/conversation-store.test.ts b/src/__tests__/lib/conversation-store.test.ts index 6d4d5053..943e5420 100644 --- a/src/__tests__/lib/conversation-store.test.ts +++ b/src/__tests__/lib/conversation-store.test.ts @@ -42,6 +42,14 @@ class MockSql implements SqlClient { } const NOW = new Date('2026-08-29T12:00:00.000Z'); +const JPEG = { + contentType: 'image/jpeg' as const, + bytes: new Uint8Array([0xff, 0xd8, 0xff, 0xd9]), +}; +const JPEG2 = { + contentType: 'image/jpeg' as const, + bytes: new Uint8Array([0xff, 0xd8, 0xff, 0x00]), +}; function thread(partial: Partial = {}): ConversationThread { return { @@ -75,10 +83,30 @@ function message(partial: Partial = {}): ConversationMes }; } +function sqlMessage(id: string, createdAt: Date): Record { + return { + id, + conversation_id: 'c1', + text: id, + created_at: createdAt, + sender_account_id: 'acc', + sender_pubkey: null, + name: 'Ada', + sats: 0, + actor_account_id: null, + actor_name: '', + gift_for_message_id: null, + event_id: null, + nostr_publish_state: 'pending', + nostr_event: null, + claimed_until: null, + }; +} + describe('CONVERSATION_SCHEMA_SQL', () => { it('creates conversation tables and unique indexes', () => { const joined = CONVERSATION_SCHEMA_SQL.join('\n'); - expect(CONVERSATION_SCHEMA_SQL).toHaveLength(21); + expect(CONVERSATION_SCHEMA_SQL).toHaveLength(24); expect(joined).toMatch(/CREATE TABLE IF NOT EXISTS conversation/i); expect(joined).toMatch(/CREATE TABLE IF NOT EXISTS conversation_message/i); expect(joined).toMatch(/actor_account_id/); @@ -88,6 +116,9 @@ describe('CONVERSATION_SCHEMA_SQL', () => { ); expect(joined).not.toMatch(/gift_for_message_id uuid REFERENCES/); expect(joined).toMatch(/CREATE TABLE IF NOT EXISTS conversation_read/i); + expect(joined).toMatch(/conversation_message_extra_photo/); + expect(joined).toMatch(/photo bytea/); + expect(joined).toMatch(/photo_content_type/); expect(joined).toMatch(/conversation_read_conversation_id_idx/); expect(joined).toMatch(/conversation_member_member_uidx/); expect(joined).toMatch(/conversation_member_platform_uidx/); @@ -284,6 +315,89 @@ describe('InMemoryConversationStore', () => { expect(await store.listMessages(opened.id, 10)).toHaveLength(1); }); + it('seeded rows without photoCount keep hasPhoto as a still count of 1', async () => { + const legacy = message({ id: 'legacy-photo', conversationId: 'c-1', hasPhoto: true }); + delete (legacy as { photoCount?: number }).photoCount; + const store = new InMemoryConversationStore([thread()], [legacy]); + const rows = await store.listMessages('c-1', 10); + expect(rows[0]?.hasPhoto).toBe(true); + expect(rows[0]?.photoCount).toBe(1); + }); + + it('append without photos is hasPhoto false and getPhoto null', async () => { + const store = new InMemoryConversationStore(); + const opened = await store.openMemberMember('a', 'b', NOW); + const created = await store.appendMessage(message({ conversationId: opened.id })); + expect(created.hasPhoto).toBe(false); + expect(created.photoCount).toBe(0); + expect(await store.getPhoto(created.id)).toBeNull(); + }); + + it('append with JPEG copies photo 0 so callers cannot mutate the store', async () => { + const store = new InMemoryConversationStore(); + const opened = await store.openMemberMember('a', 'b', NOW); + const created = await store.appendMessage(message({ conversationId: opened.id }), JPEG); + expect(created.hasPhoto).toBe(true); + expect(created.photoCount).toBe(1); + const copy = await store.getPhoto(created.id); + expect(copy).toEqual(JPEG); + if (copy !== null) { + copy.bytes[0] = 0; + } + expect((await store.getPhoto(created.id))?.bytes[0]).toBe(0xff); + expect(await store.getExtraPhoto(created.id, 1)).toBeNull(); + }); + + it('append with JPEG plus extras stores photoCount 2', async () => { + const store = new InMemoryConversationStore(); + const opened = await store.openMemberMember('a', 'b', NOW); + const created = await store.appendMessage(message({ conversationId: opened.id }), JPEG, [ + JPEG2, + ]); + expect(created.photoCount).toBe(2); + expect(await store.getExtraPhoto(created.id, 1)).toEqual(JPEG2); + expect(await store.getExtraPhoto(created.id, 0)).toBeNull(); + expect(await store.getExtraPhoto(created.id, 10)).toBeNull(); + }); + + it('throws when extras are set without photo 0', async () => { + const store = new InMemoryConversationStore(); + const opened = await store.openMemberMember('a', 'b', NOW); + await expect( + store.appendMessage(message({ conversationId: opened.id }), undefined, [JPEG2]), + ).rejects.toThrow('extra photos require photo 0'); + }); + + it('throws when more than 9 extra photos are given', async () => { + const store = new InMemoryConversationStore(); + const opened = await store.openMemberMember('a', 'b', NOW); + const extras = Array.from({ length: 10 }, () => JPEG2); + await expect( + store.appendMessage(message({ conversationId: opened.id }), JPEG, extras), + ).rejects.toThrow('at most 9 extra photos'); + }); + + it('duplicate id after a photo-less insert does not store extras', async () => { + const store = new InMemoryConversationStore(); + const opened = await store.openMemberMember('a', 'b', NOW); + const first = await store.appendMessage(message({ id: 'm-dup', conversationId: opened.id })); + const second = await store.appendMessage( + message({ id: 'm-dup', conversationId: opened.id, text: 'other' }), + JPEG, + [JPEG2], + ); + expect(second.text).toBe(first.text); + expect(second.hasPhoto).toBe(false); + expect(await store.getPhoto('m-dup')).toBeNull(); + expect(await store.getExtraPhoto('m-dup', 1)).toBeNull(); + }); + + it('getPhoto and getExtraPhoto return null for an unknown id', async () => { + const store = new InMemoryConversationStore(); + expect(await store.getPhoto('missing')).toBeNull(); + expect(await store.getExtraPhoto('missing', 1)).toBeNull(); + }); + it('lists visible own threads and staff platform threads newest first', async () => { const store = new InMemoryConversationStore(); const own = await store.openMemberMember('acc', 'other', NOW); @@ -341,6 +455,55 @@ describe('InMemoryConversationStore', () => { ]); }); + it('lists the newest thread page oldest-first with a cap and caller-owned copies', async () => { + const later = new Date(NOW.getTime() + 1000); + const newest = new Date(NOW.getTime() + 2000); + const store = new InMemoryConversationStore( + [thread()], + [ + message({ id: 'm-old', createdAt: NOW, nostrEvent: { kind: 1059 } }), + message({ id: 'm-a', createdAt: later }), + message({ id: 'm-z', createdAt: later }), + message({ id: 'm-new', createdAt: newest }), + message({ id: 'm-other', conversationId: 'c-2', createdAt: newest }), + ], + ); + + const listed = await store.listThreadPage({ conversationId: 'c-1', limit: 3, cursor: null }); + expect(listed.map((row) => row.id)).toEqual(['m-a', 'm-z', 'm-new']); + listed[0]!.text = 'changed'; + listed[0]!.createdAt.setTime(0); + listed[0]!.nostrEvent = { changed: true }; + + const again = await store.listThreadPage({ conversationId: 'c-1', limit: 3, cursor: null }); + expect(again[0]?.text).toBe('hello'); + expect(again[0]?.createdAt).toEqual(later); + expect(again[0]?.nostrEvent).toBeNull(); + }); + + it('lists rows exclusively older than a thread cursor with same-timestamp id ordering', async () => { + const tied = new Date(NOW.getTime() + 1000); + const store = new InMemoryConversationStore( + [thread()], + [ + message({ id: 'm-old', createdAt: NOW }), + message({ id: 'm-a', createdAt: tied }), + message({ id: 'm-z', createdAt: tied }), + message({ id: 'm-new', createdAt: new Date(NOW.getTime() + 2000) }), + ], + ); + + const listed = await store.listThreadPage({ + conversationId: 'c-1', + limit: 10, + cursor: { c: tied, i: 'm-z' }, + }); + expect(listed.map((row) => row.id)).toEqual(['m-old', 'm-a']); + expect( + await store.listThreadPage({ conversationId: 'missing', limit: 10, cursor: null }), + ).toEqual([]); + }); + it('caps listVisible at limit and breaks ties by id descending', async () => { const highId = thread({ id: 'z', @@ -1353,6 +1516,45 @@ describe('PostgresConversationStore', () => { expect(await store.getMessageByEventId('ab'.repeat(32))).toBeDefined(); }); + it('lists a Postgres thread page newest-first in SQL and reverses a copy', async () => { + const sql = new MockSql(); + const newest = new Date(NOW.getTime() + 1000); + sql.nextRows = [sqlMessage('m-new', newest), sqlMessage('m-old', NOW)]; + const originalRows = sql.nextRows.slice(); + + const listed = await new PostgresConversationStore(sql).listThreadPage({ + conversationId: 'c1', + limit: 2, + cursor: null, + }); + + expect(listed.map((row) => row.id)).toEqual(['m-old', 'm-new']); + expect(sql.nextRows).toEqual(originalRows); + expect(sql.queries[0]?.text).toMatch(/WHERE conversation_id = \$1/); + expect(sql.queries[0]?.text).toMatch(/ORDER BY created_at DESC, id DESC/); + expect(sql.queries[0]?.text).toMatch(/LIMIT \$2/); + expect(sql.queries[0]?.text).not.toMatch(/created_at < \$3/); + expect(sql.queries[0]?.params).toEqual(['c1', 2]); + }); + + it('binds the exclusive older cursor for a Postgres thread page', async () => { + const sql = new MockSql(); + sql.nextRows = [sqlMessage('m-old', NOW)]; + const cursorAt = new Date(NOW.getTime() + 1000); + + const listed = await new PostgresConversationStore(sql).listThreadPage({ + conversationId: 'c1', + limit: 20, + cursor: { c: cursorAt, i: 'm-cursor' }, + }); + + expect(listed.map((row) => row.id)).toEqual(['m-old']); + expect(sql.queries[0]?.text).toMatch(/created_at < \$3 OR \(created_at = \$3 AND id < \$4\)/); + expect(sql.queries[0]?.text).toMatch(/ORDER BY created_at DESC, id DESC/); + expect(sql.queries[0]?.text).toMatch(/LIMIT \$2/); + expect(sql.queries[0]?.params).toEqual(['c1', 20, cursorAt, 'm-cursor']); + }); + it('getMessageById returns undefined when no row matches', async () => { const sql = new MockSql(); sql.nextRows = []; @@ -1607,4 +1809,123 @@ describe('PostgresConversationStore', () => { new PostgresConversationStore(sql).openMemberMember('a', 'b', NOW), ).rejects.toThrow('insert boom'); }); + + it('MESSAGE_SELECT includes computed has_photo and photo_count without listing photo bytea', async () => { + const sql = new MockSql(); + sql.nextRows = []; + const store = new PostgresConversationStore(sql); + await store.getMessageById('m1'); + await store.listMessages('c1', 10); + for (const query of sql.queries) { + expect(query.text).toMatch(/has_photo/); + expect(query.text).toMatch(/photo_count/); + expect(query.text).not.toMatch(/photo bytea/); + } + }); + + it('appendMessage with JPEG binds photo params and extras insert', async () => { + const sql = new MockSql(); + const store = new PostgresConversationStore(sql); + const row = message(); + await store.appendMessage(row, JPEG, [JPEG2]); + expect(sql.executes[0]?.params[12]).toBe(row.actorAccountId ?? null); + expect(sql.executes[0]?.params[13]).toBe(row.actorName ?? ''); + expect(sql.executes[0]?.params[14]).toBe(row.giftForMessageId ?? null); + expect(sql.executes[0]?.params[15]).toEqual(JPEG.bytes); + expect(sql.executes[0]?.params[16]).toBe(JPEG.contentType); + expect(sql.executes[1]?.text).toMatch(/conversation_message_extra_photo/); + expect(sql.executes[1]?.params).toEqual([row.id, 1, JPEG2.bytes, JPEG2.contentType]); + }); + + it('appendMessage throws extras without photo 0 and more than 9 extras', async () => { + const sql = new MockSql(); + const store = new PostgresConversationStore(sql); + await expect(store.appendMessage(message(), undefined, [JPEG2])).rejects.toThrow( + 'extra photos require photo 0', + ); + await expect( + store.appendMessage( + message(), + JPEG, + Array.from({ length: 10 }, () => JPEG2), + ), + ).rejects.toThrow('at most 9 extra photos'); + expect(sql.executes).toEqual([]); + }); + + it('appendMessage deletes the message when an extra INSERT fails', async () => { + const extraError = new Error('extra insert boom'); + const sql = new MockSql(); + const execute = sql.execute.bind(sql); + sql.execute = async (text: string, params: readonly unknown[] = []): Promise => { + await execute(text, params); + if (/INSERT INTO conversation_message_extra_photo/.test(text)) { + throw extraError; + } + }; + await expect( + new PostgresConversationStore(sql).appendMessage(message(), JPEG, [JPEG2]), + ).rejects.toBe(extraError); + expect(sql.executes.some((item) => /DELETE FROM conversation_message/.test(item.text))).toBe( + true, + ); + }); + + it('getPhoto maps bytea, number[], empty, null photo, missing type, and gif type', async () => { + const sql = new MockSql(); + const store = new PostgresConversationStore(sql); + sql.nextRows = [{ photo: JPEG.bytes, photo_content_type: 'image/jpeg' }]; + expect(await store.getPhoto('m1')).toEqual(JPEG); + expect(sql.queries[0]?.text).toMatch( + /SELECT photo, photo_content_type FROM conversation_message WHERE id = \$1/, + ); + sql.nextRows = [{ photo: [0xff, 0xd8, 0xff, 0xd9], photo_content_type: 'image/jpeg' }]; + expect(await store.getPhoto('m1')).toEqual(JPEG); + sql.nextRows = []; + expect(await store.getPhoto('missing')).toBeNull(); + sql.nextRows = [{ photo: null, photo_content_type: 'image/jpeg' }]; + expect(await store.getPhoto('m1')).toBeNull(); + sql.nextRows = [{ photo: JPEG.bytes, photo_content_type: null }]; + expect(await store.getPhoto('m1')).toBeNull(); + sql.nextRows = [{ photo: JPEG.bytes, photo_content_type: 'image/gif' }]; + expect(await store.getPhoto('m1')).toBeNull(); + }); + + it('getPhoto query throw propagates', async () => { + const sql = new MockSql(); + sql.queryError = new Error('photo boom'); + await expect(new PostgresConversationStore(sql).getPhoto('m1')).rejects.toThrow('photo boom'); + }); + + it('getExtraPhoto maps bytea, number[], empty, null photo, missing type, and gif type', async () => { + const sql = new MockSql(); + const store = new PostgresConversationStore(sql); + expect(await store.getExtraPhoto('m1', 0)).toBeNull(); + expect(await store.getExtraPhoto('m1', 10)).toBeNull(); + expect(sql.queries).toEqual([]); + sql.nextRows = [{ photo: JPEG2.bytes, photo_content_type: 'image/jpeg' }]; + expect(await store.getExtraPhoto('m1', 1)).toEqual(JPEG2); + expect(sql.queries[0]?.text).toMatch( + /SELECT photo, photo_content_type FROM conversation_message_extra_photo WHERE message_id = \$1 AND idx = \$2/, + ); + expect(sql.queries[0]?.params).toEqual(['m1', 1]); + sql.nextRows = [{ photo: [0xff, 0xd8, 0xff, 0x00], photo_content_type: 'image/jpeg' }]; + expect(await store.getExtraPhoto('m1', 1)).toEqual(JPEG2); + sql.nextRows = []; + expect(await store.getExtraPhoto('m1', 1)).toBeNull(); + sql.nextRows = [{ photo: null, photo_content_type: 'image/jpeg' }]; + expect(await store.getExtraPhoto('m1', 1)).toBeNull(); + sql.nextRows = [{ photo: JPEG2.bytes, photo_content_type: null }]; + expect(await store.getExtraPhoto('m1', 1)).toBeNull(); + sql.nextRows = [{ photo: JPEG2.bytes, photo_content_type: 'image/gif' }]; + expect(await store.getExtraPhoto('m1', 1)).toBeNull(); + }); + + it('getExtraPhoto query throw propagates', async () => { + const sql = new MockSql(); + sql.queryError = new Error('extra photo boom'); + await expect(new PostgresConversationStore(sql).getExtraPhoto('m1', 1)).rejects.toThrow( + 'extra photo boom', + ); + }); }); diff --git a/src/__tests__/lib/conversation.test.ts b/src/__tests__/lib/conversation.test.ts index 7dfbc977..1b425240 100644 --- a/src/__tests__/lib/conversation.test.ts +++ b/src/__tests__/lib/conversation.test.ts @@ -196,11 +196,29 @@ describe('serializeConversationMessage', () => { createdAt: '2026-08-29T13:00:00.000Z', fromMe: false, sats: 0, + hasPhoto: false, + photoCount: 0, accountId: 'acc-a', }); expect(json).not.toHaveProperty('eventId'); expect(json).not.toHaveProperty('senderAccountId'); expect(json).not.toHaveProperty('senderPubkey'); + expect(json).not.toHaveProperty('bytes'); + expect(json).not.toHaveProperty('photo'); + }); + + it('defaults omitted photoCount to 1 when hasPhoto is true', () => { + expect(serializeConversationMessage({ ...ROW, hasPhoto: true }, false)).toMatchObject({ + hasPhoto: true, + photoCount: 1, + }); + }); + + it('defaults omitted hasPhoto and photoCount to false and 0', () => { + expect(serializeConversationMessage(ROW, false)).toMatchObject({ + hasPhoto: false, + photoCount: 0, + }); }); it('omits accountId when senderAccountId is null', () => { diff --git a/src/__tests__/lib/funding-store.test.ts b/src/__tests__/lib/funding-store.test.ts new file mode 100644 index 00000000..1cd95000 --- /dev/null +++ b/src/__tests__/lib/funding-store.test.ts @@ -0,0 +1,591 @@ +import { describe, expect, it } from 'vitest'; +import type { SqlClient } from '@/lib/auth/sql'; +import type { FundingGrant } from '@/lib/funding'; +import { + FUNDING_SCHEMA_SQL, + InMemoryFundingStore, + PostgresFundingStore, + loadGrantEffective, + migrateFundingSchema, + type FundingStore, +} from '@/lib/funding-store'; + +class MockSql implements SqlClient { + executes: { text: string; params: readonly unknown[] }[] = []; + queries: { text: string; params: readonly unknown[] }[] = []; + nextRows: unknown[] = []; + queryResults: unknown[][] | undefined; + queryError: unknown | undefined; + executeError: unknown | undefined; + + async query(text: string, params: readonly unknown[] = []): Promise { + this.queries.push({ text, params }); + if (this.queryError !== undefined) { + throw this.queryError; + } + if (this.queryResults !== undefined) { + const next = this.queryResults.shift(); + return (next ?? []) as T[]; + } + return this.nextRows as T[]; + } + + async execute(text: string, params: readonly unknown[] = []): Promise { + this.executes.push({ text, params }); + if (this.executeError !== undefined) { + throw this.executeError; + } + } +} + +const NOW_MS = Date.parse('2026-09-20T12:00:00.000Z'); +const TODAY = '2026-09-20'; +const YESTERDAY = '2026-09-19'; +const TOMORROW = '2026-09-21'; +const APPLIED = Date.parse('2026-09-01T00:00:00.000Z'); +const DECIDED = Date.parse('2026-09-10T08:00:00.000Z'); +const ADMITTED = Date.parse('2026-09-15T18:00:00.000Z'); + +function grant(overrides: Partial = {}): FundingGrant { + return { + accountId: 'acc-a', + status: 'pending', + appliedAt: APPLIED, + decidedAt: null, + decidedBy: null, + trialUtcDate: null, + admittedAt: null, + note: null, + ...overrides, + }; +} + +const EARLY = grant({ + accountId: 'acc-z', + appliedAt: Date.parse('2026-08-01T00:00:00.000Z'), + note: 'early', +}); + +const TIE_HIGH = grant({ + accountId: 'acc-b', + appliedAt: Date.parse('2026-08-02T00:00:00.000Z'), + note: 'tie-high', +}); + +const TIE_LOW = grant({ + accountId: 'acc-a', + appliedAt: Date.parse('2026-08-02T00:00:00.000Z'), + note: 'tie-low', +}); + +const LATE = grant({ + accountId: 'acc-m', + appliedAt: Date.parse('2026-08-03T00:00:00.000Z'), + note: 'late', +}); + +describe('FUNDING_SCHEMA_SQL', () => { + it('creates funding_grant with the expected columns and status check', () => { + expect(FUNDING_SCHEMA_SQL).toHaveLength(1); + expect(FUNDING_SCHEMA_SQL[0]).toMatch(/CREATE TABLE IF NOT EXISTS funding_grant/i); + expect(FUNDING_SCHEMA_SQL[0]).toMatch(/account_id uuid PRIMARY KEY REFERENCES account/i); + expect(FUNDING_SCHEMA_SQL[0]).toMatch( + /CHECK \(status IN \('pending', 'trial', 'admitted', 'rejected'\)\)/, + ); + expect(FUNDING_SCHEMA_SQL[0]).toMatch(/trial_utc_date date/); + }); +}); + +describe('migrateFundingSchema', () => { + it('runs every FUNDING_SCHEMA_SQL statement', async () => { + const sql = new MockSql(); + await migrateFundingSchema(sql); + expect(sql.executes.map((item) => item.text)).toEqual([...FUNDING_SCHEMA_SQL]); + expect(sql.executes[0]?.text).toMatch(/CREATE TABLE IF NOT EXISTS funding_grant/i); + }); +}); + +describe('InMemoryFundingStore', () => { + it('returns an equal copy after upsert, not the same object', async () => { + const store = new InMemoryFundingStore(); + const input = grant({ status: 'admitted', admittedAt: ADMITTED, note: 'ok' }); + const created = await store.upsert(input); + expect(created).toEqual(input); + expect(created).not.toBe(input); + const loaded = await store.getByAccountId('acc-a'); + expect(loaded).toEqual(input); + expect(loaded).not.toBe(input); + expect(loaded).not.toBe(created); + }); + + it('getByAccountId returns undefined when missing', async () => { + expect(await new InMemoryFundingStore().getByAccountId('missing')).toBeUndefined(); + }); + + it('listGrants is empty on a fresh store', async () => { + expect(await new InMemoryFundingStore().listGrants()).toEqual([]); + }); + + it('listGrants orders by oldest appliedAt then accountId ascending', async () => { + const store = new InMemoryFundingStore([LATE, TIE_HIGH, EARLY, TIE_LOW]); + expect((await store.listGrants()).map((row) => row.note)).toEqual([ + 'early', + 'tie-low', + 'tie-high', + 'late', + ]); + }); + + it('second upsert for the same accountId replaces the row', async () => { + const store = new InMemoryFundingStore(); + await store.upsert(grant({ status: 'pending', note: 'old' })); + const replaced = await store.upsert( + grant({ + status: 'trial', + trialUtcDate: TODAY, + decidedAt: DECIDED, + decidedBy: 'staff', + note: 'new', + }), + ); + expect(replaced.status).toBe('trial'); + expect(replaced.note).toBe('new'); + const listed = await store.listGrants(); + expect(listed).toHaveLength(1); + expect(listed[0]?.note).toBe('new'); + expect(listed[0]?.status).toBe('trial'); + expect(listed[0]?.trialUtcDate).toBe(TODAY); + }); + + it('transition writes only when the stored status is in from', async () => { + const store = new InMemoryFundingStore(); + const pending = grant({ status: 'pending' }); + expect(await store.transition(pending, ['none', 'rejected'])).toEqual(pending); + const admitted = grant({ + status: 'admitted', + decidedAt: DECIDED, + decidedBy: 'staff', + admittedAt: ADMITTED, + }); + expect(await store.transition(admitted, ['pending', 'trial'])).toEqual(admitted); + expect( + await store.transition(grant({ status: 'rejected' }), ['pending', 'trial']), + ).toBeUndefined(); + expect((await store.getByAccountId('acc-a'))?.status).toBe('admitted'); + }); + + it('copies seed, listed, got, and upserted objects so callers cannot mutate store state', async () => { + const seed: FundingGrant[] = [grant({ note: 'seed' }), LATE]; + const store = new InMemoryFundingStore(seed); + seed.pop(); + if (seed[0] !== undefined) { + seed[0].note = 'mutated-seed'; + seed[0].status = 'rejected'; + } + const listed = await store.listGrants(); + expect(listed).toHaveLength(2); + listed.pop(); + if (listed[0] !== undefined) { + listed[0].note = 'mutated-list'; + } + const got = await store.getByAccountId('acc-a'); + if (got !== undefined) { + got.note = 'mutated-got'; + } + const created = await store.upsert(grant({ accountId: 'acc-new', note: 'fresh' })); + created.note = 'mutated-upsert-return'; + const input = grant({ accountId: 'acc-write', note: 'write' }); + await store.upsert(input); + input.note = 'mutated-input'; + expect((await store.getByAccountId('acc-a'))?.note).toBe('seed'); + expect((await store.getByAccountId('acc-m'))?.note).toBe('late'); + expect((await store.getByAccountId('acc-new'))?.note).toBe('fresh'); + expect((await store.getByAccountId('acc-write'))?.note).toBe('write'); + expect(await store.listGrants()).toHaveLength(4); + }); +}); + +/** First read returns `seed`; later reads/upserts see `replacement`. */ +class RaceStore implements FundingStore { + readonly #inner: InMemoryFundingStore; + #reads = 0; + + constructor( + seed: FundingGrant, + private readonly replacement: FundingGrant, + ) { + this.#inner = new InMemoryFundingStore([seed]); + } + + async getByAccountId(accountId: string): Promise { + const value = await this.#inner.getByAccountId(accountId); + this.#reads += 1; + if (this.#reads === 1) { + await this.#inner.upsert(this.replacement); + } + return value; + } + + listGrants(): Promise { + return this.#inner.listGrants(); + } + + upsert(grant: FundingGrant): Promise { + return this.#inner.upsert(grant); + } + + transition( + grant: FundingGrant, + from: readonly (FundingGrant['status'] | 'none')[], + ): Promise { + return this.#inner.transition(grant, from); + } + + expireTrialIfUnchanged(grant: FundingGrant): Promise { + return this.#inner.expireTrialIfUnchanged(grant); + } +} + +describe('loadGrantEffective', () => { + it('persists pending when the stored trial day is before today UTC', async () => { + const store = new InMemoryFundingStore(); + const stored = grant({ + status: 'trial', + trialUtcDate: YESTERDAY, + decidedAt: DECIDED, + decidedBy: 'staff', + note: 'keep', + }); + await store.upsert(stored); + const loaded = await loadGrantEffective(store, 'acc-a', NOW_MS); + expect(loaded).toEqual({ + accountId: 'acc-a', + status: 'pending', + appliedAt: APPLIED, + decidedAt: DECIDED, + decidedBy: 'staff', + trialUtcDate: null, + admittedAt: null, + note: 'keep', + }); + expect(await store.getByAccountId('acc-a')).toEqual(loaded); + }); + + it('expireTrialIfUnchanged returns undefined when no row exists', async () => { + const store = new InMemoryFundingStore(); + expect( + await store.expireTrialIfUnchanged( + grant({ status: 'trial', trialUtcDate: YESTERDAY, decidedAt: DECIDED, decidedBy: 'staff' }), + ), + ).toBeUndefined(); + }); + + it('does not overwrite an admitted row that replaced the expired trial', async () => { + const expired = grant({ + status: 'trial', + trialUtcDate: YESTERDAY, + decidedAt: DECIDED, + decidedBy: 'staff', + note: 'keep', + }); + const admitted = grant({ + status: 'admitted', + decidedAt: DECIDED, + decidedBy: 'staff', + trialUtcDate: null, + admittedAt: ADMITTED, + note: 'keep', + }); + const store = new RaceStore(expired, admitted); + const loaded = await loadGrantEffective(store, 'acc-a', NOW_MS); + expect(loaded).toEqual(admitted); + expect(await store.getByAccountId('acc-a')).toEqual(admitted); + }); + + it('returns undefined and does not upsert when no row exists', async () => { + const store = new InMemoryFundingStore(); + expect(await loadGrantEffective(store, 'missing', NOW_MS)).toBeUndefined(); + expect(await store.listGrants()).toEqual([]); + }); + + it('does not rewrite a trial for today or tomorrow', async () => { + const store = new InMemoryFundingStore(); + await store.upsert(grant({ accountId: 'today', status: 'trial', trialUtcDate: TODAY })); + await store.upsert(grant({ accountId: 'tomorrow', status: 'trial', trialUtcDate: TOMORROW })); + const today = await loadGrantEffective(store, 'today', NOW_MS); + const tomorrow = await loadGrantEffective(store, 'tomorrow', NOW_MS); + expect(today?.status).toBe('trial'); + expect(today?.trialUtcDate).toBe(TODAY); + expect(tomorrow?.status).toBe('trial'); + expect(tomorrow?.trialUtcDate).toBe(TOMORROW); + expect((await store.getByAccountId('today'))?.status).toBe('trial'); + expect((await store.getByAccountId('tomorrow'))?.status).toBe('trial'); + }); + + it('persists pending via Postgres UPDATE … WHERE still that trial', async () => { + const sql = new MockSql(); + const expiredRow = { + account_id: 'acc-a', + status: 'trial' as const, + applied_at: new Date('2026-09-01T00:00:00.000Z'), + decided_at: new Date('2026-09-10T08:00:00.000Z'), + decided_by: 'staff', + trial_utc_date: YESTERDAY, + admitted_at: null, + note: 'keep', + }; + const pendingRow = { + ...expiredRow, + status: 'pending' as const, + trial_utc_date: null, + admitted_at: null, + }; + sql.queryResults = [[expiredRow], [pendingRow]]; + const loaded = await loadGrantEffective(new PostgresFundingStore(sql), 'acc-a', NOW_MS); + expect(sql.queries[1]?.text).toMatch(/UPDATE funding_grant SET/); + expect(sql.queries[1]?.text).toMatch( + /WHERE account_id = \$1 AND status = 'trial' AND trial_utc_date = \$2/, + ); + expect(sql.queries[1]?.text).toMatch(/RETURNING /); + expect(sql.queries[1]?.params[0]).toBe('acc-a'); + expect(sql.queries[1]?.params[1]).toBe(YESTERDAY); + expect(sql.queries[1]?.params[2]).toBe('pending'); + expect(sql.executes).toEqual([]); + expect(loaded).toEqual({ + accountId: 'acc-a', + status: 'pending', + appliedAt: APPLIED, + decidedAt: DECIDED, + decidedBy: 'staff', + trialUtcDate: null, + admittedAt: null, + note: 'keep', + }); + }); + + it('binds null decided_at when the expired Postgres trial has no decision stamp', async () => { + const sql = new MockSql(); + const expiredRow = { + account_id: 'acc-a', + status: 'trial' as const, + applied_at: new Date('2026-09-01T00:00:00.000Z'), + decided_at: null, + decided_by: null, + trial_utc_date: YESTERDAY, + admitted_at: null, + note: null, + }; + const pendingRow = { + ...expiredRow, + status: 'pending' as const, + trial_utc_date: null, + }; + sql.queryResults = [[expiredRow], [pendingRow]]; + const loaded = await loadGrantEffective(new PostgresFundingStore(sql), 'acc-a', NOW_MS); + expect(sql.queries[1]?.params[4]).toBeNull(); + expect(sql.queries[1]?.params[7]).toBeNull(); + expect(loaded?.decidedAt).toBeNull(); + expect(loaded?.admittedAt).toBeNull(); + }); + + it('does not overwrite a Postgres admitted row when UPDATE matches 0 rows', async () => { + const sql = new MockSql(); + const expiredRow = { + account_id: 'acc-a', + status: 'trial' as const, + applied_at: new Date('2026-09-01T00:00:00.000Z'), + decided_at: new Date('2026-09-10T08:00:00.000Z'), + decided_by: 'staff', + trial_utc_date: YESTERDAY, + admitted_at: null, + note: 'keep', + }; + const admittedRow = { + account_id: 'acc-a', + status: 'admitted' as const, + applied_at: new Date('2026-09-01T00:00:00.000Z'), + decided_at: new Date('2026-09-10T08:00:00.000Z'), + decided_by: 'staff', + trial_utc_date: null, + admitted_at: new Date('2026-09-15T18:00:00.000Z'), + note: 'keep', + }; + sql.queryResults = [[expiredRow], [], [admittedRow]]; + const loaded = await loadGrantEffective(new PostgresFundingStore(sql), 'acc-a', NOW_MS); + expect(sql.queries[1]?.text).toMatch(/UPDATE funding_grant SET/); + expect(sql.queries[2]?.text).toMatch(/SELECT .+ FROM funding_grant WHERE account_id = \$1/); + expect(sql.executes).toEqual([]); + expect(loaded).toEqual({ + accountId: 'acc-a', + status: 'admitted', + appliedAt: APPLIED, + decidedAt: DECIDED, + decidedBy: 'staff', + trialUtcDate: null, + admittedAt: ADMITTED, + note: 'keep', + }); + }); +}); + +describe('PostgresFundingStore', () => { + it('getByAccountId binds $1 and maps Date and string timestamps and trial_utc_date Date', async () => { + const sql = new MockSql(); + sql.nextRows = [ + { + account_id: 'acc-a', + status: 'trial', + applied_at: new Date('2026-09-01T00:00:00.000Z'), + decided_at: '2026-09-10T08:00:00.000Z', + decided_by: 'staff', + trial_utc_date: new Date('2026-09-20T00:00:00.000Z'), + admitted_at: null, + note: 'hello', + }, + ]; + const loaded = await new PostgresFundingStore(sql).getByAccountId('acc-a'); + expect(sql.queries[0]?.text).toMatch(/SELECT .+ FROM funding_grant WHERE account_id = \$1/); + expect(sql.queries[0]?.params).toEqual(['acc-a']); + expect(loaded).toEqual({ + accountId: 'acc-a', + status: 'trial', + appliedAt: APPLIED, + decidedAt: DECIDED, + decidedBy: 'staff', + trialUtcDate: TODAY, + admittedAt: null, + note: 'hello', + }); + }); + + it('getByAccountId returns undefined when no row matches', async () => { + const sql = new MockSql(); + sql.nextRows = []; + expect(await new PostgresFundingStore(sql).getByAccountId('missing')).toBeUndefined(); + }); + + it('maps null trial_utc_date and null timestamptz columns', async () => { + const sql = new MockSql(); + sql.nextRows = [ + { + account_id: 'acc-a', + status: 'pending', + applied_at: new Date('2026-09-01T00:00:00.000Z'), + decided_at: null, + decided_by: null, + trial_utc_date: null, + admitted_at: null, + note: null, + }, + ]; + expect(await new PostgresFundingStore(sql).getByAccountId('acc-a')).toEqual(grant()); + }); + + it('listGrants orders by applied_at then account_id and maps string trial dates', async () => { + const sql = new MockSql(); + sql.nextRows = [ + { + account_id: 'acc-a', + status: 'admitted', + applied_at: '2026-09-01T00:00:00.000Z', + decided_at: new Date('2026-09-10T08:00:00.000Z'), + decided_by: null, + trial_utc_date: '2026-09-20T00:00:00.000Z', + admitted_at: new Date('2026-09-15T18:00:00.000Z'), + note: null, + }, + ]; + const listed = await new PostgresFundingStore(sql).listGrants(); + expect(sql.queries[0]?.text).toMatch(/ORDER BY applied_at ASC, account_id ASC/); + expect(sql.queries[0]?.params).toEqual([]); + expect(listed).toEqual([ + { + accountId: 'acc-a', + status: 'admitted', + appliedAt: APPLIED, + decidedAt: DECIDED, + decidedBy: null, + trialUtcDate: TODAY, + admittedAt: ADMITTED, + note: null, + }, + ]); + }); + + it('upsert uses INSERT and ON CONFLICT (account_id) DO UPDATE', async () => { + const sql = new MockSql(); + const input = grant({ + status: 'admitted', + decidedAt: DECIDED, + decidedBy: 'staff', + trialUtcDate: TODAY, + admittedAt: ADMITTED, + note: 'n', + }); + const created = await new PostgresFundingStore(sql).upsert(input); + expect(sql.executes[0]?.text).toMatch(/INSERT INTO funding_grant/); + expect(sql.executes[0]?.text).toMatch(/ON CONFLICT \(account_id\) DO UPDATE/); + expect(sql.executes[0]?.params).toEqual([ + input.accountId, + input.status, + new Date(input.appliedAt), + new Date(DECIDED), + 'staff', + TODAY, + new Date(ADMITTED), + 'n', + ]); + expect(created).toEqual(input); + expect(created).not.toBe(input); + }); + + it('propagates query errors', async () => { + const sql = new MockSql(); + sql.queryError = new Error('list boom'); + await expect(new PostgresFundingStore(sql).getByAccountId('acc-a')).rejects.toThrow( + 'list boom', + ); + await expect(new PostgresFundingStore(sql).listGrants()).rejects.toThrow('list boom'); + }); + + it('propagates upsert execute errors', async () => { + const sql = new MockSql(); + sql.executeError = new Error('write boom'); + await expect(new PostgresFundingStore(sql).upsert(grant())).rejects.toThrow('write boom'); + }); + + it('transition INSERT ON CONFLICT WHERE status = ANY when from includes none', async () => { + const sql = new MockSql(); + const input = grant({ status: 'pending' }); + sql.nextRows = [ + { + account_id: input.accountId, + status: 'pending', + applied_at: new Date(input.appliedAt), + decided_at: null, + decided_by: null, + trial_utc_date: null, + admitted_at: null, + note: null, + }, + ]; + const created = await new PostgresFundingStore(sql).transition(input, ['none', 'rejected']); + expect(sql.queries[0]?.text).toMatch(/INSERT INTO funding_grant/); + expect(sql.queries[0]?.text).toMatch(/WHERE funding_grant.status = ANY\(\$9::text\[\]\)/); + expect(sql.queries[0]?.params[8]).toEqual(['rejected']); + expect(created?.status).toBe('pending'); + }); + + it('transition UPDATE WHERE status = ANY when from has no none', async () => { + const sql = new MockSql(); + sql.nextRows = []; + const missed = await new PostgresFundingStore(sql).transition( + grant({ status: 'admitted', admittedAt: ADMITTED, decidedAt: DECIDED, decidedBy: 'staff' }), + ['pending', 'trial'], + ); + expect(sql.queries[0]?.text).toMatch(/UPDATE funding_grant SET/); + expect(sql.queries[0]?.text).toMatch(/status = ANY\(\$9::text\[\]\)/); + expect(sql.queries[0]?.params[8]).toEqual(['pending', 'trial']); + expect(missed).toBeUndefined(); + }); +}); diff --git a/src/__tests__/lib/funding.test.ts b/src/__tests__/lib/funding.test.ts new file mode 100644 index 00000000..86d38edf --- /dev/null +++ b/src/__tests__/lib/funding.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest'; +import type { AccountRole } from '@/lib/auth/store'; +import type { FundingGrant, FundingStatus } from '@/lib/funding'; +import { + effectiveStatus, + eligibleToday, + fundingReviewedAt, + serializeOwnerFunding, +} from '@/lib/funding'; + +const NOW_MS = Date.parse('2026-09-20T12:00:00.000Z'); +const TODAY = '2026-09-20'; +const YESTERDAY = '2026-09-19'; +const TOMORROW = '2026-09-21'; +const NON_BASIS: AccountRole = 'verified'; + +function grant(overrides: Partial = {}): FundingGrant { + return { + accountId: 'acc', + status: 'pending', + appliedAt: Date.parse('2026-09-01T00:00:00.000Z'), + decidedAt: null, + decidedBy: null, + trialUtcDate: null, + admittedAt: null, + note: null, + ...overrides, + }; +} + +function trial(trialUtcDate: string | null): FundingGrant { + return grant({ status: 'trial', trialUtcDate }); +} + +describe('effectiveStatus', () => { + it('returns none when the grant is missing', () => { + expect(effectiveStatus(undefined, NOW_MS)).toBe('none'); + }); + + it('returns pending for a stored pending grant', () => { + expect(effectiveStatus(grant({ status: 'pending' }), NOW_MS)).toBe('pending'); + }); + + it('returns trial when trialUtcDate is today UTC', () => { + expect(effectiveStatus(trial(TODAY), NOW_MS)).toBe('trial'); + }); + + it('returns pending when trialUtcDate is strictly before today UTC', () => { + expect(effectiveStatus(trial(YESTERDAY), NOW_MS)).toBe('pending'); + }); + + it('returns trial when trialUtcDate is after today UTC', () => { + expect(effectiveStatus(trial(TOMORROW), NOW_MS)).toBe('trial'); + }); + + it('does not expire a trial whose trialUtcDate is null', () => { + expect(effectiveStatus(trial(null), NOW_MS)).toBe('trial'); + }); + + it('returns admitted and rejected unchanged', () => { + expect(effectiveStatus(grant({ status: 'admitted' }), NOW_MS)).toBe('admitted'); + expect(effectiveStatus(grant({ status: 'rejected' }), NOW_MS)).toBe('rejected'); + }); +}); + +describe('eligibleToday', () => { + it('is false when the grant is missing, for basis and non-basis', () => { + expect(eligibleToday(NON_BASIS, undefined, NOW_MS)).toBe(false); + expect(eligibleToday('basis', undefined, NOW_MS)).toBe(false); + }); + + it('is always false for basis, even when admitted or trial today', () => { + expect(eligibleToday('basis', grant({ status: 'admitted' }), NOW_MS)).toBe(false); + expect(eligibleToday('basis', trial(TODAY), NOW_MS)).toBe(false); + }); + + it('is false for a pending grant on a non-basis role', () => { + expect(eligibleToday(NON_BASIS, grant({ status: 'pending' }), NOW_MS)).toBe(false); + }); + + it('is true for a non-basis trial on today UTC', () => { + expect(eligibleToday(NON_BASIS, trial(TODAY), NOW_MS)).toBe(true); + }); + + it('is false for a trial whose day is yesterday', () => { + expect(eligibleToday(NON_BASIS, trial(YESTERDAY), NOW_MS)).toBe(false); + }); + + it('is false for a trial whose day is tomorrow', () => { + expect(eligibleToday(NON_BASIS, trial(TOMORROW), NOW_MS)).toBe(false); + }); + + it('is true for admitted on a non-basis role and false for basis', () => { + const admitted = grant({ + status: 'admitted', + admittedAt: NOW_MS, + }); + expect(eligibleToday(NON_BASIS, admitted, NOW_MS)).toBe(true); + expect(eligibleToday('basis', admitted, NOW_MS)).toBe(false); + }); + + it('is false for rejected', () => { + expect(eligibleToday(NON_BASIS, grant({ status: 'rejected' }), NOW_MS)).toBe(false); + }); +}); + +describe('effectiveStatus and eligibleToday matrix', () => { + it('covers stored statuses for verified vs basis', () => { + const cases: Array<{ + status: FundingStatus; + trialUtcDate: string | null; + effective: ReturnType; + eligibleVerified: boolean; + }> = [ + { status: 'pending', trialUtcDate: null, effective: 'pending', eligibleVerified: false }, + { status: 'trial', trialUtcDate: TODAY, effective: 'trial', eligibleVerified: true }, + { status: 'trial', trialUtcDate: YESTERDAY, effective: 'pending', eligibleVerified: false }, + { status: 'trial', trialUtcDate: TOMORROW, effective: 'trial', eligibleVerified: false }, + { status: 'admitted', trialUtcDate: null, effective: 'admitted', eligibleVerified: true }, + { status: 'rejected', trialUtcDate: null, effective: 'rejected', eligibleVerified: false }, + ]; + for (const row of cases) { + const stored = grant({ status: row.status, trialUtcDate: row.trialUtcDate }); + expect(effectiveStatus(stored, NOW_MS)).toBe(row.effective); + expect(eligibleToday(NON_BASIS, stored, NOW_MS)).toBe(row.eligibleVerified); + expect(eligibleToday('basis', stored, NOW_MS)).toBe(false); + } + }); +}); + +describe('serializeOwnerFunding', () => { + it('is null for basis', () => { + expect(serializeOwnerFunding('basis', grant({ status: 'admitted' }), NOW_MS, 'Mod')).toBeNull(); + }); + + it('emits none when the grant is missing', () => { + expect(serializeOwnerFunding(NON_BASIS, undefined, NOW_MS, null)).toEqual({ + status: 'none', + trialUtcDate: null, + admittedAt: null, + reviewedByName: null, + }); + }); + + it('keeps trialUtcDate null when the stored trial day is null', () => { + expect(serializeOwnerFunding(NON_BASIS, trial(null), NOW_MS, null)).toEqual({ + status: 'trial', + trialUtcDate: null, + admittedAt: null, + reviewedByName: null, + }); + }); + + it('keeps admittedAt null when admission has no timestamp', () => { + expect( + serializeOwnerFunding( + NON_BASIS, + grant({ status: 'admitted', admittedAt: null }), + NOW_MS, + 'Mod', + ), + ).toEqual({ + status: 'admitted', + trialUtcDate: null, + admittedAt: null, + reviewedByName: 'Mod', + }); + }); +}); + +describe('fundingReviewedAt', () => { + it('is null unless the effective status is admitted', () => { + expect(fundingReviewedAt(undefined, NOW_MS)).toBeNull(); + expect(fundingReviewedAt(grant({ status: 'pending', admittedAt: NOW_MS }), NOW_MS)).toBeNull(); + }); + + it('returns null when admitted but admittedAt is missing', () => { + expect(fundingReviewedAt(grant({ status: 'admitted', admittedAt: null }), NOW_MS)).toBeNull(); + }); + + it('returns admittedAt when admitted', () => { + expect(fundingReviewedAt(grant({ status: 'admitted', admittedAt: NOW_MS }), NOW_MS)).toBe( + NOW_MS, + ); + }); +}); diff --git a/src/__tests__/lib/message-store.test.ts b/src/__tests__/lib/message-store.test.ts index dcef940c..1bac696e 100644 --- a/src/__tests__/lib/message-store.test.ts +++ b/src/__tests__/lib/message-store.test.ts @@ -1028,6 +1028,111 @@ describe('InMemoryMessageStore', () => { expect(popularMismatchedKind.map((row) => row.id)).toEqual(['pop-high', 'paid', 'pop-low']); }); + it('listFeed filters by hashtag token and pages only matches', async () => { + const untagged = { + ...LATE, + id: 'untagged', + text: 'living room', + createdAt: new Date('2026-08-10T00:00:00.000Z'), + }; + const shop = { + ...EARLY, + id: 'shop', + text: 'Come by #21GiftsShop.', + createdAt: new Date('2026-08-09T00:00:00.000Z'), + }; + const shopCase = { + ...EARLY, + id: 'shop-case', + text: 'also #21giftsshop here', + createdAt: new Date('2026-08-08T00:00:00.000Z'), + }; + const prefix = { + ...EARLY, + id: 'prefix', + text: 'visit #21GiftsShopper', + createdAt: new Date('2026-08-07T00:00:00.000Z'), + }; + const paidShop = { + ...EARLY, + id: 'paid-shop', + text: 'paid #21GiftsShop', + sats: 21, + createdAt: new Date('2026-08-06T00:00:00.000Z'), + }; + const store = new InMemoryMessageStore([untagged, shop, shopCase, prefix, paidShop]); + await store.create({ + ...LATE, + id: 'shop-reply', + parentId: 'shop', + text: 'reply #21GiftsShop', + }); + const emptyStaff = new Set(); + const tagged = await store.listFeed({ + limit: 10, + mode: 'all', + cursor: null, + staffAccountIds: emptyStaff, + hashtag: '21GiftsShop', + }); + expect(tagged.map((row) => row.id)).toEqual(['shop', 'shop-case', 'paid-shop']); + const omitted = await store.listFeed({ + limit: 10, + mode: 'all', + cursor: null, + staffAccountIds: emptyStaff, + }); + expect(omitted.map((row) => row.id)).toEqual([ + 'untagged', + 'shop', + 'shop-case', + 'prefix', + 'paid-shop', + ]); + const emptyHashtag = await store.listFeed({ + limit: 10, + mode: 'all', + cursor: null, + staffAccountIds: emptyStaff, + hashtag: '', + }); + expect(emptyHashtag.map((row) => row.id)).toEqual([ + 'untagged', + 'shop', + 'shop-case', + 'prefix', + 'paid-shop', + ]); + const firstPage = await store.listFeed({ + limit: 1, + mode: 'all', + cursor: null, + staffAccountIds: emptyStaff, + hashtag: '21GiftsShop', + }); + expect(firstPage.map((row) => row.id)).toEqual(['shop']); + const last = firstPage[0]; + if (last === undefined) { + throw new Error('expected last'); + } + const secondPage = await store.listFeed({ + limit: 1, + mode: 'all', + cursor: { k: 't', c: last.createdAt, i: last.id }, + staffAccountIds: emptyStaff, + hashtag: '21GiftsShop', + }); + expect(secondPage.map((row) => row.id)).toEqual(['shop-case']); + const unpaidTagged = await store.listFeed({ + limit: 10, + mode: 'unpaid', + cursor: null, + staffAccountIds: emptyStaff, + hashtag: '21GiftsShop', + }); + expect(unpaidTagged.map((row) => row.id)).toEqual(['shop', 'shop-case']); + }); + it('listFeed replyCount includes zapper children like listLatest', async () => { const store = new InMemoryMessageStore([EARLY]); await store.create({ @@ -4215,6 +4320,50 @@ describe('PostgresMessageStore', () => { expect(mapped[0]?.replyCount).toBe(0); }); + it('listFeed SQL filters by hashtag token', async () => { + const sql = new MockSql(); + sql.nextRows = []; + const store = new PostgresMessageStore(sql); + const staff = new Set(['staff-1']); + await store.listFeed({ + limit: 10, + mode: 'all', + cursor: null, + staffAccountIds: staff, + hashtag: '21GiftsShop', + }); + await store.listFeed({ + limit: 10, + mode: 'unpaid', + cursor: null, + staffAccountIds: staff, + hashtag: '21GiftsShop', + }); + await store.listFeed({ + limit: 10, + mode: 'all', + cursor: null, + staffAccountIds: staff, + }); + await store.listFeed({ + limit: 10, + mode: 'all', + cursor: null, + staffAccountIds: staff, + hashtag: '', + }); + const tagged = sql.queries[0]; + expect(tagged?.text).toMatch(/text ~\*/); + expect(tagged?.text).toMatch(/parent_id IS NULL/); + expect(tagged?.params).toContain('#21giftsshop([^a-z0-9_]|$)'); + const unpaidTagged = sql.queries[1]; + expect(unpaidTagged?.text).toMatch(/sats = 0/); + expect(unpaidTagged?.text).toMatch(/text ~\*/); + expect(unpaidTagged?.params).toContain('#21giftsshop([^a-z0-9_]|$)'); + expect(sql.queries[2]?.text).not.toMatch(/text ~\*/); + expect(sql.queries[3]?.text).not.toMatch(/text ~\*/); + }); + it('propagates listFeed query errors', async () => { const sql = new MockSql(); sql.queryError = new Error('feed boom'); diff --git a/src/__tests__/routes/conversations.test.ts b/src/__tests__/routes/conversations.test.ts index e3d8e3f3..3da9b529 100644 --- a/src/__tests__/routes/conversations.test.ts +++ b/src/__tests__/routes/conversations.test.ts @@ -5,10 +5,15 @@ import { GIFT_INVOICE_MAX_MSAT } from '@/lib/config'; import { CONVERSATION_LIST_LIMIT } from '@/lib/conversation'; import { InMemoryConversationStore } from '@/lib/conversation-store'; import type { FetchFn } from '@/lib/lnurlp'; -import { unsignedNostrDefaults } from '@/lib/message'; +import { + decodeMessageFeedCursor, + encodeMessageFeedCursor, + unsignedNostrDefaults, +} from '@/lib/message'; import { InMemoryMessageStore, type MessageStore } from '@/lib/message-store'; import { InvoiceRateLimiter } from '@/lib/nostr/rate-limit'; import { InMemoryPushStore } from '@/lib/push-store'; +import { InMemoryFundingStore } from '@/lib/funding-store'; import type { SpendPing } from '@/lib/spend-ping'; import { conversationRoutes } from '@/routes/conversations'; @@ -40,6 +45,16 @@ async function flushMicrotasks(): Promise { const AUTH = { authorization: 'Bearer tok' }; const NOTE_ID = '00000000-0000-4000-8000-000000000001'; const LIVING_ROOM_POST_ID = '00000000-0000-4000-8000-0000000000aa'; +const JPEG = { + contentType: 'image/jpeg' as const, + bytes: new Uint8Array([0xff, 0xd8, 0xff, 0xd9]), +}; +const JPEG2 = { + contentType: 'image/jpeg' as const, + bytes: new Uint8Array([0xff, 0xd8, 0xff, 0x00]), +}; +const JPEG_B64 = Buffer.from(JPEG.bytes).toString('base64'); +const JPEG2_B64 = Buffer.from(JPEG2.bytes).toString('base64'); function livingRoomStore(createdAt: Date = new Date(now())): InMemoryMessageStore { return new InMemoryMessageStore([ @@ -55,11 +70,30 @@ function livingRoomStore(createdAt: Date = new Date(now())): InMemoryMessageStor ]); } +function admittedFunding(accountId = 'acc'): InMemoryFundingStore { + return new InMemoryFundingStore([ + { + accountId, + status: 'admitted', + appliedAt: 1, + decidedAt: 1, + decidedBy: 'staff', + trialUtcDate: null, + admittedAt: 1, + note: null, + }, + ]); +} + function mount( authStore: InMemoryAuthStore, conversations = new InMemoryConversationStore(), messages = new InMemoryMessageStore(), - extra: { spendPing?: SpendPing; pushStore?: InMemoryPushStore } = {}, + extra: { + spendPing?: SpendPing; + pushStore?: InMemoryPushStore; + fundingStore?: InMemoryFundingStore; + } = {}, ): Hono { return new Hono().route( '/conversations', @@ -70,6 +104,7 @@ function mount( now, ...(extra.spendPing === undefined ? {} : { spendPing: extra.spendPing }), ...(extra.pushStore === undefined ? {} : { pushStore: extra.pushStore }), + ...(extra.fundingStore === undefined ? {} : { fundingStore: extra.fundingStore }), }), ); } @@ -1406,6 +1441,129 @@ describe('GET /conversations/:id', () => { expect(body.messages[0]).not.toHaveProperty('senderAccountId'); }); + it('returns the newest 200 messages oldest-first by default with an older cursor', async () => { + const auth = await seeded(); + await withOther(auth); + const conversations = new InMemoryConversationStore(); + const thread = await conversations.openMemberMember('acc', 'other', new Date(now())); + const ids: string[] = []; + for (let i = 0; i <= CONVERSATION_LIST_LIMIT; i += 1) { + const id = `00000000-0000-4000-8000-${i.toString(16).padStart(12, '0')}`; + ids.push(id); + await conversations.appendMessage({ + id, + conversationId: thread.id, + text: `message ${i}`, + createdAt: new Date(now() + i), + senderAccountId: 'acc', + senderPubkey: null, + name: 'Ada', + sats: 0, + eventId: null, + nostrPublishState: 'pending', + nostrEvent: null, + claimedUntil: null, + }); + } + + const res = await mount(auth, conversations).request(`/conversations/${thread.id}`, { + headers: AUTH, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + messages: Array<{ id: string }>; + nextCursor?: string; + }; + expect(body.messages.map((row) => row.id)).toEqual(ids.slice(1)); + expect(body.nextCursor).toBeDefined(); + expect(decodeMessageFeedCursor(body.nextCursor ?? '')).toEqual({ + k: 't', + c: new Date(now() + 1).toISOString(), + i: ids[1], + }); + }); + + it('pages older messages from the oldest row of the newest page', async () => { + const auth = await seeded(); + await withOther(auth); + const conversations = new InMemoryConversationStore(); + const thread = await conversations.openMemberMember('acc', 'other', new Date(now())); + const ids = [ + '00000000-0000-4000-8000-000000000011', + '00000000-0000-4000-8000-000000000012', + '00000000-0000-4000-8000-000000000013', + ]; + for (const [i, id] of ids.entries()) { + await conversations.appendMessage({ + id, + conversationId: thread.id, + text: `message ${i}`, + createdAt: new Date(now() + i), + senderAccountId: 'acc', + senderPubkey: null, + name: 'Ada', + sats: 0, + eventId: null, + nostrPublishState: 'pending', + nostrEvent: null, + claimedUntil: null, + }); + } + + const first = await mount(auth, conversations).request(`/conversations/${thread.id}?limit=2`, { + headers: AUTH, + }); + expect(first.status).toBe(200); + const firstBody = (await first.json()) as { + messages: Array<{ id: string }>; + nextCursor?: string; + }; + expect(firstBody.messages.map((row) => row.id)).toEqual(ids.slice(1)); + expect(firstBody.nextCursor).toBeDefined(); + + const second = await mount(auth, conversations).request( + `/conversations/${thread.id}?limit=2&cursor=${encodeURIComponent(firstBody.nextCursor ?? '')}`, + { headers: AUTH }, + ); + expect(second.status).toBe(200); + const secondBody = (await second.json()) as { + messages: Array<{ id: string }>; + nextCursor?: string; + }; + expect(secondBody.messages.map((row) => row.id)).toEqual(ids.slice(0, 1)); + expect(secondBody).not.toHaveProperty('nextCursor'); + }); + + it.each(['0', '201', 'abc'])('returns 400 for invalid limit %s', async (limit) => { + const res = await mount(await seeded()).request(`/conversations/${NOTE_ID}?limit=${limit}`, { + headers: AUTH, + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Invalid limit' }); + }); + + it.each([ + '%%%', + encodeMessageFeedCursor({ + k: 's', + s: 21, + c: new Date(now()).toISOString(), + i: NOTE_ID, + }), + encodeMessageFeedCursor({ + k: 't', + c: new Date(now()).toISOString(), + i: 'not-a-uuid', + }), + ])('returns 400 for an invalid cursor', async (cursor) => { + const res = await mount(await seeded()).request( + `/conversations/${NOTE_ID}?cursor=${encodeURIComponent(cursor)}`, + { headers: AUTH }, + ); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Invalid cursor' }); + }); + it('sets fromMe false for Damus inbound without a sender account', async () => { const auth = await seeded(); const conversations = new InMemoryConversationStore(); @@ -1575,7 +1733,54 @@ describe('POST /conversations/:id', () => { body: 'not json', }); expect(res.status).toBe(400); - expect(await res.json()).toEqual({ error: 'Expected a JSON body with a "text" string' }); + expect(await res.json()).toEqual({ error: 'Expected a JSON body with text and/or photo' }); + }); + + it('returns 400 when a member_member thread includes a photo', async () => { + const auth = await seeded(); + await withOther(auth); + const conversations = new InMemoryConversationStore(); + const thread = await conversations.openMemberMember('acc', 'other', new Date(now())); + const res = await mount(auth, conversations).request(`/conversations/${thread.id}`, { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ + photo: { contentType: 'image/jpeg', data: JPEG_B64 }, + }), + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Photos are only allowed in the Moderators group' }); + }); + + it('returns 400 when a member_platform thread includes a photo', async () => { + const auth = await seeded(); + await withPlatform(auth); + const conversations = new InMemoryConversationStore(); + const thread = await conversations.openMemberPlatform('acc', 'plat', new Date(now())); + const res = await mount(auth, conversations).request(`/conversations/${thread.id}`, { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ + photos: [{ contentType: 'image/jpeg', data: JPEG_B64 }], + }), + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Photos are only allowed in the Moderators group' }); + }); + + it('returns 400 when a member_damus thread includes a photo', async () => { + const auth = await seeded(); + const conversations = new InMemoryConversationStore(); + const thread = await conversations.openMemberDamus('acc', 'aa'.repeat(32), new Date(now())); + const res = await mount(auth, conversations).request(`/conversations/${thread.id}`, { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ + photo: { contentType: 'image/jpeg', data: JPEG_B64 }, + }), + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Photos are only allowed in the Moderators group' }); }); it('returns 400 for an empty text string', async () => { @@ -2352,6 +2557,35 @@ describe('moderator_group', () => { }); it('pings spend once with kind moderator when a Lightning Address is set', async () => { + const auth = await seeded('moderator'); + await withPlatform(auth); + const existing = await auth.getAccount('acc'); + expect(existing).toBeDefined(); + if (existing === undefined) { + throw new Error('expected account'); + } + await auth.updateAccount({ + ...existing, + lightningAddress: 'ada@walletofsatoshi.com', + }); + const conversations = new InMemoryConversationStore(); + const thread = await conversations.ensureModeratorGroup('plat', new Date(now())); + const spendPing = { ping: vi.fn(async () => undefined) }; + const res = await mount(auth, conversations, livingRoomStore(), { + spendPing, + fundingStore: admittedFunding(), + }).request(`/conversations/${thread.id}`, { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'hello mods' }), + }); + expect(res.status).toBe(200); + const created = (await res.json()) as { id: string }; + expect(spendPing.ping).toHaveBeenCalledTimes(1); + expect(spendPing.ping).toHaveBeenCalledWith('ada@walletofsatoshi.com', created.id, 'moderator'); + }); + + it('does not ping when the moderator is not funding-eligible', async () => { const auth = await seeded('moderator'); await withPlatform(auth); const existing = await auth.getAccount('acc'); @@ -2375,9 +2609,12 @@ describe('moderator_group', () => { }, ); expect(res.status).toBe(200); - const created = (await res.json()) as { id: string }; - expect(spendPing.ping).toHaveBeenCalledTimes(1); - expect(spendPing.ping).toHaveBeenCalledWith('ada@walletofsatoshi.com', created.id, 'moderator'); + expect(spendPing.ping).not.toHaveBeenCalled(); + expect( + parsedEvents(warn).some( + (e) => e['event'] === 'spend.ping.skipped' && e['reason'] === 'not_eligible', + ), + ).toBe(true); }); it('does not ping when the moderator has no living-room post today', async () => { @@ -2492,14 +2729,14 @@ describe('moderator_group', () => { throw new Error('ping boom'); }), }; - const res = await mount(auth, conversations, livingRoomStore(), { spendPing }).request( - `/conversations/${thread.id}`, - { - method: 'POST', - headers: { ...AUTH, 'content-type': 'application/json' }, - body: JSON.stringify({ text: 'hello mods' }), - }, - ); + const res = await mount(auth, conversations, livingRoomStore(), { + spendPing, + fundingStore: admittedFunding(), + }).request(`/conversations/${thread.id}`, { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'hello mods' }), + }); expect(res.status).toBe(200); expect(spendPing.ping).toHaveBeenCalledTimes(1); }); @@ -2581,11 +2818,509 @@ describe('moderator_group', () => { }, ); expect(res.status).toBe(400); - expect(await res.json()).toEqual({ error: 'Text must be 1–500 characters' }); + expect(await res.json()).toEqual({ + error: 'Text must be 1–500 characters or include a photo', + }); expect(spendPing.ping).not.toHaveBeenCalled(); }); }); +describe('moderator-group photos', () => { + it('POST text only is hasPhoto false photoCount 0', async () => { + const auth = await seeded('moderator'); + await withPlatform(auth); + const conversations = new InMemoryConversationStore(); + const thread = await conversations.ensureModeratorGroup('plat', new Date(now())); + const res = await mount(auth, conversations).request(`/conversations/${thread.id}`, { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'hello mods' }), + }); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ hasPhoto: false, photoCount: 0 }); + }); + + it('POST photo with no text is 200 hasPhoto true photoCount 1', async () => { + const auth = await seeded('moderator'); + await withPlatform(auth); + const conversations = new InMemoryConversationStore(); + const thread = await conversations.ensureModeratorGroup('plat', new Date(now())); + const res = await mount(auth, conversations).request(`/conversations/${thread.id}`, { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ + photo: { contentType: 'image/jpeg', data: JPEG_B64 }, + }), + }); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ hasPhoto: true, photoCount: 1, text: '' }); + const listed = await conversations.listMessages(thread.id, 10); + expect(listed[0]?.nostrPublishState).toBe('skipped'); + expect(listed[0]?.eventId).toBeNull(); + }); + + it('POST photos array of 10 is 200 photoCount 10', async () => { + const auth = await seeded('moderator'); + await withPlatform(auth); + const conversations = new InMemoryConversationStore(); + const thread = await conversations.ensureModeratorGroup('plat', new Date(now())); + const res = await mount(auth, conversations).request(`/conversations/${thread.id}`, { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ + photos: Array.from({ length: 10 }, () => ({ + contentType: 'image/jpeg', + data: JPEG_B64, + })), + }), + }); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ hasPhoto: true, photoCount: 10 }); + const listed = await conversations.listMessages(thread.id, 10); + expect(listed[0]?.nostrPublishState).toBe('skipped'); + expect(listed[0]?.eventId).toBeNull(); + }); + + it('POST 11 photos is 400 At most 10 photos', async () => { + const auth = await seeded('moderator'); + await withPlatform(auth); + const conversations = new InMemoryConversationStore(); + const thread = await conversations.ensureModeratorGroup('plat', new Date(now())); + const res = await mount(auth, conversations).request(`/conversations/${thread.id}`, { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ + photos: Array.from({ length: 11 }, () => ({ + contentType: 'image/jpeg', + data: JPEG_B64, + })), + }), + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'At most 10 photos' }); + }); + + it('POST video without text or photo is 400', async () => { + const auth = await seeded('moderator'); + await withPlatform(auth); + const conversations = new InMemoryConversationStore(); + const thread = await conversations.ensureModeratorGroup('plat', new Date(now())); + const res = await mount(auth, conversations).request(`/conversations/${thread.id}`, { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ video: { contentType: 'video/mp4', data: 'AAAA' } }), + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Expected a JSON body with text and/or photo' }); + }); + + it('POST text plus video is 400 and does not persist', async () => { + const auth = await seeded('moderator'); + await withPlatform(auth); + const conversations = new InMemoryConversationStore(); + const thread = await conversations.ensureModeratorGroup('plat', new Date(now())); + const res = await mount(auth, conversations).request(`/conversations/${thread.id}`, { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ + text: 'caption', + video: { contentType: 'video/mp4', data: 'AAAA' }, + }), + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Expected a JSON body with text and/or photo' }); + expect(await conversations.listMessages(thread.id, 10)).toEqual([]); + }); + + it('POST photo plus video is 400 and does not persist', async () => { + const auth = await seeded('moderator'); + await withPlatform(auth); + const conversations = new InMemoryConversationStore(); + const thread = await conversations.ensureModeratorGroup('plat', new Date(now())); + const res = await mount(auth, conversations).request(`/conversations/${thread.id}`, { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ + photo: { contentType: 'image/jpeg', data: JPEG_B64 }, + video: { contentType: 'video/mp4', data: 'AAAA' }, + }), + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Expected a JSON body with text and/or photo' }); + expect(await conversations.listMessages(thread.id, 10)).toEqual([]); + }); + + it('POST invalid still is 400', async () => { + const auth = await seeded('moderator'); + await withPlatform(auth); + const conversations = new InMemoryConversationStore(); + const thread = await conversations.ensureModeratorGroup('plat', new Date(now())); + const res = await mount(auth, conversations).request(`/conversations/${thread.id}`, { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ + photo: { contentType: 'image/jpeg', data: 'nope' }, + }), + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: 'Photo must be a JPEG, PNG, or WebP under 1 MiB', + }); + }); + + it('POST invalid still in photos is 400', async () => { + const auth = await seeded('moderator'); + await withPlatform(auth); + const conversations = new InMemoryConversationStore(); + const thread = await conversations.ensureModeratorGroup('plat', new Date(now())); + const res = await mount(auth, conversations).request(`/conversations/${thread.id}`, { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ + photos: [ + { contentType: 'image/jpeg', data: JPEG_B64 }, + { contentType: 'image/jpeg', data: 'nope' }, + ], + }), + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: 'Photo must be a JPEG, PNG, or WebP under 1 MiB', + }); + }); + + it('POST empty text without a photo is 400', async () => { + const auth = await seeded('moderator'); + await withPlatform(auth); + const conversations = new InMemoryConversationStore(); + const thread = await conversations.ensureModeratorGroup('plat', new Date(now())); + const res = await mount(auth, conversations).request(`/conversations/${thread.id}`, { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ text: '' }), + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: 'Text must be 1–500 characters or include a photo', + }); + }); + + it('GET photo 0 as moderator returns JPEG bytes', async () => { + const auth = await seeded('moderator'); + await withPlatform(auth); + const conversations = new InMemoryConversationStore(); + const thread = await conversations.ensureModeratorGroup('plat', new Date(now())); + const created = (await ( + await mount(auth, conversations).request(`/conversations/${thread.id}`, { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ + photo: { contentType: 'image/jpeg', data: JPEG_B64 }, + }), + }) + ).json()) as { id: string }; + const res = await mount(auth, conversations).request( + `/conversations/${thread.id}/messages/${created.id}/photo`, + { headers: AUTH }, + ); + expect(res.status).toBe(200); + expect(res.headers.get('cache-control')).toBe('private, no-store'); + expect(res.headers.get('access-control-allow-origin')).toBeNull(); + expect(new Uint8Array(await res.arrayBuffer())).toEqual(JPEG.bytes); + }); + + it('GET photo as moderator without a platform account returns JPEG bytes', async () => { + const auth = await seeded('moderator'); + const conversations = new InMemoryConversationStore(); + const thread = await conversations.ensureModeratorGroup('plat', new Date(now())); + const created = await conversations.appendMessage( + { + id: NOTE_ID, + conversationId: thread.id, + text: '', + createdAt: new Date(now()), + senderAccountId: 'acc', + senderPubkey: null, + name: 'Ada', + sats: 0, + eventId: null, + nostrPublishState: 'skipped', + nostrEvent: null, + claimedUntil: null, + }, + JPEG, + ); + const res = await mount(auth, conversations).request( + `/conversations/${thread.id}/messages/${created.id}/photo`, + { headers: AUTH }, + ); + expect(res.status).toBe(200); + expect(new Uint8Array(await res.arrayBuffer())).toEqual(JPEG.bytes); + }); + + it('GET photo/1.jpg after two stills returns JPEG2', async () => { + const auth = await seeded('moderator'); + await withPlatform(auth); + const conversations = new InMemoryConversationStore(); + const thread = await conversations.ensureModeratorGroup('plat', new Date(now())); + const created = (await ( + await mount(auth, conversations).request(`/conversations/${thread.id}`, { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ + photos: [ + { contentType: 'image/jpeg', data: JPEG_B64 }, + { contentType: 'image/jpeg', data: JPEG2_B64 }, + ], + }), + }) + ).json()) as { id: string }; + const res = await mount(auth, conversations).request( + `/conversations/${thread.id}/messages/${created.id}/photo/1.jpg`, + { headers: AUTH }, + ); + expect(res.status).toBe(200); + expect(res.headers.get('cache-control')).toBe('private, no-store'); + expect(res.headers.get('access-control-allow-origin')).toBeNull(); + expect(new Uint8Array(await res.arrayBuffer())).toEqual(JPEG2.bytes); + }); + + it('GET photo with a non-UUID message id is 404 Photo not found', async () => { + const auth = await seeded('moderator'); + await withPlatform(auth); + const conversations = new InMemoryConversationStore(); + const thread = await conversations.ensureModeratorGroup('plat', new Date(now())); + const res = await mount(auth, conversations).request( + `/conversations/${thread.id}/messages/not-a-uuid/photo`, + { headers: AUTH }, + ); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'Photo not found' }); + }); + + it('GET photo for an unknown conversation UUID is 404 Not found', async () => { + const auth = await seeded('moderator'); + await withPlatform(auth); + const res = await mount(auth, new InMemoryConversationStore()).request( + `/conversations/${NOTE_ID}/messages/${NOTE_ID}/photo`, + { headers: AUTH }, + ); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'Not found' }); + }); + + it('GET photo with a non-UUID conversation id is 404 Not found', async () => { + const auth = await seeded('moderator'); + await withPlatform(auth); + const res = await mount(auth, new InMemoryConversationStore()).request( + `/conversations/not-a-uuid/messages/${NOTE_ID}/photo`, + { headers: AUTH }, + ); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'Not found' }); + }); + + it('GET photo missing still is 404 Photo not found', async () => { + const auth = await seeded('moderator'); + await withPlatform(auth); + const conversations = new InMemoryConversationStore(); + const thread = await conversations.ensureModeratorGroup('plat', new Date(now())); + const created = await conversations.appendMessage({ + id: NOTE_ID, + conversationId: thread.id, + text: 'no still', + createdAt: new Date(now()), + senderAccountId: 'acc', + senderPubkey: null, + name: 'Ada', + sats: 0, + eventId: null, + nostrPublishState: 'skipped', + nostrEvent: null, + claimedUntil: null, + }); + const res = await mount(auth, conversations).request( + `/conversations/${thread.id}/messages/${created.id}/photo`, + { headers: AUTH }, + ); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'Photo not found' }); + }); + + it('GET photo for a message in another thread is 404 Photo not found', async () => { + const auth = await seeded('moderator'); + await withPlatform(auth); + const conversations = new InMemoryConversationStore(); + const group = await conversations.ensureModeratorGroup('plat', new Date(now())); + const other = await conversations.openMemberMember('acc', 'bob', new Date(now())); + const created = await conversations.appendMessage({ + id: NOTE_ID, + conversationId: other.id, + text: 'elsewhere', + createdAt: new Date(now()), + senderAccountId: 'acc', + senderPubkey: null, + name: 'Ada', + sats: 0, + eventId: null, + nostrPublishState: 'skipped', + nostrEvent: null, + claimedUntil: null, + }); + const res = await mount(auth, conversations).request( + `/conversations/${group.id}/messages/${created.id}/photo`, + { headers: AUTH }, + ); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'Photo not found' }); + }); + + it('GET photo without bearer is 401', async () => { + const auth = await seeded('moderator'); + await withPlatform(auth); + const conversations = new InMemoryConversationStore(); + const thread = await conversations.ensureModeratorGroup('plat', new Date(now())); + const res = await mount(auth, conversations).request( + `/conversations/${thread.id}/messages/${NOTE_ID}/photo`, + ); + expect(res.status).toBe(401); + const extra = await mount(auth, conversations).request( + `/conversations/${thread.id}/messages/${NOTE_ID}/photo/1.jpg`, + ); + expect(extra.status).toBe(401); + }); + + it('GET photo as a basis non-member is 404 Not found', async () => { + const mod = await seeded('moderator'); + await withPlatform(mod); + const conversations = new InMemoryConversationStore(); + const thread = await conversations.ensureModeratorGroup('plat', new Date(now())); + const created = await conversations.appendMessage( + { + id: NOTE_ID, + conversationId: thread.id, + text: '', + createdAt: new Date(now()), + senderAccountId: 'acc', + senderPubkey: null, + name: 'Ada', + sats: 0, + eventId: null, + nostrPublishState: 'skipped', + nostrEvent: null, + claimedUntil: null, + }, + JPEG, + ); + const basis = await seeded(); + await withPlatform(basis); + const res = await mount(basis, conversations).request( + `/conversations/${thread.id}/messages/${created.id}/photo`, + { headers: AUTH }, + ); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'Not found' }); + }); + + it('GET photo bad file is 404 Photo not found', async () => { + const auth = await seeded('moderator'); + await withPlatform(auth); + const conversations = new InMemoryConversationStore(); + const thread = await conversations.ensureModeratorGroup('plat', new Date(now())); + const created = await conversations.appendMessage( + { + id: NOTE_ID, + conversationId: thread.id, + text: '', + createdAt: new Date(now()), + senderAccountId: 'acc', + senderPubkey: null, + name: 'Ada', + sats: 0, + eventId: null, + nostrPublishState: 'skipped', + nostrEvent: null, + claimedUntil: null, + }, + JPEG, + ); + const app = mount(auth, conversations); + for (const file of ['0.jpg', '10.jpg', 'foo.png']) { + const res = await app.request( + `/conversations/${thread.id}/messages/${created.id}/photo/${file}`, + { headers: AUTH }, + ); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'Photo not found' }); + } + }); + + it('GET photo 503 when getPhoto throws', async () => { + const auth = await seeded('moderator'); + await withPlatform(auth); + const conversations = new InMemoryConversationStore(); + const thread = await conversations.ensureModeratorGroup('plat', new Date(now())); + const created = await conversations.appendMessage( + { + id: NOTE_ID, + conversationId: thread.id, + text: '', + createdAt: new Date(now()), + senderAccountId: 'acc', + senderPubkey: null, + name: 'Ada', + sats: 0, + eventId: null, + nostrPublishState: 'skipped', + nostrEvent: null, + claimedUntil: null, + }, + JPEG, + ); + conversations.getPhoto = async () => { + throw new Error('boom'); + }; + const res = await mount(auth, conversations).request( + `/conversations/${thread.id}/messages/${created.id}/photo`, + { headers: AUTH }, + ); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ error: 'Conversations are unavailable' }); + expect(parsedEvents(warn).some((e) => e['event'] === 'conversations.photo.failed')).toBe(true); + }); + + it('pings spend once with kind moderator for a photo-only post', async () => { + const auth = await seeded('moderator'); + await withPlatform(auth); + const existing = await auth.getAccount('acc'); + expect(existing).toBeDefined(); + if (existing === undefined) { + throw new Error('expected account'); + } + await auth.updateAccount({ + ...existing, + lightningAddress: 'ada@walletofsatoshi.com', + }); + const conversations = new InMemoryConversationStore(); + const thread = await conversations.ensureModeratorGroup('plat', new Date(now())); + const spendPing = { ping: vi.fn(async () => undefined) }; + const res = await mount(auth, conversations, livingRoomStore(), { + spendPing, + fundingStore: admittedFunding(), + }).request(`/conversations/${thread.id}`, { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ + photo: { contentType: 'image/jpeg', data: JPEG_B64 }, + }), + }); + expect(res.status).toBe(200); + const created = (await res.json()) as { id: string }; + expect(spendPing.ping).toHaveBeenCalledTimes(1); + expect(spendPing.ping).toHaveBeenCalledWith('ada@walletofsatoshi.com', created.id, 'moderator'); + }); +}); + describe('POST /conversations/:id/invoice', () => { it('returns 401 without a session', async () => { const res = await mount(await seeded()).request( @@ -3451,7 +4186,7 @@ describe('GET /conversations/:id?sinceMessageId=', () => { expect(body.messages.some((row) => row.id === giftId)).toBe(true); }); - it('unblocks when the gift id is outside the oldest list window', async () => { + it('returns a newest gift immediately and pages to the remaining older row', async () => { const auth = await seeded(); await withOther(auth); const conversations = new InMemoryConversationStore(); @@ -3509,8 +4244,26 @@ describe('GET /conversations/:id?sinceMessageId=', () => { }); expect(res.status).toBe(200); expect(slept).toBe(0); - const body = (await res.json()) as { messages: Array<{ id: string }> }; + const body = (await res.json()) as { + messages: Array<{ id: string }>; + nextCursor?: string; + }; expect(body.messages).toHaveLength(CONVERSATION_LIST_LIMIT); - expect(body.messages.some((row) => row.id === giftId)).toBe(false); + expect(body.messages.some((row) => row.id === giftId)).toBe(true); + expect(body.nextCursor).toBeDefined(); + + const older = await app.request( + `/conversations/${thread.id}?cursor=${encodeURIComponent(body.nextCursor ?? '')}`, + { headers: AUTH }, + ); + expect(older.status).toBe(200); + const olderBody = (await older.json()) as { + messages: Array<{ id: string }>; + nextCursor?: string; + }; + expect(olderBody.messages.map((row) => row.id)).toEqual([ + '00000000-0000-4000-8000-000000000000', + ]); + expect(olderBody).not.toHaveProperty('nextCursor'); }); }); diff --git a/src/__tests__/routes/funding.test.ts b/src/__tests__/routes/funding.test.ts new file mode 100644 index 00000000..b0757605 --- /dev/null +++ b/src/__tests__/routes/funding.test.ts @@ -0,0 +1,953 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { Hono } from 'hono'; +import { InMemoryAuthStore, type Account } from '@/lib/auth/store'; +import type { FundingGrant } from '@/lib/funding'; +import { InMemoryFundingStore, type FundingStore } from '@/lib/funding-store'; +import { unsignedNostrDefaults } from '@/lib/message'; +import { InMemoryMessageStore } from '@/lib/message-store'; +import { removeForumVideo, writeForumVideo } from '@/lib/video'; +import { fundingRoutes } from '@/routes/funding'; + +const now = (): number => 1_700_000_000_000; +const TODAY = '2023-11-14'; +const YESTERDAY = '2023-11-13'; +const FOUNDER = '11111111-1111-4111-8111-111111111111'; +const MOD = '22222222-2222-4222-8222-222222222222'; +const SUBJECT = '33333333-3333-4333-8333-333333333333'; +const OTHER = '44444444-4444-4444-8444-444444444444'; +const VERIFIED = '55555555-5555-4555-8555-555555555555'; + +function parsedEvents(warn: ReturnType): Array> { + return warn.mock.calls + .map((call) => call[0]) + .filter((arg): arg is string => typeof arg === 'string' && arg.startsWith('{')) + .map((arg) => JSON.parse(arg) as Record); +} + +function account(partial: Pick & Partial): Account { + return { + linkingKey: null, + name: partial.name ?? partial.id, + lightningAddress: null, + lightningAddressVerified: false, + forumLawsDismissed: false, + location: null, + viewKey: `${partial.id.replace(/-/g, '')}${'a'.repeat(64)}`.slice(0, 64), + createdAt: 1, + rulesAgreedAt: now(), + ...partial, + }; +} + +function grant( + partial: Pick & Partial, +): FundingGrant { + return { + appliedAt: now() - 60_000, + decidedAt: null, + decidedBy: null, + trialUtcDate: null, + admittedAt: null, + note: null, + ...partial, + }; +} + +async function staffed( + extras: Account[] = [], +): Promise<{ authStore: InMemoryAuthStore; fundingStore: InMemoryFundingStore }> { + const authStore = new InMemoryAuthStore(); + await authStore.createAccount(account({ id: FOUNDER, role: 'founder', name: 'Founder' })); + await authStore.createAccount(account({ id: MOD, role: 'moderator', name: 'Mod' })); + await authStore.createAccount(account({ id: OTHER, role: 'basis', name: 'Other' })); + await authStore.createAccount(account({ id: VERIFIED, role: 'verified', name: 'Ada' })); + await authStore.createSession({ token: 'founder', accountId: FOUNDER, createdAt: now() }); + await authStore.createSession({ token: 'mod', accountId: MOD, createdAt: now() }); + await authStore.createSession({ token: 'other', accountId: OTHER, createdAt: now() }); + await authStore.createSession({ token: 'verified', accountId: VERIFIED, createdAt: now() }); + for (const extra of extras) { + await authStore.createAccount(extra); + } + return { authStore, fundingStore: new InMemoryFundingStore() }; +} + +function mount( + authStore: InMemoryAuthStore, + fundingStore: FundingStore, + messageStore: InMemoryMessageStore = new InMemoryMessageStore(), +): Hono { + return new Hono().route( + '/funding', + fundingRoutes({ + authStore, + fundingStore, + messageStore, + now, + }), + ); +} + +function post( + app: Hono, + path: string, + token: string | undefined, + body?: unknown, +): Promise { + return Promise.resolve( + app.request(path, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(token === undefined ? {} : { authorization: `Bearer ${token}` }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }), + ); +} + +function get(app: Hono, path: string, token: string | undefined): Promise { + return Promise.resolve( + app.request(path, { + method: 'GET', + headers: token === undefined ? {} : { authorization: `Bearer ${token}` }, + }), + ); +} + +const boomStore: FundingStore = { + getByAccountId: async () => { + throw new Error('boom'); + }, + listGrants: async () => { + throw new Error('boom'); + }, + upsert: async () => { + throw new Error('boom'); + }, + transition: async () => { + throw new Error('boom'); + }, + expireTrialIfUnchanged: async () => { + throw new Error('boom'); + }, +}; + +describe('POST /funding/apply', () => { + let warn: ReturnType; + + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + warn.mockRestore(); + }); + + it('returns 401 without a session', async () => { + const { authStore, fundingStore } = await staffed(); + const res = await post(mount(authStore, fundingStore), '/funding/apply', undefined); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: 'Unauthorized' }); + }); + + it('returns 403 when the caller is basis', async () => { + const { authStore, fundingStore } = await staffed(); + const res = await post(mount(authStore, fundingStore), '/funding/apply', 'other'); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: 'Forbidden' }); + }); + + it('returns 200 and pending funding for a verified caller', async () => { + const { authStore, fundingStore } = await staffed(); + const res = await post(mount(authStore, fundingStore), '/funding/apply', 'verified'); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + funding: { + status: 'pending', + trialUtcDate: null, + admittedAt: null, + reviewedByName: null, + }, + }); + expect(parsedEvents(warn).some((e) => e['event'] === 'funding.applied')).toBe(true); + }); + + it('returns 409 when the CAS apply misses after the status check', async () => { + const { authStore } = await staffed(); + const store: FundingStore = { + getByAccountId: () => Promise.resolve(undefined), + listGrants: () => Promise.resolve([]), + upsert: () => Promise.resolve(grant({ accountId: VERIFIED, status: 'pending' })), + transition: () => Promise.resolve(undefined), + expireTrialIfUnchanged: () => Promise.resolve(undefined), + }; + expect((await post(mount(authStore, store), '/funding/apply', 'verified')).status).toBe(409); + }); + + it('returns 409 when already pending, trial, or admitted', async () => { + const { authStore, fundingStore } = await staffed(); + const app = mount(authStore, fundingStore); + expect((await post(app, '/funding/apply', 'verified')).status).toBe(200); + expect((await post(app, '/funding/apply', 'verified')).status).toBe(409); + expect(await (await post(app, '/funding/apply', 'verified')).json()).toEqual({ + error: 'Conflict', + }); + + await fundingStore.upsert( + grant({ accountId: MOD, status: 'trial', trialUtcDate: TODAY, appliedAt: now() }), + ); + expect((await post(app, '/funding/apply', 'mod')).status).toBe(409); + + await fundingStore.upsert( + grant({ + accountId: FOUNDER, + status: 'admitted', + admittedAt: now(), + appliedAt: now(), + }), + ); + expect((await post(app, '/funding/apply', 'founder')).status).toBe(409); + }); + + it('returns 200 when re-applying after rejected', async () => { + const { authStore, fundingStore } = await staffed(); + await fundingStore.upsert( + grant({ + accountId: VERIFIED, + status: 'rejected', + decidedAt: 1, + decidedBy: FOUNDER, + appliedAt: 1, + }), + ); + const res = await post(mount(authStore, fundingStore), '/funding/apply', 'verified'); + expect(res.status).toBe(200); + expect(((await res.json()) as { funding: { status: string } }).funding.status).toBe('pending'); + const stored = await fundingStore.getByAccountId(VERIFIED); + expect(stored?.appliedAt).toBe(now()); + expect(stored?.decidedAt).toBeNull(); + expect(stored?.decidedBy).toBeNull(); + }); + + it('returns 503 when the store throws', async () => { + const { authStore } = await staffed(); + const res = await post(mount(authStore, boomStore), '/funding/apply', 'verified'); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ error: 'Funding is unavailable' }); + expect(parsedEvents(warn).some((e) => e['event'] === 'funding.write.failed')).toBe(true); + }); +}); + +describe('GET /funding/applications', () => { + let warn: ReturnType; + + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + warn.mockRestore(); + }); + + it('returns 401 without a session', async () => { + const { authStore, fundingStore } = await staffed(); + const res = await get(mount(authStore, fundingStore), '/funding/applications', undefined); + expect(res.status).toBe(401); + }); + + it('returns 403 when the caller is not staff', async () => { + const { authStore, fundingStore } = await staffed(); + const res = await get(mount(authStore, fundingStore), '/funding/applications', 'verified'); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: 'Forbidden' }); + }); + + it('returns an empty list when there are no pending grants', async () => { + const { authStore, fundingStore } = await staffed(); + await fundingStore.upsert(grant({ accountId: VERIFIED, status: 'trial', trialUtcDate: TODAY })); + const res = await get(mount(authStore, fundingStore), '/funding/applications', 'mod'); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ applications: [] }); + }); + + it('returns pending applications oldest appliedAt first and includes an expired trial', async () => { + const { authStore, fundingStore } = await staffed(); + await fundingStore.upsert( + grant({ accountId: VERIFIED, status: 'pending', appliedAt: now() - 2 }), + ); + await fundingStore.upsert( + grant({ + accountId: SUBJECT, + status: 'trial', + trialUtcDate: YESTERDAY, + appliedAt: now() - 1, + }), + ); + await authStore.createAccount(account({ id: SUBJECT, role: 'verified', name: 'Sub' })); + await fundingStore.upsert( + grant({ + accountId: MOD, + status: 'admitted', + admittedAt: now(), + appliedAt: now() - 3, + }), + ); + const ghost = '66666666-6666-4666-8666-666666666666'; + await fundingStore.upsert(grant({ accountId: ghost, status: 'pending', appliedAt: now() })); + const res = await get(mount(authStore, fundingStore), '/funding/applications', 'founder'); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + applications: [ + { accountId: VERIFIED, name: 'Ada', role: 'verified', appliedAt: now() - 2 }, + { accountId: SUBJECT, name: 'Sub', role: 'verified', appliedAt: now() - 1 }, + ], + }); + expect((await fundingStore.getByAccountId(SUBJECT))?.status).toBe('pending'); + expect(parsedEvents(warn).some((e) => e['event'] === 'funding.applications.listed')).toBe(true); + }); + + it('returns 503 when listing throws', async () => { + const { authStore } = await staffed(); + const res = await get(mount(authStore, boomStore), '/funding/applications', 'mod'); + expect(res.status).toBe(503); + expect(parsedEvents(warn).some((e) => e['event'] === 'funding.list.failed')).toBe(true); + }); +}); + +describe('GET /funding/applications/:accountId', () => { + let warn: ReturnType; + + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + warn.mockRestore(); + }); + + it('returns 401 without a session', async () => { + const { authStore, fundingStore } = await staffed(); + const res = await get( + mount(authStore, fundingStore), + `/funding/applications/${VERIFIED}`, + undefined, + ); + expect(res.status).toBe(401); + }); + + it('returns 403 when the caller is not staff', async () => { + const { authStore, fundingStore } = await staffed(); + const res = await get( + mount(authStore, fundingStore), + `/funding/applications/${VERIFIED}`, + 'verified', + ); + expect(res.status).toBe(403); + }); + + it('returns 404 for a non-uuid id', async () => { + const { authStore, fundingStore } = await staffed(); + const res = await get(mount(authStore, fundingStore), '/funding/applications/nope', 'founder'); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'Not found' }); + }); + + it('returns 404 when there is no grant', async () => { + const { authStore, fundingStore } = await staffed(); + const res = await get( + mount(authStore, fundingStore), + `/funding/applications/${VERIFIED}`, + 'founder', + ); + expect(res.status).toBe(404); + }); + + it('returns 404 when the account is missing', async () => { + const { authStore, fundingStore } = await staffed(); + await fundingStore.upsert(grant({ accountId: SUBJECT, status: 'pending' })); + const res = await get( + mount(authStore, fundingStore), + `/funding/applications/${SUBJECT}`, + 'founder', + ); + expect(res.status).toBe(404); + }); + + it('returns account, effective grant, and posts', async () => { + const { authStore, fundingStore } = await staffed(); + await fundingStore.upsert( + grant({ + accountId: VERIFIED, + status: 'trial', + trialUtcDate: YESTERDAY, + appliedAt: 10, + decidedAt: 20, + }), + ); + const postId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + const messageStore = new InMemoryMessageStore([ + { + id: postId, + accountId: VERIFIED, + name: 'Ada', + text: 'hello', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + }, + ]); + const res = await get( + mount(authStore, fundingStore, messageStore), + `/funding/applications/${VERIFIED}`, + 'mod', + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + account: { id: string; name: string; role: string }; + grant: { status: string; appliedAt: number; trialUtcDate: string | null }; + messages: Array<{ id: string; text: string }>; + }; + expect(body.account).toEqual({ + id: VERIFIED, + name: 'Ada', + role: 'verified', + lightningAddress: null, + }); + expect(body.grant.status).toBe('pending'); + expect(body.grant.appliedAt).toBe(10); + expect(body.grant.trialUtcDate).toBeNull(); + expect(body.messages[0]?.id).toBe(postId); + expect(body.messages[0]?.text).toBe('hello'); + }); + + it('marks a post payable when the applicant has a Lightning Address and event id', async () => { + const { authStore, fundingStore } = await staffed(); + const existing = await authStore.getAccount(VERIFIED); + expect(existing).toBeDefined(); + if (existing === undefined) { + throw new Error('expected verified'); + } + await authStore.updateAccount({ + ...existing, + lightningAddress: 'ada@walletofsatoshi.com', + }); + await fundingStore.upsert(grant({ accountId: VERIFIED, status: 'pending' })); + const postId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + const messageStore = new InMemoryMessageStore([ + { + id: postId, + accountId: VERIFIED, + name: 'Ada', + text: 'hello', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + eventId: 'e'.repeat(64), + }, + ]); + const res = await get( + mount(authStore, fundingStore, messageStore), + `/funding/applications/${VERIFIED}`, + 'founder', + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { messages: Array<{ payable?: boolean }> }; + expect(body.messages[0]?.payable).toBe(true); + }); + + it('subtracts missing-file video replies from replyCount', async () => { + const { authStore, fundingStore } = await staffed(); + await fundingStore.upsert(grant({ accountId: VERIFIED, status: 'pending' })); + const parentId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + const childId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + const messageStore = new InMemoryMessageStore([ + { + id: parentId, + accountId: VERIFIED, + name: 'Ada', + text: 'parent', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + }, + { + id: childId, + accountId: VERIFIED, + name: 'Ada', + text: 'gone', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + parentId, + hasVideo: true, + videoContentType: 'video/mp4', + }, + ]); + const res = await get( + mount(authStore, fundingStore, messageStore), + `/funding/applications/${VERIFIED}`, + 'founder', + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { messages: Array<{ id: string; replyCount: number }> }; + expect(body.messages[0]?.id).toBe(parentId); + expect(body.messages[0]?.replyCount).toBe(0); + expect(await messageStore.getById(childId)).toBeUndefined(); + }); + + it('drops a missing-file video post and keeps a present one', async () => { + const { authStore, fundingStore } = await staffed(); + await fundingStore.upsert(grant({ accountId: VERIFIED, status: 'pending' })); + const keptId = '5c5051d3-adba-44f9-a964-9bd0df1ce096'; + const droppedId = '6d6162e4-becb-45fa-b075-ace1ef2df107'; + const bytes = new Uint8Array(32); + bytes.set([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d]); + await writeForumVideo(keptId, { contentType: 'video/mp4', bytes }); + try { + const messageStore = new InMemoryMessageStore([ + { + id: keptId, + accountId: VERIFIED, + name: 'Ada', + text: 'clip', + createdAt: new Date(now()), + ...unsignedNostrDefaults(), + hasPhoto: false, + hasVideo: true, + videoContentType: 'video/mp4', + }, + { + id: droppedId, + accountId: VERIFIED, + name: 'Ada', + text: 'gone', + createdAt: new Date(now() - 1), + ...unsignedNostrDefaults(), + hasPhoto: false, + hasVideo: true, + videoContentType: 'video/mp4', + }, + ]); + const res = await get( + mount(authStore, fundingStore, messageStore), + `/funding/applications/${VERIFIED}`, + 'founder', + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { messages: Array<{ id: string; hasVideo: boolean }> }; + expect(body.messages.map((row) => row.id)).toEqual([keptId]); + expect(body.messages[0]?.hasVideo).toBe(true); + expect(await messageStore.getById(droppedId)).toBeUndefined(); + } finally { + await removeForumVideo(keptId, 'video/mp4'); + } + }); + + it('returns 503 when the store throws', async () => { + const { authStore } = await staffed(); + const res = await get( + mount(authStore, boomStore), + `/funding/applications/${VERIFIED}`, + 'founder', + ); + expect(res.status).toBe(503); + expect(parsedEvents(warn).some((e) => e['event'] === 'funding.list.failed')).toBe(true); + }); +}); + +describe('POST /funding/trial', () => { + let warn: ReturnType; + + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + warn.mockRestore(); + }); + + it('returns 401 without a session', async () => { + const { authStore, fundingStore } = await staffed(); + const res = await post(mount(authStore, fundingStore), '/funding/trial', undefined, { + accountId: VERIFIED, + }); + expect(res.status).toBe(401); + }); + + it('returns 403 when the caller is not staff', async () => { + const { authStore, fundingStore } = await staffed(); + const res = await post(mount(authStore, fundingStore), '/funding/trial', 'verified', { + accountId: VERIFIED, + }); + expect(res.status).toBe(403); + }); + + it('returns 400 for missing JSON', async () => { + const { authStore, fundingStore } = await staffed(); + const res = await mount(authStore, fundingStore).request('/funding/trial', { + method: 'POST', + headers: { authorization: 'Bearer founder' }, + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: 'Expected a JSON body with an "accountId" string', + }); + }); + + it('returns 404 for a non-uuid accountId', async () => { + const { authStore, fundingStore } = await staffed(); + const res = await post(mount(authStore, fundingStore), '/funding/trial', 'founder', { + accountId: 'nope', + }); + expect(res.status).toBe(404); + }); + + it('returns 404 when the subject is missing', async () => { + const { authStore, fundingStore } = await staffed(); + const res = await post(mount(authStore, fundingStore), '/funding/trial', 'founder', { + accountId: SUBJECT, + }); + expect(res.status).toBe(404); + }); + + it('returns 409 when the subject is the caller', async () => { + const { authStore, fundingStore } = await staffed(); + await fundingStore.upsert(grant({ accountId: FOUNDER, status: 'pending' })); + const res = await post(mount(authStore, fundingStore), '/funding/trial', 'founder', { + accountId: FOUNDER, + }); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ error: 'Conflict' }); + }); + + it('returns 409 when the subject is basis or not pending', async () => { + const { authStore, fundingStore } = await staffed(); + await fundingStore.upsert(grant({ accountId: OTHER, status: 'pending' })); + const app = mount(authStore, fundingStore); + expect((await post(app, '/funding/trial', 'founder', { accountId: OTHER })).status).toBe(409); + expect((await post(app, '/funding/trial', 'founder', { accountId: VERIFIED })).status).toBe( + 409, + ); + }); + + it('returns 200 and sets trialUtcDate to today UTC', async () => { + const { authStore, fundingStore } = await staffed(); + await fundingStore.upsert(grant({ accountId: VERIFIED, status: 'pending', appliedAt: 9 })); + const res = await post(mount(authStore, fundingStore), '/funding/trial', 'founder', { + accountId: VERIFIED, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + id: string; + name: string; + role: string; + funding: { status: string; trialUtcDate: string | null; reviewedByName: string | null }; + }; + expect(body).toMatchObject({ id: VERIFIED, name: 'Ada', role: 'verified' }); + expect(body.funding.status).toBe('trial'); + expect(body.funding.trialUtcDate).toBe(TODAY); + expect(body.funding.reviewedByName).toBeNull(); + expect((await fundingStore.getByAccountId(VERIFIED))?.appliedAt).toBe(9); + expect(parsedEvents(warn).some((e) => e['event'] === 'funding.trial')).toBe(true); + }); + + it('returns 503 when upsert throws', async () => { + const { authStore } = await staffed(); + const store: FundingStore = { + getByAccountId: async () => grant({ accountId: VERIFIED, status: 'pending' }), + listGrants: async () => [], + upsert: async () => { + throw new Error('boom'); + }, + transition: async () => { + throw new Error('boom'); + }, + expireTrialIfUnchanged: async () => { + throw new Error('boom'); + }, + }; + const res = await post(mount(authStore, store), '/funding/trial', 'founder', { + accountId: VERIFIED, + }); + expect(res.status).toBe(503); + }); + + it('returns 409 when the CAS trial misses after the status check', async () => { + const { authStore } = await staffed(); + const pending = grant({ accountId: VERIFIED, status: 'pending' }); + const store: FundingStore = { + getByAccountId: () => Promise.resolve(pending), + listGrants: () => Promise.resolve([pending]), + upsert: () => Promise.resolve(pending), + transition: () => Promise.resolve(undefined), + expireTrialIfUnchanged: () => Promise.resolve(undefined), + }; + expect( + (await post(mount(authStore, store), '/funding/trial', 'founder', { accountId: VERIFIED })) + .status, + ).toBe(409); + }); + + it('returns 503 when loading the subject throws', async () => { + const inner = new InMemoryAuthStore(); + await inner.createAccount(account({ id: FOUNDER, role: 'founder', name: 'Founder' })); + await inner.createSession({ token: 'founder', accountId: FOUNDER, createdAt: now() }); + const authStore = new Proxy(inner, { + get(target, prop, receiver) { + if (prop === 'getAccount') { + return async (id: string) => { + if (id === VERIFIED) { + throw new Error('boom'); + } + return target.getAccount(id); + }; + } + const value = Reflect.get(target, prop, receiver) as unknown; + return typeof value === 'function' + ? (value as (...args: never[]) => unknown).bind(target) + : value; + }, + }) as InMemoryAuthStore; + const res = await post( + mount(authStore, new InMemoryFundingStore()), + '/funding/trial', + 'founder', + { + accountId: VERIFIED, + }, + ); + expect(res.status).toBe(503); + }); +}); + +describe('POST /funding/admit', () => { + let warn: ReturnType; + + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + warn.mockRestore(); + }); + + it('returns 401 without a session', async () => { + const { authStore, fundingStore } = await staffed(); + const res = await post(mount(authStore, fundingStore), '/funding/admit', undefined, { + accountId: VERIFIED, + }); + expect(res.status).toBe(401); + }); + + it('returns 409 when admitting self, basis, or a non-pending grant', async () => { + const { authStore, fundingStore } = await staffed(); + await fundingStore.upsert(grant({ accountId: FOUNDER, status: 'pending' })); + await fundingStore.upsert(grant({ accountId: OTHER, status: 'pending' })); + const app = mount(authStore, fundingStore); + expect((await post(app, '/funding/admit', 'founder', { accountId: FOUNDER })).status).toBe(409); + expect((await post(app, '/funding/admit', 'founder', { accountId: OTHER })).status).toBe(409); + expect((await post(app, '/funding/admit', 'founder', { accountId: VERIFIED })).status).toBe( + 409, + ); + }); + + it('returns 409 when the CAS transition misses after the status check', async () => { + const { authStore } = await staffed(); + const pending = grant({ accountId: VERIFIED, status: 'pending' }); + const store: FundingStore = { + getByAccountId: () => Promise.resolve(pending), + listGrants: () => Promise.resolve([pending]), + upsert: () => Promise.resolve(pending), + transition: () => Promise.resolve(undefined), + expireTrialIfUnchanged: () => Promise.resolve(undefined), + }; + const app = mount(authStore, store); + expect((await post(app, '/funding/admit', 'founder', { accountId: VERIFIED })).status).toBe( + 409, + ); + }); + + it('admits from pending and from trial the same day', async () => { + const { authStore, fundingStore } = await staffed(); + await fundingStore.upsert(grant({ accountId: VERIFIED, status: 'pending', appliedAt: 4 })); + const app = mount(authStore, fundingStore); + const pending = await post(app, '/funding/admit', 'founder', { accountId: VERIFIED }); + expect(pending.status).toBe(200); + const admitted = (await pending.json()) as { + funding: { + status: string; + admittedAt: number; + trialUtcDate: string | null; + reviewedByName: string | null; + }; + }; + expect(admitted.funding.status).toBe('admitted'); + expect(admitted.funding.admittedAt).toBe(now()); + expect(admitted.funding.trialUtcDate).toBeNull(); + expect(admitted.funding.reviewedByName).toBe('Founder'); + expect(parsedEvents(warn).some((e) => e['event'] === 'funding.admitted')).toBe(true); + + await fundingStore.upsert( + grant({ + accountId: MOD, + status: 'trial', + trialUtcDate: TODAY, + appliedAt: 5, + }), + ); + const fromTrial = await post(app, '/funding/admit', 'founder', { accountId: MOD }); + expect(fromTrial.status).toBe(200); + expect(((await fromTrial.json()) as { funding: { status: string } }).funding.status).toBe( + 'admitted', + ); + }); + + it('returns 400 for missing JSON and 404 for a missing subject', async () => { + const { authStore, fundingStore } = await staffed(); + const app = mount(authStore, fundingStore); + expect( + ( + await app.request('/funding/admit', { + method: 'POST', + headers: { authorization: 'Bearer founder' }, + }) + ).status, + ).toBe(400); + expect((await post(app, '/funding/admit', 'founder', { accountId: SUBJECT })).status).toBe(404); + }); + + it('returns 503 when upsert throws', async () => { + const { authStore } = await staffed(); + const store: FundingStore = { + getByAccountId: async () => grant({ accountId: VERIFIED, status: 'pending' }), + listGrants: async () => [], + upsert: async () => { + throw new Error('boom'); + }, + transition: async () => { + throw new Error('boom'); + }, + expireTrialIfUnchanged: async () => { + throw new Error('boom'); + }, + }; + const res = await post(mount(authStore, store), '/funding/admit', 'mod', { + accountId: VERIFIED, + }); + expect(res.status).toBe(503); + }); +}); + +describe('POST /funding/reject', () => { + let warn: ReturnType; + + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + warn.mockRestore(); + }); + + it('returns 403 when the caller is not staff', async () => { + const { authStore, fundingStore } = await staffed(); + const res = await post(mount(authStore, fundingStore), '/funding/reject', 'verified', { + accountId: VERIFIED, + }); + expect(res.status).toBe(403); + }); + + it('returns 409 when rejecting self or an admitted grant', async () => { + const { authStore, fundingStore } = await staffed(); + await fundingStore.upsert(grant({ accountId: FOUNDER, status: 'pending' })); + await fundingStore.upsert( + grant({ accountId: VERIFIED, status: 'admitted', admittedAt: now() }), + ); + const app = mount(authStore, fundingStore); + expect((await post(app, '/funding/reject', 'founder', { accountId: FOUNDER })).status).toBe( + 409, + ); + expect((await post(app, '/funding/reject', 'founder', { accountId: VERIFIED })).status).toBe( + 409, + ); + }); + + it('rejects pending and trial and clears trial and admitted', async () => { + const { authStore, fundingStore } = await staffed(); + await fundingStore.upsert(grant({ accountId: VERIFIED, status: 'pending', appliedAt: 8 })); + const app = mount(authStore, fundingStore); + const res = await post(app, '/funding/reject', 'mod', { accountId: VERIFIED }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + funding: { status: string; trialUtcDate: string | null; admittedAt: number | null }; + }; + expect(body.funding.status).toBe('rejected'); + expect(body.funding.trialUtcDate).toBeNull(); + expect(body.funding.admittedAt).toBeNull(); + expect(parsedEvents(warn).some((e) => e['event'] === 'funding.rejected')).toBe(true); + + await fundingStore.upsert( + grant({ + accountId: MOD, + status: 'trial', + trialUtcDate: TODAY, + appliedAt: 7, + }), + ); + expect((await post(app, '/funding/reject', 'founder', { accountId: MOD })).status).toBe(200); + }); + + it('returns 400 for missing JSON and 404 for a missing subject', async () => { + const { authStore, fundingStore } = await staffed(); + const app = mount(authStore, fundingStore); + expect( + ( + await app.request('/funding/reject', { + method: 'POST', + headers: { authorization: 'Bearer founder', 'content-type': 'application/json' }, + body: JSON.stringify({}), + }) + ).status, + ).toBe(400); + expect((await post(app, '/funding/reject', 'founder', { accountId: SUBJECT })).status).toBe( + 404, + ); + }); + + it('returns 503 when upsert throws', async () => { + const { authStore } = await staffed(); + const store: FundingStore = { + getByAccountId: async () => grant({ accountId: VERIFIED, status: 'pending' }), + listGrants: async () => [], + upsert: async () => { + throw new Error('boom'); + }, + transition: async () => { + throw new Error('boom'); + }, + expireTrialIfUnchanged: async () => { + throw new Error('boom'); + }, + }; + const res = await post(mount(authStore, store), '/funding/reject', 'founder', { + accountId: VERIFIED, + }); + expect(res.status).toBe(503); + }); + + it('returns 409 when the CAS reject misses after the status check', async () => { + const { authStore } = await staffed(); + const pending = grant({ accountId: VERIFIED, status: 'pending' }); + const store: FundingStore = { + getByAccountId: () => Promise.resolve(pending), + listGrants: () => Promise.resolve([pending]), + upsert: () => Promise.resolve(pending), + transition: () => Promise.resolve(undefined), + expireTrialIfUnchanged: () => Promise.resolve(undefined), + }; + expect( + (await post(mount(authStore, store), '/funding/reject', 'founder', { accountId: VERIFIED })) + .status, + ).toBe(409); + }); +}); diff --git a/src/__tests__/routes/invoices.test.ts b/src/__tests__/routes/invoices.test.ts index 9288191c..573e5288 100644 --- a/src/__tests__/routes/invoices.test.ts +++ b/src/__tests__/routes/invoices.test.ts @@ -10,7 +10,9 @@ import { InMemoryMessageStore } from '@/lib/message-store'; import { InMemoryNotificationStore } from '@/lib/notification-store'; import { InMemoryPushStore } from '@/lib/push-store'; import { invoiceRoutes } from '@/routes/invoices'; -import { createApp } from '@/server'; +import { createApp as createAppRaw } from '@/server'; +import type { FundingGrant } from '@/lib/funding'; +import { InMemoryFundingStore } from '@/lib/funding-store'; import { decodeBolt11 } from '@/lib/bolt11'; import type { FetchFn } from '@/lib/lnurlp'; @@ -18,6 +20,30 @@ vi.mock('@/lib/bolt11', () => ({ decodeBolt11: vi.fn(), })); +function admittedStore(accountId = 'acc-alice'): InMemoryFundingStore { + return new InMemoryFundingStore([ + { + accountId, + status: 'admitted', + appliedAt: Date.parse('2026-09-01T00:00:00.000Z'), + decidedAt: Date.parse('2026-09-10T08:00:00.000Z'), + decidedBy: 'staff', + trialUtcDate: null, + admittedAt: Date.parse('2026-09-15T18:00:00.000Z'), + note: null, + }, + ]); +} + +function createApp(deps: Parameters[0] = {}): ReturnType { + return createAppRaw({ + fundingStore: admittedStore(), + ...deps, + }); +} + +const spendApp = createApp; +const NOW_MS = Date.parse('2026-09-20T12:00:00.000Z'); const TOKEN = 'spend-secret-token'; const ADDRESS = 'alice@walletofsatoshi.com'; const PR = 'lnbc1issued'; @@ -86,7 +112,7 @@ async function seedPasskeyAccount( await authStore.createAccount({ id: 'acc-alice', linkingKey: null, - role: 'basis', + role: 'verified', name: 'Ada', lightningAddress: address, lightningAddressVerified: true, @@ -108,6 +134,19 @@ async function seedPasskeyAccount( /** * Seed one live non-profile top-level forum row for `accountId` (EARLY-shaped). */ +function expiredTrialGrant(accountId = 'acc-alice'): FundingGrant { + return { + accountId, + status: 'trial', + appliedAt: Date.parse('2026-09-01T00:00:00.000Z'), + decidedAt: Date.parse('2026-09-10T08:00:00.000Z'), + decidedBy: 'staff', + trialUtcDate: '2026-09-19', + admittedAt: null, + note: 'keep', + }; +} + function livePostStore(accountId: string = 'acc-alice'): InMemoryMessageStore { return new InMemoryMessageStore([ { @@ -695,6 +734,27 @@ describe('POST /invoices', () => { expect(parsedEvents(warn).some((e) => e['event'] === 'invoice.passkey_required')).toBe(true); }); + it('returns 403 when the account has a passkey but no funding grant', async () => { + const authStore = new InMemoryAuthStore(); + await seedPasskeyAccount(authStore); + const fetchImpl: FetchFn = async () => { + throw new Error('LNURL must not be called'); + }; + const res = await spendApp({ + spendApiToken: TOKEN, + authStore, + messageStore: livePostStore(), + fetchImpl, + fundingStore: new InMemoryFundingStore(), + }).request( + '/invoices', + auth({ method: 'POST', body: JSON.stringify({ address: ADDRESS, amountMsat: 1000 }) }), + ); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: 'Funding grant required' }); + expect(parsedEvents(warn).some((e) => e['event'] === 'invoice.funding_required')).toBe(true); + }); + it('returns 403 when the account has a passkey but no live forum post', async () => { const authStore = new InMemoryAuthStore(); await seedPasskeyAccount(authStore); @@ -963,7 +1023,7 @@ describe('POST /invoices', () => { await authStore.createAccount({ id: 'acc-alice', linkingKey: null, - role: 'basis', + role: 'verified', name: 'Ada', lightningAddress: ADDRESS, lightningAddressVerified: true, @@ -1473,6 +1533,7 @@ describe('POST /invoices', () => { store: invoiceStore, authStore, messageStore: uuidPostStore(), + fundingStore: admittedStore(), now: () => 1, fetchImpl: happyFetch(), }), @@ -1519,6 +1580,7 @@ describe('POST /invoices', () => { authStore, messageStore: uuidPostStore(), conversationStore, + fundingStore: admittedStore(), now: () => 1, fetchImpl: happyFetch(), }), @@ -2697,3 +2759,134 @@ describe('POST /invoices/proof', () => { ]); }); }); +describe('GET /invoices/eligible', () => { + it('returns 503 when the spend token is not configured', async () => { + const res = await spendApp({ spendApiToken: '' }).request( + `/invoices/eligible?address=${encodeURIComponent(ADDRESS)}`, + ); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ error: 'Spend invoices are not configured' }); + }); + + it('returns 401 when the bearer is missing', async () => { + const res = await spendApp({ spendApiToken: TOKEN }).request( + `/invoices/eligible?address=${encodeURIComponent(ADDRESS)}`, + ); + expect(res.status).toBe(401); + }); + + it('returns 400 on a bad Lightning Address', async () => { + const res = await spendApp({ spendApiToken: TOKEN }).request( + '/invoices/eligible?address=nope', + auth(), + ); + expect(res.status).toBe(400); + }); + + it('returns 400 when the address query is omitted', async () => { + const res = await spendApp({ spendApiToken: TOKEN }).request('/invoices/eligible', auth()); + expect(res.status).toBe(400); + }); + + it('uses the default empty funding store when none is injected', async () => { + const res = await invoiceRoutes({ + spendApiToken: TOKEN, + store: new InMemoryInvoiceStore(), + authStore: new InMemoryAuthStore(), + messageStore: new InMemoryMessageStore(), + now: () => 1, + fetchImpl: happyFetch(), + }).request(`/eligible?address=${encodeURIComponent(ADDRESS)}`, auth()); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ eligible: false }); + }); + + it('returns eligible false for an unknown address', async () => { + const res = await spendApp({ spendApiToken: TOKEN }).request( + `/invoices/eligible?address=${encodeURIComponent(ADDRESS)}`, + auth(), + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ eligible: false }); + }); + + it('returns eligible true when admitted today', async () => { + const authStore = new InMemoryAuthStore(); + await seedPasskeyAccount(authStore); + const account = await authStore.getAccount('acc-alice'); + if (account !== undefined) { + await authStore.updateAccount({ ...account, role: 'verified' }); + } + const fundingStore = new InMemoryFundingStore([ + { + accountId: 'acc-alice', + status: 'admitted', + appliedAt: Date.parse('2026-09-01T00:00:00.000Z'), + decidedAt: Date.parse('2026-09-10T08:00:00.000Z'), + decidedBy: 'staff', + trialUtcDate: null, + admittedAt: Date.parse('2026-09-15T18:00:00.000Z'), + note: null, + }, + ]); + const res = await spendApp({ spendApiToken: TOKEN, authStore, fundingStore }).request( + `/invoices/eligible?address=${encodeURIComponent(ADDRESS)}`, + auth(), + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ eligible: true }); + }); + + it('returns eligible false when there is no grant', async () => { + const authStore = new InMemoryAuthStore(); + await seedPasskeyAccount(authStore); + const res = await spendApp({ + spendApiToken: TOKEN, + authStore, + fundingStore: new InMemoryFundingStore(), + }).request(`/invoices/eligible?address=${encodeURIComponent(ADDRESS)}`, auth()); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ eligible: false }); + }); + + it('does not persist when GET /eligible sees an expired trial', async () => { + const authStore = new InMemoryAuthStore(); + await seedPasskeyAccount(authStore); + const stored = expiredTrialGrant(); + const fundingStore = new InMemoryFundingStore([stored]); + const res = await spendApp({ + spendApiToken: TOKEN, + authStore, + fundingStore, + now: () => NOW_MS, + }).request(`/invoices/eligible?address=${encodeURIComponent(ADDRESS)}`, auth()); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ eligible: false }); + expect(await fundingStore.getByAccountId('acc-alice')).toEqual(stored); + expect((await fundingStore.getByAccountId('acc-alice'))?.status).toBe('trial'); + }); + it('does not persist when POST /invoices sees an expired trial', async () => { + const authStore = new InMemoryAuthStore(); + await seedPasskeyAccount(authStore); + const stored = expiredTrialGrant(); + const fundingStore = new InMemoryFundingStore([stored]); + const fetchImpl: FetchFn = async () => { + throw new Error('LNURL must not be called'); + }; + const res = await spendApp({ + spendApiToken: TOKEN, + authStore, + messageStore: livePostStore(), + fetchImpl, + fundingStore, + now: () => NOW_MS, + }).request( + '/invoices', + auth({ method: 'POST', body: JSON.stringify({ address: ADDRESS, amountMsat: 1000 }) }), + ); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: 'Funding grant required' }); + expect(await fundingStore.getByAccountId('acc-alice')).toEqual(stored); + expect((await fundingStore.getByAccountId('acc-alice'))?.status).toBe('trial'); + }); +}); diff --git a/src/__tests__/routes/me.test.ts b/src/__tests__/routes/me.test.ts index d9553cbc..a6e1629d 100644 --- a/src/__tests__/routes/me.test.ts +++ b/src/__tests__/routes/me.test.ts @@ -228,6 +228,7 @@ describe('GET /me', () => { missing: string[]; hasPosted: boolean; notificationLevel: 'all' | 'active' | 'mentions'; + funding: null; }; expect(body.id).toBe('acc'); expect(body.role).toBe('basis'); @@ -242,6 +243,22 @@ describe('GET /me', () => { expect(body.missing).toEqual(['name', 'username', 'lightning-address', 'rules']); expect(body.hasPosted).toBe(false); expect(body.notificationLevel).toBe('all'); + expect(body.funding).toBeNull(); + }); + + it('returns funding none for a verified account without a grant', async () => { + const store = await seededStore(); + const existing = await store.getAccount('acc'); + expect(existing).toBeDefined(); + await store.updateAccount({ ...existing!, role: 'verified' }); + const res = await mount(store).request('/me', { headers: AUTH }); + expect(res.status).toBe(200); + expect(((await res.json()) as { funding: unknown }).funding).toEqual({ + status: 'none', + trialUtcDate: null, + admittedAt: null, + reviewedByName: null, + }); }); it('returns hasPosted false when the only live row is the profile note', async () => { diff --git a/src/__tests__/routes/members.test.ts b/src/__tests__/routes/members.test.ts index f9ec5848..da3306ab 100644 --- a/src/__tests__/routes/members.test.ts +++ b/src/__tests__/routes/members.test.ts @@ -4,6 +4,7 @@ import { InMemoryAuthStore } from '@/lib/auth/store'; import { unsignedNostrDefaults } from '@/lib/message'; import { InMemoryMessageStore } from '@/lib/message-store'; import type { TrustEdge } from '@/lib/trust'; +import { InMemoryFundingStore } from '@/lib/funding-store'; import { InMemoryTrustStore } from '@/lib/trust-store'; import { removeForumVideo, writeForumVideo } from '@/lib/video'; import { membersRoutes } from '@/routes/members'; @@ -38,8 +39,12 @@ function mount( authStore: InMemoryAuthStore, messageStore: InMemoryMessageStore = new InMemoryMessageStore(), trustStore: InMemoryTrustStore = new InMemoryTrustStore(), + fundingStore: InMemoryFundingStore = new InMemoryFundingStore(), ): Hono { - return new Hono().route('/members', membersRoutes({ authStore, messageStore, trustStore, now })); + return new Hono().route( + '/members', + membersRoutes({ authStore, messageStore, trustStore, fundingStore, now }), + ); } async function seededCaller( @@ -175,6 +180,76 @@ describe('GET /members/:accountId', () => { expect(body['trust']).toEqual(NULL_TRUST); expect(body['aboutMe']).toBeNull(); expect(body['aboutMeHasPhoto']).toBe(false); + expect(body['fundingReviewedAt']).toBeNull(); + }); + + it('defaults fundingStore when omitted from membersRoutes', async () => { + const authStore = await seededCaller(); + await addAccount(authStore, ACCOUNT_ID, 'b'.repeat(64)); + const res = await new Hono() + .route( + '/members', + membersRoutes({ + authStore, + messageStore: new InMemoryMessageStore(), + trustStore: new InMemoryTrustStore(), + now, + }), + ) + .request(`/members/${ACCOUNT_ID}`, { headers: AUTH }); + expect(res.status).toBe(200); + expect( + ((await res.json()) as { fundingReviewedAt: number | null }).fundingReviewedAt, + ).toBeNull(); + }); + + it('returns fundingReviewedAt only when the grant is admitted', async () => { + const authStore = await seededCaller(); + await addAccount(authStore, ACCOUNT_ID, 'b'.repeat(64)); + const fundingStore = new InMemoryFundingStore([ + { + accountId: ACCOUNT_ID, + status: 'admitted', + appliedAt: 1, + decidedAt: now(), + decidedBy: 'caller', + trialUtcDate: null, + admittedAt: now(), + note: null, + }, + ]); + const admitted = await mount( + authStore, + new InMemoryMessageStore(), + new InMemoryTrustStore(), + fundingStore, + ).request(`/members/${ACCOUNT_ID}`, { headers: AUTH }); + expect(admitted.status).toBe(200); + expect( + ((await admitted.json()) as { fundingReviewedAt: number | null }).fundingReviewedAt, + ).toBe(now()); + + const pendingStore = new InMemoryFundingStore([ + { + accountId: ACCOUNT_ID, + status: 'pending', + appliedAt: 1, + decidedAt: null, + decidedBy: null, + trialUtcDate: null, + admittedAt: now(), + note: null, + }, + ]); + const pending = await mount( + authStore, + new InMemoryMessageStore(), + new InMemoryTrustStore(), + pendingStore, + ).request(`/members/${ACCOUNT_ID}`, { headers: AUTH }); + expect( + ((await pending.json()) as { fundingReviewedAt: number | null }).fundingReviewedAt, + ).toBeNull(); }); it('marks the profile note not payable when eventId is empty', async () => { diff --git a/src/__tests__/routes/messages.test.ts b/src/__tests__/routes/messages.test.ts index 21417380..0bf2a4fd 100644 --- a/src/__tests__/routes/messages.test.ts +++ b/src/__tests__/routes/messages.test.ts @@ -14,6 +14,7 @@ import { unsignedNostrDefaults, } from '@/lib/message'; import { InvoiceRateLimiter, PostRateLimiter } from '@/lib/nostr/rate-limit'; +import { InMemoryFundingStore } from '@/lib/funding-store'; import { messagesRoutes, type MessagesRouteDeps } from '@/routes/messages'; import { parseNostrKek } from '@/lib/nostr/kek'; import { ensureAccountNostrKey } from '@/lib/nostr/keys'; @@ -60,6 +61,21 @@ const JPEG2_B64 = Buffer.from(JPEG2_BYTES).toString('base64'); const JPEG3_BYTES = new Uint8Array([0xff, 0xd8, 0xff, 0x01]); const JPEG3_B64 = Buffer.from(JPEG3_BYTES).toString('base64'); +function admittedFunding(accountId = 'acc'): InMemoryFundingStore { + return new InMemoryFundingStore([ + { + accountId, + status: 'admitted', + appliedAt: 1, + decidedAt: 1, + decidedBy: 'staff', + trialUtcDate: null, + admittedAt: 1, + note: null, + }, + ]); +} + function mount( authStore: InMemoryAuthStore, store: MessageStore = new InMemoryMessageStore(), @@ -874,6 +890,116 @@ describe('GET /messages', () => { expect(await res.json()).toEqual({ error: 'Invalid mode' }); }); + it('returns 400 for an invalid hashtag', async () => { + const app = mount(await rulesStore()); + for (const hashtag of [ + '', + '%2321GiftsShop', + '21-gifts', + '_nope', + 'a'.repeat(65), + '21%20gifts', + ]) { + const res = await app.request(`/messages?hashtag=${hashtag}`, { headers: AUTH }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Invalid hashtag' }); + } + }); + + it('lists only notes whose text contains the hashtag token', async () => { + const authStore = await namedStore('Ada'); + const messageStore = new InMemoryMessageStore(); + await messageStore.create({ + id: '00000000-0000-4000-8000-000000000021', + accountId: 'acc', + name: 'Ada', + text: 'Shop #21GiftsShop', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + }); + await messageStore.create({ + id: '00000000-0000-4000-8000-000000000022', + accountId: 'acc', + name: 'Ada', + text: 'living room', + createdAt: new Date(now() + 1), + hasPhoto: false, + ...unsignedNostrDefaults(), + }); + const app = mount(authStore, messageStore); + const filtered = await app.request('/messages?hashtag=21GiftsShop', { headers: AUTH }); + expect(filtered.status).toBe(200); + const filteredBody = (await filtered.json()) as { messages: Array<{ id: string }> }; + expect(filteredBody.messages.map((row) => row.id)).toEqual([ + '00000000-0000-4000-8000-000000000021', + ]); + const all = await app.request('/messages', { headers: AUTH }); + expect(all.status).toBe(200); + const allBody = (await all.json()) as { messages: Array<{ id: string }> }; + expect(allBody.messages.map((row) => row.id)).toEqual([ + '00000000-0000-4000-8000-000000000022', + '00000000-0000-4000-8000-000000000021', + ]); + }); + + it('pages hashtag matches without mixing in untagged notes', async () => { + const authStore = await namedStore('Ada'); + const messageStore = new InMemoryMessageStore(); + await messageStore.create({ + id: '00000000-0000-4000-8000-000000000023', + accountId: 'acc', + name: 'Ada', + text: 'Shop #21GiftsShop', + createdAt: new Date(now() + 1), + hasPhoto: false, + ...unsignedNostrDefaults(), + }); + await messageStore.create({ + id: '00000000-0000-4000-8000-000000000024', + accountId: 'acc', + name: 'Ada', + text: 'also #21giftsshop here', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + }); + await messageStore.create({ + id: '00000000-0000-4000-8000-000000000025', + accountId: 'acc', + name: 'Ada', + text: 'living room', + createdAt: new Date(now() + 2), + hasPhoto: false, + ...unsignedNostrDefaults(), + }); + const app = mount(authStore, messageStore); + const first = await app.request('/messages?hashtag=21GiftsShop&limit=1', { headers: AUTH }); + expect(first.status).toBe(200); + const firstBody = (await first.json()) as { + messages: Array<{ id: string }>; + nextCursor?: string; + }; + expect(firstBody.messages.map((row) => row.id)).toEqual([ + '00000000-0000-4000-8000-000000000023', + ]); + expect(typeof firstBody.nextCursor).toBe('string'); + const cursor = firstBody.nextCursor; + expect(cursor).toBeDefined(); + if (cursor === undefined) { + throw new Error('expected nextCursor'); + } + const second = await app.request( + `/messages?hashtag=21GiftsShop&limit=1&cursor=${encodeURIComponent(cursor)}`, + { headers: AUTH }, + ); + expect(second.status).toBe(200); + const secondBody = (await second.json()) as { messages: Array<{ id: string }> }; + expect(secondBody.messages.map((row) => row.id)).toEqual([ + '00000000-0000-4000-8000-000000000024', + ]); + }); + it('returns 400 for an invalid limit', async () => { const app = mount(await rulesStore()); for (const limit of ['0', '201', 'abc']) { @@ -2171,8 +2297,9 @@ describe('POST /messages', () => { it('pings spend once on a top-level post', async () => { const spendPing = { ping: vi.fn(async (_address: string, _messageId: string) => undefined) }; - const res = await mount(await namedStore('Ada'), new InMemoryMessageStore(), { + const res = await mount(await staffStore('Ada'), new InMemoryMessageStore(), { spendPing, + fundingStore: admittedFunding(), }).request('/messages', { method: 'POST', headers: { ...AUTH, 'content-type': 'application/json' }, @@ -2213,7 +2340,10 @@ describe('POST /messages', () => { it('does not ping spend a second time on photo replay', async () => { const spendPing = { ping: vi.fn(async (_address: string, _messageId: string) => undefined) }; - const app = mount(await namedStore('Ada'), new InMemoryMessageStore(), { spendPing }); + const app = mount(await staffStore('Ada'), new InMemoryMessageStore(), { + spendPing, + fundingStore: admittedFunding(), + }); const body = JSON.stringify({ text: 'push photo', photo: { contentType: 'image/jpeg', data: JPEG_B64 }, @@ -2237,6 +2367,24 @@ describe('POST /messages', () => { expect(spendPing.ping).toHaveBeenCalledTimes(1); }); + it('skips spend ping when the poster is not funding-eligible', async () => { + const spendPing = { ping: vi.fn(async (_address: string, _messageId: string) => undefined) }; + const res = await mount(await namedStore('Ada'), new InMemoryMessageStore(), { + spendPing, + }).request('/messages', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'hello' }), + }); + expect(res.status).toBe(200); + expect(spendPing.ping).not.toHaveBeenCalled(); + expect( + parsedEvents(warn).some( + (e) => e['event'] === 'spend.ping.skipped' && e['reason'] === 'not_eligible', + ), + ).toBe(true); + }); + it('returns 200 on a top-level post when spendPing is omitted', async () => { const res = await mount(await namedStore('Ada')).request('/messages', { method: 'POST', @@ -2252,8 +2400,9 @@ describe('POST /messages', () => { throw new Error('ping boom'); }), }; - const res = await mount(await namedStore('Ada'), new InMemoryMessageStore(), { + const res = await mount(await staffStore('Ada'), new InMemoryMessageStore(), { spendPing, + fundingStore: admittedFunding(), }).request('/messages', { method: 'POST', headers: { ...AUTH, 'content-type': 'application/json' }, @@ -2277,8 +2426,9 @@ describe('POST /messages', () => { form.set('text', 'clip'); form.set('video', new File([mp4()], 'clip.mp4', { type: 'video/mp4' })); form.set('poster', new File([JPEG_BYTES], 'poster.jpg', { type: 'image/jpeg' })); - const res = await mount(await namedStore('Ada'), new InMemoryMessageStore(), { + const res = await mount(await staffStore('Ada'), new InMemoryMessageStore(), { spendPing, + fundingStore: admittedFunding(), }).request('/messages', { method: 'POST', headers: AUTH, diff --git a/src/index.ts b/src/index.ts index 0c6d004a..f8bd9915 100644 --- a/src/index.ts +++ b/src/index.ts @@ -64,6 +64,7 @@ if (import.meta.main) { conversationStore, notificationStore, trustStore, + fundingStore, } = boot; const pushStore = boot.pushStore ?? new InMemoryPushStore(); const vapid = resolveVapidConfig(process.env); @@ -97,6 +98,7 @@ if (import.meta.main) { ...(conversationStore === undefined ? {} : { conversationStore }), ...(notificationStore === undefined ? {} : { notificationStore }), ...(trustStore === undefined ? {} : { trustStore }), + ...(fundingStore === undefined ? {} : { fundingStore }), vapidPublicKey: vapidPublicKey ?? '', }); Bun.serve({ fetch: app.fetch, hostname: host, port }); diff --git a/src/lib/auth/account-json.ts b/src/lib/auth/account-json.ts index d037993a..330eb7a4 100644 --- a/src/lib/auth/account-json.ts +++ b/src/lib/auth/account-json.ts @@ -5,7 +5,9 @@ import { type AccountMissingField, type AccountSetup, } from '@/lib/auth/account-setup'; -import type { Account, NotificationLevel } from '@/lib/auth/store'; +import type { Account, AuthStore, NotificationLevel } from '@/lib/auth/store'; +import { serializeOwnerFunding, type OwnerFundingJson } from '@/lib/funding'; +import type { FundingStore } from '@/lib/funding-store'; import type { MessageStore } from '@/lib/message-store'; import { parseNotificationLevel } from '@/lib/notification'; @@ -42,7 +44,8 @@ export interface AccountResponse { /** * Owner-facing account JSON: the eleven public fields plus the durable * view-key capability secret, the next `setup` step, factual `missing`, - * `hasPosted`, `aboutMe`, `aboutMeHasPhoto`, and `notificationLevel`. + * `hasPosted`, `aboutMe`, `aboutMeHasPhoto`, `notificationLevel`, and + * `funding`. */ export interface OwnerAccountResponse extends AccountResponse { /** 64 lowercase hex; capability URL secret for `GET /view/:viewKey`. */ @@ -80,6 +83,11 @@ export interface OwnerAccountResponse extends AccountResponse { * Owner-only; omitted from public `GET /view/:viewKey` and member cards. */ notificationLevel: NotificationLevel; + /** + * Funding-program grant. `null` for `basis` (do not leak grants). + * Otherwise always an object; no row is `{ status: 'none', … }`. + */ + funding: OwnerFundingJson | null; } /** @@ -170,7 +178,8 @@ export function serializeDebugAccount(account: Account): DebugAccountResponse { * * Includes `viewKey` so the owner can copy the capability URL. The second * argument is the live-post flag (`hasPosted`); the third is About me; - * the fourth is whether the live profile note has a photo. + * the fourth is whether the live profile note has a photo; the fifth is + * `funding` (`null` for `basis`, default `null`). * This function performs no I/O. Never used by the operator debug listing. * Does not expose `profileMessageId`. * @@ -178,15 +187,17 @@ export function serializeDebugAccount(account: Account): DebugAccountResponse { * @param hasPosted - True when the account has a live non-profile forum row. * @param aboutMe - Profile bio, or `null` when unfilled. * @param aboutMeHasPhoto - True when the live profile note has a photo. - * @returns Eighteen fields including `viewKey`, `setup`, `missing`, - * `hasPosted`, `location`, `aboutMe`, `aboutMeHasPhoto`, and - * `notificationLevel`. + * @param funding - Owner funding JSON, or `null` for `basis`. Defaults to + * `null` so direct test callers keep a present field. + * @returns Nineteen fields (eleven public + `viewKey`, `setup`, `missing`, + * `hasPosted`, `aboutMe`, `aboutMeHasPhoto`, `notificationLevel`, `funding`). */ export function serializeOwnerAccount( account: Account, hasPosted: boolean, aboutMe: string | null, aboutMeHasPhoto: boolean, + funding: OwnerFundingJson | null = null, ): OwnerAccountResponse { return { ...serializeAccount(account), @@ -197,9 +208,20 @@ export function serializeOwnerAccount( aboutMe, aboutMeHasPhoto, notificationLevel: parseNotificationLevel(account.notificationLevel), + funding, }; } +/** Funding lookup used by {@link serializeOwnerAccountWithPosts}. */ +export interface OwnerFundingLookup { + /** Funding-grant persistence. */ + store: FundingStore; + /** Epoch milliseconds for lazy trial expiry. */ + nowMs: number; + /** Account lookup for admitted `reviewedByName`. */ + authStore: Pick; +} + /** * Project owner JSON after looking up whether the account has a live * non-profile forum row and loading the profile-note About me text. @@ -212,15 +234,18 @@ export function serializeOwnerAccount( * * @param account - Stored account. * @param messages - Message store (live-post lookup and profile-note read). + * @param funding - Optional grant lookup; omitted → `basis` `null`, else + * `{ status: 'none', … }` without I/O. * @returns Owner JSON including `hasPosted`, `aboutMe`, `aboutMeHasPhoto`, - * and `notificationLevel` (via {@link serializeOwnerAccount}). `aboutMe` is - * `null` when the profile note is missing or `deletedAt` is set, else - * `aboutMeFromNote(account.name, row.text, row.name)`. + * `notificationLevel`, and `funding` (via {@link serializeOwnerAccount}). + * `aboutMe` is `null` when the profile note is missing or `deletedAt` is + * set, else `aboutMeFromNote(account.name, row.text, row.name)`. * `aboutMeHasPhoto` is true iff the live row has `hasPhoto === true`. */ export async function serializeOwnerAccountWithPosts( account: Account, messages: Pick, + funding?: OwnerFundingLookup, ): Promise { const hasPosted = await messages.accountHasLivePost(account.id, account.profileMessageId ?? null); const profileId = account.profileMessageId; @@ -233,7 +258,19 @@ export async function serializeOwnerAccountWithPosts( aboutMeHasPhoto = row.hasPhoto === true; } } - return serializeOwnerAccount(account, hasPosted, aboutMe, aboutMeHasPhoto); + let fundingJson: OwnerFundingJson | null; + if (funding === undefined) { + fundingJson = serializeOwnerFunding(account.role, undefined, 0, null); + } else { + const grant = await funding.store.getByAccountId(account.id); + let reviewerName: string | null = null; + if (grant?.decidedBy !== null && grant?.decidedBy !== undefined) { + const reviewer = await funding.authStore.getAccount(grant.decidedBy); + reviewerName = reviewer?.name ?? null; + } + fundingJson = serializeOwnerFunding(account.role, grant, funding.nowMs, reviewerName); + } + return serializeOwnerAccount(account, hasPosted, aboutMe, aboutMeHasPhoto, fundingJson); } /** diff --git a/src/lib/boot-stores.ts b/src/lib/boot-stores.ts index 943262c0..b638a888 100644 --- a/src/lib/boot-stores.ts +++ b/src/lib/boot-stores.ts @@ -41,8 +41,9 @@ import { } from '@/lib/notification-store'; import { migratePushSchema, PostgresPushStore, type PushStore } from '@/lib/push-store'; import { migrateTrustSchema, PostgresTrustStore, type TrustStore } from '@/lib/trust-store'; +import { migrateFundingSchema, PostgresFundingStore, type FundingStore } from '@/lib/funding-store'; -/** Auth, gift, forum, contact, conversation, notification, push, trust, and FX persistence produced from `DATABASE_URL`. */ +/** Auth, gift, forum, contact, conversation, notification, push, trust, funding, and FX persistence produced from `DATABASE_URL`. */ export interface BootStores { /** Durable or in-memory account store. */ authStore: AuthStore; @@ -96,6 +97,11 @@ export interface BootStores { * opened so `createApp` keeps the empty in-memory default. */ trustStore: TrustStore | undefined; + /** + * Postgres-backed funding-grant store, or `undefined` when no SQL client was + * opened so `createApp` keeps the empty in-memory default. + */ + fundingStore: FundingStore | undefined; } /** Optional boot wiring so tests never hit the network. */ @@ -118,40 +124,42 @@ export interface BootFxOptions { /** * Open auth, optional gift, forum, contact, conversation, notification, - * push, and trust persistence, and the BTC-USD and USD-fiat rate books from - * `DATABASE_URL`. + * push, trust, and funding persistence, and the BTC-USD and USD-fiat rate + * books from `DATABASE_URL`. * * Blank or unset URL yields in-memory auth, `giftStore: undefined`, * `giftRecorder: undefined`, `messageStore: undefined`, * `contactStore: undefined`, `apiLogStore: undefined`, * `conversationStore: undefined`, * `notificationStore: undefined`, `pushStore: undefined`, - * `trustStore: undefined`, `nostrKek: undefined`, an empty - * {@link InMemoryBtcUsdStore}, and an empty {@link InMemoryFiatStore}. A set - * URL asks `createClient` for one `SqlClient`, migrates auth (via + * `trustStore: undefined`, `fundingStore: undefined`, `nostrKek: undefined`, + * an empty {@link InMemoryBtcUsdStore}, and an empty {@link InMemoryFiatStore}. + * A set URL asks `createClient` for one `SqlClient`, migrates auth (via * `openAuthStore`) then the FX tables (`btc_usd_daily` then `usd_fiat_daily`), * `message`, `contact`, `conversation`, `push`, `notification`, `trust_edge`, - * `api_log`, and `db_change` schemas (notification after push, trust after - * notification, `api_log` after trust and immediately before `db_change` so - * `trg_db_change` attaches), builds a {@link QueryGiftStore}, + * `funding_grant`, `api_log`, and `db_change` schemas (notification after push, trust + * after notification, funding after trust, `api_log` immediately before + * `db_change` so `trg_db_change` attaches), builds a {@link QueryGiftStore}, * {@link SqlGiftRecorder}, {@link PostgresMessageStore}, * {@link PostgresContactStore}, {@link PostgresConversationStore}, - * {@link PostgresNotificationStore}, {@link PostgresPushStore}, and - * {@link PostgresTrustStore}, parses `NOSTR_NSEC_KEK` into `nostrKek`, - * constructs {@link PostgresBtcUsdStore} and {@link PostgresFiatStore}, and - * best-effort fills rates for the outbound gift day range (BTC-USD failures - * log `gifts.fx.boot_fill.failed`; fiat failures log - * `gifts.fx.fiat_boot_fill.failed`; neither throws). Once the Postgres message - * store exists, it backfills zap-payment claims and best-effort backfills - * external zappers after the `db_change` triggers are attached and before the - * remaining Postgres stores are constructed. External-zapper backfill failures - * log `nostr.zapper.backfill.failed` and do not abort boot. + * {@link PostgresNotificationStore}, {@link PostgresPushStore}, + * {@link PostgresTrustStore}, and {@link PostgresFundingStore}, parses + * `NOSTR_NSEC_KEK` into `nostrKek`, constructs {@link PostgresBtcUsdStore} and + * {@link PostgresFiatStore}, and best-effort fills rates for the outbound gift + * day range (BTC-USD failures log `gifts.fx.boot_fill.failed`; fiat failures + * log `gifts.fx.fiat_boot_fill.failed`; neither throws). Once the Postgres + * message store exists, it backfills zap-payment claims and best-effort + * backfills external zappers after the `db_change` triggers are attached and + * before the remaining Postgres stores are constructed. External-zapper + * backfill failures log `nostr.zapper.backfill.failed` and do not abort boot. * Memory boots omit - * `notificationStore` and `trustStore`, leave `nostrKek` undefined, and do - * not run the `db_change` migrate. SQL boots return - * {@link PostgresNotificationStore} and {@link PostgresTrustStore}. - * `migrateTrustSchema` runs after auth/`account` exists and before - * `migrateDbChangeSchema` so `trg_db_change` attaches to `trust_edge`. + * `notificationStore`, `trustStore`, and `fundingStore`, leave `nostrKek` + * undefined, and do not run the `db_change` migrate. SQL boots return + * {@link PostgresNotificationStore}, {@link PostgresTrustStore}, + * {@link PostgresFundingStore}, and {@link PostgresApiLogStore}. + * `migrateTrustSchema` then `migrateFundingSchema` run after auth/`account` + * exists and before `migrateApiLogSchema` / `migrateDbChangeSchema` so + * `trg_db_change` attaches to `trust_edge` and `funding_grant`. * `migrateApiLogSchema` runs after `openAuthStore` (account exists) and * immediately before `migrateDbChangeSchema` so `trg_db_change` attaches * to `api_log`. @@ -194,6 +202,7 @@ export async function openBootStores( notificationStore: undefined, pushStore: undefined, trustStore: undefined, + fundingStore: undefined, }; } @@ -207,6 +216,7 @@ export async function openBootStores( await migratePushSchema(sqlClient); await migrateNotificationSchema(sqlClient); await migrateTrustSchema(sqlClient); + await migrateFundingSchema(sqlClient); await migrateApiLogSchema(sqlClient); await migrateDbChangeSchema(sqlClient); @@ -263,6 +273,7 @@ export async function openBootStores( const pushStore = new PostgresPushStore(sqlClient); const notificationStore = new PostgresNotificationStore(sqlClient); const trustStore = new PostgresTrustStore(sqlClient); + const fundingStore = new PostgresFundingStore(sqlClient); return { authStore, giftStore, @@ -277,5 +288,6 @@ export async function openBootStores( notificationStore, pushStore, trustStore, + fundingStore, }; } diff --git a/src/lib/conversation-store.ts b/src/lib/conversation-store.ts index 46b4a05b..6a42bbdc 100644 --- a/src/lib/conversation-store.ts +++ b/src/lib/conversation-store.ts @@ -14,9 +14,16 @@ import { type ConversationMessageRow, type ConversationThread, } from '@/lib/conversation'; -import type { NostrPublishState } from '@/lib/message'; +import type { ForumPhoto, ForumPhotoContentType, NostrPublishState } from '@/lib/message'; import { normalizeSignedEvent } from '@/lib/nostr/publish'; +/** Keyset query for one messenger-style conversation page. */ +export type ConversationThreadPageQuery = { + conversationId: string; + limit: number; + cursor: { c: Date; i: string } | null; +}; + /** * Persistence port for conversation threads and messages. */ @@ -190,13 +197,51 @@ export interface ConversationStore { */ listMessages(conversationId: string, limit: number): Promise; + /** + * Newest page for a conversation, returned oldest-first within the page. + * + * @param query - Conversation, page size, and exclusive older cursor. + * @returns At most `limit` caller-owned message rows. + */ + listThreadPage(query: ConversationThreadPageQuery): Promise; + /** * Persist a message and bump `lastMessageAt`. Duplicate message `id` or - * `eventId` returns the existing row. + * `eventId` returns the existing row and does not insert extras. + * + * `extraPhotos` are indices 1..length (max 9). Empty/omitted = none. When + * extras are non-empty, `photo` (index 0) is required. * * @param row - Fully formed message. + * @param photo - Optional decoded photo (copied into storage; index 0). + * @param extraPhotos - Optional extra stills (indices 1..n, max 9). + * @returns The stored row (a copy) with `hasPhoto` / `photoCount` from + * stored stills. On duplicate id / eventId, the existing row. + * @throws When extras are present without photo 0, when extras exceed 9, + * or when persistence fails. + */ + appendMessage( + row: ConversationMessageRow, + photo?: ForumPhoto, + extraPhotos?: readonly ForumPhoto[], + ): Promise; + + /** + * Load photo bytes for a conversation message id (index 0). + * + * @param id - Message id. + * @returns A copy of the photo, or `null` when missing / no photo. + */ + getPhoto(id: string): Promise; + + /** + * Load one extra still (indices 1–9) for a conversation message id. + * + * @param id - Message id. + * @param index - Extra index (1–9). Values outside that range return `null`. + * @returns A copy of the extra photo, or `null` when missing / out of range. */ - appendMessage(row: ConversationMessageRow): Promise; + getExtraPhoto(id: string, index: number): Promise; /** * Claim unsigned pending rows (`eventId` null, sender account set) for wrap. @@ -329,6 +374,16 @@ export const CONVERSATION_SCHEMA_SQL: readonly string[] = [ )`, `CREATE INDEX IF NOT EXISTS conversation_read_conversation_id_idx ON conversation_read (conversation_id)`, + `ALTER TABLE conversation_message ADD COLUMN IF NOT EXISTS photo bytea`, + `ALTER TABLE conversation_message ADD COLUMN IF NOT EXISTS photo_content_type text`, + `CREATE TABLE IF NOT EXISTS conversation_message_extra_photo ( + message_id uuid NOT NULL REFERENCES conversation_message (id) ON DELETE CASCADE, + idx smallint NOT NULL, + photo bytea NOT NULL, + photo_content_type text NOT NULL, + PRIMARY KEY (message_id, idx), + CONSTRAINT conversation_message_extra_photo_idx_range CHECK (idx >= 1 AND idx <= 9) +)`, `DO $unwrap$ DECLARE repair_row RECORD; @@ -435,7 +490,9 @@ const THREAD_SELECT = `c.id, c.kind, c.account_a, c.account_b, c.counterpart_pub ), 0) AS last_sats`; const MESSAGE_SELECT = `id, conversation_id, text, created_at, sender_account_id, sender_pubkey, name, sats, - event_id, nostr_publish_state, nostr_event, claimed_until, actor_account_id, actor_name, gift_for_message_id`; + event_id, nostr_publish_state, nostr_event, claimed_until, actor_account_id, actor_name, gift_for_message_id, + (photo IS NOT NULL) AS has_photo, + ((photo IS NOT NULL)::int + COALESCE((SELECT COUNT(*)::int FROM conversation_message_extra_photo e WHERE e.message_id = conversation_message.id), 0)) AS photo_count`; /** * Apply {@link CONVERSATION_SCHEMA_SQL} in order. Idempotent. @@ -457,6 +514,9 @@ export class InMemoryConversationStore implements ConversationStore { readonly #threads: ConversationThread[]; readonly #messages: ConversationMessageRow[]; readonly #lastRead: Map; + readonly #photos = new Map(); + /** Extra stills; array index 0 = idx 1. */ + readonly #extraPhotos = new Map(); /** * @param seedThreads - Optional seed threads; copied into private storage. @@ -697,14 +757,23 @@ export class InMemoryConversationStore implements ConversationStore { ); } + /** Copy a row and set `hasPhoto` / `photoCount` from the photo maps. */ + #withListedMedia(row: ConversationMessageRow): ConversationMessageRow { + const copy = copyMessage(row); + const hasPhoto0 = this.#photos.has(row.id) || row.hasPhoto === true; + copy.hasPhoto = hasPhoto0; + copy.photoCount = (hasPhoto0 ? 1 : 0) + (this.#extraPhotos.get(row.id)?.length ?? 0); + return copy; + } + getMessageById(id: string): Promise { const row = this.#messages.find((item) => item.id === id); - return Promise.resolve(row === undefined ? undefined : copyMessage(row)); + return Promise.resolve(row === undefined ? undefined : this.#withListedMedia(row)); } getMessageByEventId(eventId: string): Promise { const row = this.#messages.find((item) => item.eventId === eventId); - return Promise.resolve(row === undefined ? undefined : copyMessage(row)); + return Promise.resolve(row === undefined ? undefined : this.#withListedMedia(row)); } listMessages(conversationId: string, limit: number): Promise { @@ -712,28 +781,110 @@ export class InMemoryConversationStore implements ConversationStore { .filter((row) => row.conversationId === conversationId) .sort(compareMessagesOldestFirst) .slice(0, limit) - .map((row) => copyMessage(row)); + .map((row) => this.#withListedMedia(row)); return Promise.resolve(listed); } - appendMessage(row: ConversationMessageRow): Promise { + listThreadPage(query: ConversationThreadPageQuery): Promise { + const listed = this.#messages + .filter((row) => { + if (row.conversationId !== query.conversationId) { + return false; + } + if (query.cursor === null) { + return true; + } + const byTime = row.createdAt.getTime() - query.cursor.c.getTime(); + return byTime < 0 || (byTime === 0 && row.id.localeCompare(query.cursor.i) < 0); + }) + .sort(compareMessagesNewestFirst) + .slice(0, query.limit) + .reverse() + .map((row) => this.#withListedMedia(row)); + return Promise.resolve(listed); + } + + /** + * Persist a message and optional stills; return a copy. + * + * @param row - Message to store. + * @param photo - Optional photo (bytes copied; index 0). + * @param extraPhotos - Optional extra stills (indices 1..n, max 9). + * @returns A copy of the stored row with `hasPhoto` / `photoCount` from + * stored stills. Duplicate `id` / `eventId` returns the existing row + * without inserting extras. + * @throws When extras are present without photo 0 or extras exceed 9. + */ + appendMessage( + row: ConversationMessageRow, + photo?: ForumPhoto, + extraPhotos?: readonly ForumPhoto[], + ): Promise { const existingById = this.#messages.find((item) => item.id === row.id); if (existingById !== undefined) { - return Promise.resolve(copyMessage(existingById)); + return Promise.resolve(this.#withListedMedia(existingById)); } if (row.eventId !== null) { const existing = this.#messages.find((item) => item.eventId === row.eventId); if (existing !== undefined) { - return Promise.resolve(copyMessage(existing)); + return Promise.resolve(this.#withListedMedia(existing)); } } - const stored = copyMessage(row); + const extras = [...(extraPhotos ?? [])]; + if (extras.length > 0 && photo === undefined) { + return Promise.reject(new Error('extra photos require photo 0')); + } + if (extras.length > 9) { + return Promise.reject(new Error('at most 9 extra photos')); + } + const hasPhoto = photo !== undefined; + const stored = copyMessage({ + ...row, + hasPhoto, + photoCount: (hasPhoto ? 1 : 0) + extras.length, + }); this.#messages.push(stored); + if (photo !== undefined) { + this.#photos.set(stored.id, copyPhoto(photo)); + } + if (extras.length > 0) { + this.#extraPhotos.set( + stored.id, + extras.map((item) => copyPhoto(item)), + ); + } const thread = this.#threads.find((item) => item.id === row.conversationId); if (thread !== undefined && row.createdAt.getTime() >= thread.lastMessageAt.getTime()) { thread.lastMessageAt = new Date(row.createdAt.getTime()); } - return Promise.resolve(copyMessage(stored)); + return Promise.resolve(this.#withListedMedia(stored)); + } + + /** + * Return a copy of the photo for `id`, or `null`. + * + * @param id - Message id. + * @returns Photo copy or `null`. + */ + getPhoto(id: string): Promise { + const photo = this.#photos.get(id); + return Promise.resolve(photo === undefined ? null : copyPhoto(photo)); + } + + /** + * Load one extra still (indices 1–9) for a message id. + * + * @param id - Message id. + * @param index - Extra index (1–9). Values outside that range return `null`. + * @returns A copy of the extra photo, or `null` when missing / out of range. + */ + getExtraPhoto(id: string, index: number): Promise { + if (index < 1 || index > 9) { + return Promise.resolve(null); + } + const list = this.#extraPhotos.get(id); + const photo = list?.[index - 1]; + return Promise.resolve(photo === undefined ? null : copyPhoto(photo)); } claimUnsigned(limit: number, nowMs: number, leaseMs: number): Promise { @@ -900,8 +1051,18 @@ interface ConversationMessageSqlRow { actor_account_id: string | null; actor_name: string | null; gift_for_message_id: string | null; + has_photo?: boolean | number | string | null; + photo_count?: number | string | null; +} + +/** Row shape for `getPhoto` / `getExtraPhoto`. */ +interface ConversationPhotoSqlRow { + photo: Uint8Array | Buffer | number[] | null; + photo_content_type: string | null; } +const FORUM_PHOTO_TYPES: ReadonlySet = new Set(['image/jpeg', 'image/png', 'image/webp']); + /** * Durable {@link ConversationStore} backed by Postgres. */ @@ -1242,30 +1403,84 @@ export class PostgresConversationStore implements ConversationStore { return rows.map((row) => mapMessage(row)); } - async appendMessage(row: ConversationMessageRow): Promise { + async listThreadPage(query: ConversationThreadPageQuery): Promise { + const rows = + query.cursor === null + ? await this.#sql.query( + `SELECT ${MESSAGE_SELECT} + FROM conversation_message + WHERE conversation_id = $1 + ORDER BY created_at DESC, id DESC + LIMIT $2`, + [query.conversationId, query.limit], + ) + : await this.#sql.query( + `SELECT ${MESSAGE_SELECT} + FROM conversation_message + WHERE conversation_id = $1 + AND (created_at < $3 OR (created_at = $3 AND id < $4)) + ORDER BY created_at DESC, id DESC + LIMIT $2`, + [query.conversationId, query.limit, query.cursor.c, query.cursor.i], + ); + return rows.slice().reverse().map(mapMessage); + } + + /** + * Persist a message and optional stills; return a copy. + * + * @param row - Message to store. + * @param photo - Optional photo (bytes copied; index 0). + * @param extraPhotos - Optional extra stills (indices 1..n, max 9). + * @returns The stored row with `hasPhoto` / `photoCount` from stored + * stills. Duplicate `id` / `eventId` returns the existing row without + * inserting extras. + * @throws When extras are present without photo 0, when extras exceed 9, + * or when persistence fails. + */ + async appendMessage( + row: ConversationMessageRow, + photo?: ForumPhoto, + extraPhotos?: readonly ForumPhoto[], + ): Promise { + const extras = [...(extraPhotos ?? [])]; + if (extras.length > 0 && photo === undefined) { + throw new Error('extra photos require photo 0'); + } + if (extras.length > 9) { + throw new Error('at most 9 extra photos'); + } + const hasPhoto = photo !== undefined; + const stored = copyMessage({ + ...row, + hasPhoto, + photoCount: (hasPhoto ? 1 : 0) + extras.length, + }); try { await this.#sql.execute( `INSERT INTO conversation_message ( id, conversation_id, text, created_at, sender_account_id, sender_pubkey, name, sats, event_id, nostr_publish_state, nostr_event, claimed_until, actor_account_id, actor_name, - gift_for_message_id - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12,$13,$14,$15)`, + gift_for_message_id, photo, photo_content_type + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12,$13,$14,$15,$16,$17)`, [ - row.id, - row.conversationId, - row.text, - row.createdAt, - row.senderAccountId, - row.senderPubkey, - row.name, - row.sats, - row.eventId, - row.nostrPublishState, - row.nostrEvent, - row.claimedUntil === null ? null : new Date(row.claimedUntil), + stored.id, + stored.conversationId, + stored.text, + stored.createdAt, + stored.senderAccountId, + stored.senderPubkey, + stored.name, + stored.sats, + stored.eventId, + stored.nostrPublishState, + stored.nostrEvent, + stored.claimedUntil === null ? null : new Date(stored.claimedUntil), row.actorAccountId ?? null, row.actorName ?? '', row.giftForMessageId ?? null, + photo === undefined ? null : photo.bytes, + photo === undefined ? null : photo.contentType, ], ); } catch (error: unknown) { @@ -1283,11 +1498,74 @@ export class PostgresConversationStore implements ConversationStore { } throw error; } + for (const [i, extra] of extras.entries()) { + try { + await this.#sql.execute( + `INSERT INTO conversation_message_extra_photo (message_id, idx, photo, photo_content_type) VALUES ($1,$2,$3,$4)`, + [stored.id, i + 1, extra.bytes, extra.contentType], + ); + } catch (error: unknown) { + await this.#sql.execute(`DELETE FROM conversation_message WHERE id = $1`, [stored.id]); + throw error; + } + } await this.#sql.execute( `UPDATE conversation SET last_message_at = GREATEST(last_message_at, $2) WHERE id = $1`, - [row.conversationId, row.createdAt], + [stored.conversationId, stored.createdAt], ); - return copyMessage(row); + return stored; + } + + /** + * Load photo bytes for a conversation message id (index 0). + * + * @param id - Message id (`$1`). + * @returns Photo copy, or `null` when missing / null photo / bad type. + */ + async getPhoto(id: string): Promise { + const rows = await this.#sql.query( + `SELECT photo, photo_content_type FROM conversation_message WHERE id = $1`, + [id], + ); + const row = rows[0]; + if (row === undefined || row.photo === null || row.photo_content_type === null) { + return null; + } + if (!FORUM_PHOTO_TYPES.has(row.photo_content_type)) { + return null; + } + return { + contentType: row.photo_content_type as ForumPhotoContentType, + bytes: toUint8Array(row.photo), + }; + } + + /** + * Load one extra still (indices 1–9) for a conversation message id. + * + * @param id - Message id (`$1`). + * @param index - Extra index (1–9) (`$2`). Values outside that range return `null`. + * @returns A copy of the extra photo, or `null` when missing / out of range / bad type. + */ + async getExtraPhoto(id: string, index: number): Promise { + if (index < 1 || index > 9) { + return null; + } + const rows = await this.#sql.query( + `SELECT photo, photo_content_type FROM conversation_message_extra_photo WHERE message_id = $1 AND idx = $2`, + [id, index], + ); + const row = rows[0]; + if (row === undefined || row.photo === null || row.photo_content_type === null) { + return null; + } + if (!FORUM_PHOTO_TYPES.has(row.photo_content_type)) { + return null; + } + return { + contentType: row.photo_content_type as ForumPhotoContentType, + bytes: toUint8Array(row.photo), + }; } async claimUnsigned( @@ -1432,6 +1710,10 @@ function copyThread(thread: ConversationThread): ConversationThread { }; } +function copyPhoto(photo: ForumPhoto): ForumPhoto { + return { contentType: photo.contentType, bytes: photo.bytes.slice() }; +} + function copyMessage(row: ConversationMessageRow): ConversationMessageRow { return { ...row, @@ -1439,9 +1721,19 @@ function copyMessage(row: ConversationMessageRow): ConversationMessageRow { nostrEvent: row.nostrEvent === null ? null : { ...row.nostrEvent }, actorAccountId: row.actorAccountId ?? null, actorName: row.actorName ?? '', + hasPhoto: row.hasPhoto === true, + photoCount: typeof row.photoCount === 'number' ? row.photoCount : row.hasPhoto === true ? 1 : 0, }; } +/** Coerce Postgres bytea drivers into a fresh {@link Uint8Array}. */ +function toUint8Array(value: Uint8Array | Buffer | number[]): Uint8Array { + if (value instanceof Uint8Array) { + return value.slice(); + } + return Uint8Array.from(value); +} + function parseKind(raw: string): ConversationKind { if ( raw === 'member_member' || @@ -1500,6 +1792,7 @@ async function alignMemberPlatformAccountB( function mapMessage(row: ConversationMessageSqlRow): ConversationMessageRow { const state = row.nostr_publish_state; + const hasPhoto = Boolean(row.has_photo); return { id: row.id, conversationId: row.conversation_id, @@ -1512,6 +1805,9 @@ function mapMessage(row: ConversationMessageSqlRow): ConversationMessageRow { actorName: row.actor_name ?? '', giftForMessageId: row.gift_for_message_id, sats: Number(row.sats ?? 0), + hasPhoto, + /* v8 ignore next -- photo_count is selected; null only on a pre-migration row */ + photoCount: Number(row.photo_count ?? (hasPhoto ? 1 : 0)), eventId: row.event_id, nostrPublishState: state === 'pending' || state === 'published' || state === 'failed' || state === 'skipped' diff --git a/src/lib/conversation.ts b/src/lib/conversation.ts index 62550960..bac00a3c 100644 --- a/src/lib/conversation.ts +++ b/src/lib/conversation.ts @@ -3,7 +3,8 @@ * * Threads are member↔member, member↔platform, member↔Damus, or the closed * moderator_group singleton. Member HTTP may include optional counterpart/sender - * `accountId` for 21.gifts accounts and never exposes event ids or npubs + * `accountId` for 21.gifts accounts, always includes `hasPhoto` / `photoCount`, + * and never exposes event ids, npubs, or photo bytes * (Damus-only display names may use truncated npubs via the routes layer). */ @@ -79,6 +80,16 @@ export interface ConversationMessageRow { giftForMessageId?: string | null; /** Credited sats on this row; `0` for unpaid text. Gift-only rows use `text: ''` and `sats >= 1`. */ sats: number; + /** + * Whether a still is stored for this message (bytes never on the row). + * Omitted on older fixtures; serializers default `false`. + */ + hasPhoto?: boolean; + /** + * Still-photo count 0–10 when known (`hasPhoto` plus extra stills). + * Omitted on older fixtures; serializers default `hasPhoto ? 1 : 0`. + */ + photoCount?: number; /** Signed/wrapped event id, or null until published. */ eventId: string | null; /** Fan-out state. */ @@ -127,6 +138,10 @@ export interface PublicConversationMessage { fromMe: boolean; /** Credited sats; `0` for unpaid text. */ sats: number; + /** Whether a still is stored for this message (bytes never in JSON). */ + hasPhoto: boolean; + /** Still-photo count 0–10 (`hasPhoto` plus extra stills). */ + photoCount: number; /** * Projected 21.gifts account id. Members always see the stored sender; * staff see the actor when `actorAccountId` is set. Omitted when that @@ -213,6 +228,11 @@ export function serializeConversation( return json; } +/** Public JSON `photoCount` (0–10). */ +function conversationPhotoCount(row: ConversationMessageRow): number { + return typeof row.photoCount === 'number' ? row.photoCount : row.hasPhoto === true ? 1 : 0; +} + /** * Project a message row to its public JSON shape. * @@ -223,9 +243,10 @@ export function serializeConversation( * @param row - Persisted message. * @param fromMe - Whether this message was sent by the viewer. * @param opts - `{ staff: true }` projects actor identity when present. - * @returns Public fields only (event id omitted; `accountId` when a - * 21.gifts account is shown; `giftFor` when this row is a paid gift - * for another message). + * @returns Public fields only (event id omitted; `hasPhoto` / `photoCount` + * always present; `accountId` when a 21.gifts account is shown; `giftFor` + * when this row is a paid gift for another message). Photo bytes are never + * included. */ export function serializeConversationMessage( row: ConversationMessageRow, @@ -247,6 +268,8 @@ export function serializeConversationMessage( createdAt: row.createdAt.toISOString(), fromMe, sats: row.sats, + hasPhoto: row.hasPhoto === true, + photoCount: conversationPhotoCount(row), }; if (typeof accountId === 'string' && accountId !== '') { json.accountId = accountId; diff --git a/src/lib/funding-store.ts b/src/lib/funding-store.ts new file mode 100644 index 00000000..39509760 --- /dev/null +++ b/src/lib/funding-store.ts @@ -0,0 +1,466 @@ +/** + * Persistence for funding-program grants (one row per account). + * + * v1 default is in-memory. Production boot injects Postgres when + * `DATABASE_URL` is set. Column layout matches `docs/schema/funding_grant.sql`. + */ + +import type { SqlClient } from '@/lib/auth/sql'; +import { + effectiveStatus, + expiredTrialAsPending, + type FundingGrant, + type FundingStatus, +} from '@/lib/funding'; + +/** + * Persistence port for funding grants. + */ +export interface FundingStore { + /** + * The grant for `accountId`, if any. + * + * @param accountId - Account that applied. + * @returns A copy of the stored grant, or `undefined` when none. + */ + getByAccountId(accountId: string): Promise; + + /** + * Every stored grant, oldest `appliedAt` first, then `accountId` ascending. + * + * @returns Grant copies (caller-owned). + */ + listGrants(): Promise; + + /** + * Insert or replace the grant for `grant.accountId`. + * + * @param grant - Fully formed grant. + * @returns The stored grant (a copy). + */ + upsert(grant: FundingGrant): Promise; + + /** + * Write `grant` only when the stored status is in `from` (`'none'` = no + * row). Zero matching rows → `undefined` (caller maps to HTTP 409). + * + * @param grant - Fully formed next grant. + * @param from - Allowed current statuses, including `'none'` for insert. + * @returns The stored grant, or `undefined` when the CAS missed. + */ + transition( + grant: FundingGrant, + from: readonly (FundingStatus | 'none')[], + ): Promise; + + /** + * Persist pending only when the row is still `status='trial'` with + * `grant.trialUtcDate`. Zero matching rows → current row via + * {@link getByAccountId}. + * + * @param grant - Expired trial from the first read. + * @returns The pending row when the CAS matched, else the current grant. + */ + expireTrialIfUnchanged(grant: FundingGrant): Promise; +} + +/** Idempotent DDL for the funding_grant table (matches `docs/schema/funding_grant.sql`). */ +export const FUNDING_SCHEMA_SQL: readonly string[] = [ + `CREATE TABLE IF NOT EXISTS funding_grant ( + account_id uuid PRIMARY KEY REFERENCES account (id), + status text NOT NULL CHECK (status IN ('pending', 'trial', 'admitted', 'rejected')), + applied_at timestamptz NOT NULL, + decided_at timestamptz, + decided_by uuid REFERENCES account (id), + trial_utc_date date, + admitted_at timestamptz, + note text +)`, +]; + +const FUNDING_SELECT = + 'account_id, status, applied_at, decided_at, decided_by, trial_utc_date, admitted_at, note'; + +/** + * Apply {@link FUNDING_SCHEMA_SQL} in order. Idempotent. + * + * @param sql - Parameter-bound SQL client. + * @returns Resolves when every statement has executed. + */ +export async function migrateFundingSchema(sql: SqlClient): Promise { + for (const statement of FUNDING_SCHEMA_SQL) { + await sql.execute(statement); + } +} + +/** + * If the stored trial is expired, persist pending via + * {@link expiredTrialAsPending} only when the row is still that trial + * (compare-and-set) and return that; else return the grant. + * + * @param store - Funding persistence. + * @param accountId - Account that applied. + * @param nowMs - Epoch milliseconds (UTC day). + * @returns The observed grant (lazy-persisted when an expired trial still + * matches), or `undefined` when no row. + */ +export async function loadGrantEffective( + store: FundingStore, + accountId: string, + nowMs: number, +): Promise { + const grant = await store.getByAccountId(accountId); + if (grant === undefined) { + return undefined; + } + if ( + effectiveStatus(grant, nowMs) === 'pending' && + grant.status === 'trial' && + grant.trialUtcDate !== null + ) { + return persistExpiredTrial(store, grant); + } + return copyGrant(grant); +} + +/** + * Rewrite expired trial → pending only while the row is still that trial. + * Both stores use {@link FundingStore.expireTrialIfUnchanged} (InMemory + * compares the map without yielding; Postgres `UPDATE … WHERE status='trial' + * AND trial_utc_date`). + * + * @param store - Funding persistence. + * @param grant - Expired trial from the first read. + * @returns Pending when the CAS matched, else the current row. + */ +async function persistExpiredTrial( + store: FundingStore, + grant: FundingGrant, +): Promise { + return store.expireTrialIfUnchanged(grant); +} + +/** + * Process-local {@link FundingStore}. Used in tests and when no database URL + * is configured — the process still boots. + */ +export class InMemoryFundingStore implements FundingStore { + readonly #grants = new Map(); + + /** + * @param seed - Optional seed grants; copied into private storage. + */ + constructor(seed: readonly FundingGrant[] = []) { + for (const grant of seed) { + this.#grants.set(grant.accountId, copyGrant(grant)); + } + } + + /** + * Copy of the grant for `accountId`, or `undefined`. + * + * @param accountId - Account that applied. + * @returns A copy, or `undefined` when none. + */ + getByAccountId(accountId: string): Promise { + const grant = this.#grants.get(accountId); + return Promise.resolve(grant === undefined ? undefined : copyGrant(grant)); + } + + /** + * Oldest-`appliedAt` copy of every stored grant, then `accountId` ascending. + * + * @returns A new array of copies; mutating it does not change the store. + */ + listGrants(): Promise { + return Promise.resolve([...this.#grants.values()].sort(compareGrants).map(copyGrant)); + } + + /** + * Replace the grant for `grant.accountId` and return a copy. + * + * @param grant - Grant to store. + * @returns A copy of the stored grant. + */ + upsert(grant: FundingGrant): Promise { + const stored = copyGrant(grant); + this.#grants.set(stored.accountId, stored); + return Promise.resolve(copyGrant(stored)); + } + + /** + * Write `grant` only when the in-memory status is in `from`. + * + * @param grant - Fully formed next grant. + * @param from - Allowed current statuses, including `'none'` for insert. + * @returns The stored grant, or `undefined` when the CAS missed. + */ + transition( + grant: FundingGrant, + from: readonly (FundingStatus | 'none')[], + ): Promise { + const current = this.#grants.get(grant.accountId); + const observed: FundingStatus | 'none' = current === undefined ? 'none' : current.status; + if (!from.includes(observed)) { + return Promise.resolve(undefined); + } + const stored = copyGrant(grant); + this.#grants.set(stored.accountId, stored); + return Promise.resolve(copyGrant(stored)); + } + + /** + * Write pending only when the in-memory row is still that expired trial. + * + * @param grant - Expired trial from the first read. + * @returns The pending row when the CAS matched, else the current grant. + */ + expireTrialIfUnchanged(grant: FundingGrant): Promise { + const current = this.#grants.get(grant.accountId); + if ( + current !== undefined && + current.status === 'trial' && + current.trialUtcDate === grant.trialUtcDate + ) { + const pending = expiredTrialAsPending(grant); + const stored = copyGrant(pending); + this.#grants.set(stored.accountId, stored); + return Promise.resolve(copyGrant(stored)); + } + return Promise.resolve(current === undefined ? undefined : copyGrant(current)); + } +} + +/** Row shape selected from `funding_grant`. */ +interface FundingSqlRow { + account_id: string; + status: FundingStatus; + applied_at: Date | string; + decided_at: Date | string | null; + decided_by: string | null; + trial_utc_date: Date | string | null; + admitted_at: Date | string | null; + note: string | null; +} + +/** Map a SQL row onto {@link FundingGrant}. Unexported. */ +function mapFundingRow(row: FundingSqlRow): FundingGrant { + return { + accountId: row.account_id, + status: row.status, + appliedAt: epochMs(row.applied_at), + decidedAt: nullableEpochMs(row.decided_at), + decidedBy: row.decided_by, + trialUtcDate: mapTrialUtcDate(row.trial_utc_date), + admittedAt: nullableEpochMs(row.admitted_at), + note: row.note, + }; +} + +/** + * Durable {@link FundingStore} backed by Postgres. + */ +export class PostgresFundingStore implements FundingStore { + readonly #sql: SqlClient; + + /** + * @param sql - Parameter-bound SQL client (already migrated). + */ + constructor(sql: SqlClient) { + this.#sql = sql; + } + + /** + * One row from `funding_grant` by account. + * + * @param accountId - Account that applied (`$1`). + * @returns The mapped grant, or `undefined` when none. + */ + async getByAccountId(accountId: string): Promise { + const rows = await this.#sql.query( + `SELECT ${FUNDING_SELECT} FROM funding_grant WHERE account_id = $1`, + [accountId], + ); + const row = rows[0]; + return row === undefined ? undefined : mapFundingRow(row); + } + + /** + * Oldest-first list from `funding_grant`. + * + * @returns Mapped rows. + */ + async listGrants(): Promise { + const rows = await this.#sql.query( + `SELECT ${FUNDING_SELECT} FROM funding_grant ORDER BY applied_at ASC, account_id ASC`, + ); + return rows.map((row) => mapFundingRow(row)); + } + + /** + * Insert or replace `grant` in `funding_grant` and return a copy. + * + * @param grant - Fully formed grant. + * @returns The input grant after a successful upsert (a copy). + */ + async upsert(grant: FundingGrant): Promise { + await this.#sql.execute( + `INSERT INTO funding_grant (account_id, status, applied_at, decided_at, decided_by, trial_utc_date, admitted_at, note) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (account_id) DO UPDATE SET + status = EXCLUDED.status, + applied_at = EXCLUDED.applied_at, + decided_at = EXCLUDED.decided_at, + decided_by = EXCLUDED.decided_by, + trial_utc_date = EXCLUDED.trial_utc_date, + admitted_at = EXCLUDED.admitted_at, + note = EXCLUDED.note`, + [ + grant.accountId, + grant.status, + new Date(grant.appliedAt), + grant.decidedAt === null ? null : new Date(grant.decidedAt), + grant.decidedBy, + grant.trialUtcDate, + grant.admittedAt === null ? null : new Date(grant.admittedAt), + grant.note, + ], + ); + return copyGrant(grant); + } + + /** + * Write `grant` only when the stored status is in `from`. `'none'` uses + * `INSERT … ON CONFLICT DO UPDATE WHERE status = ANY(from without none)`. + * + * @param grant - Fully formed next grant. + * @param from - Allowed current statuses, including `'none'` for insert. + * @returns The stored grant, or `undefined` when the CAS missed. + */ + async transition( + grant: FundingGrant, + from: readonly (FundingStatus | 'none')[], + ): Promise { + const fromStatus = from.filter((status): status is FundingStatus => status !== 'none'); + const params = [ + grant.accountId, + grant.status, + new Date(grant.appliedAt), + grant.decidedAt === null ? null : new Date(grant.decidedAt), + grant.decidedBy, + grant.trialUtcDate, + grant.admittedAt === null ? null : new Date(grant.admittedAt), + grant.note, + fromStatus, + ]; + const rows = from.includes('none') + ? await this.#sql.query( + `INSERT INTO funding_grant (account_id, status, applied_at, decided_at, decided_by, trial_utc_date, admitted_at, note) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (account_id) DO UPDATE SET + status = EXCLUDED.status, + applied_at = EXCLUDED.applied_at, + decided_at = EXCLUDED.decided_at, + decided_by = EXCLUDED.decided_by, + trial_utc_date = EXCLUDED.trial_utc_date, + admitted_at = EXCLUDED.admitted_at, + note = EXCLUDED.note + WHERE funding_grant.status = ANY($9::text[]) + RETURNING ${FUNDING_SELECT}`, + params, + ) + : await this.#sql.query( + `UPDATE funding_grant SET + status = $2, + applied_at = $3, + decided_at = $4, + decided_by = $5, + trial_utc_date = $6, + admitted_at = $7, + note = $8 + WHERE account_id = $1 AND status = ANY($9::text[]) + RETURNING ${FUNDING_SELECT}`, + params, + ); + const row = rows[0]; + return row === undefined ? undefined : mapFundingRow(row); + } + + /** + * Persist pending only when the row is still `status='trial'` with + * `grant.trialUtcDate`. Zero matching rows → current row via + * {@link getByAccountId}. + * + * @param grant - Expired trial from the first read. + * @returns The pending row when the CAS matched, else the current grant. + */ + async expireTrialIfUnchanged(grant: FundingGrant): Promise { + const pending = expiredTrialAsPending(grant); + const rows = await this.#sql.query( + `UPDATE funding_grant SET + status = $3, + applied_at = $4, + decided_at = $5, + decided_by = $6, + trial_utc_date = $7, + admitted_at = $8, + note = $9 + WHERE account_id = $1 AND status = 'trial' AND trial_utc_date = $2 + RETURNING ${FUNDING_SELECT}`, + [ + grant.accountId, + grant.trialUtcDate, + pending.status, + new Date(pending.appliedAt), + pending.decidedAt === null ? null : new Date(pending.decidedAt), + pending.decidedBy, + pending.trialUtcDate, + null, + pending.note, + ], + ); + const row = rows[0]; + if (row === undefined) { + return this.getByAccountId(grant.accountId); + } + return mapFundingRow(row); + } +} + +/** Caller-owned shallow copy. */ +function copyGrant(grant: FundingGrant): FundingGrant { + return { ...grant }; +} + +/** Oldest `appliedAt` first, then `accountId` ascending. */ +function compareGrants(a: FundingGrant, b: FundingGrant): number { + const byTime = a.appliedAt - b.appliedAt; + if (byTime !== 0) { + return byTime; + } + return a.accountId.localeCompare(b.accountId); +} + +/** `timestamptz` (Date or ISO string) to epoch ms. */ +function epochMs(value: Date | string): number { + return value instanceof Date ? value.getTime() : new Date(value).getTime(); +} + +/** Nullable `timestamptz` to epoch ms. `null` stays `null`. */ +function nullableEpochMs(value: Date | string | null): number | null { + return value === null ? null : epochMs(value); +} + +/** + * SQL `date` to `YYYY-MM-DD`. Date → ISO day; string → first ten chars; + * `null` stays `null`. + */ +function mapTrialUtcDate(value: Date | string | null): string | null { + if (value === null) { + return null; + } + if (value instanceof Date) { + return value.toISOString().slice(0, 10); + } + return value.slice(0, 10); +} diff --git a/src/lib/funding.ts b/src/lib/funding.ts new file mode 100644 index 00000000..b82ac6c5 --- /dev/null +++ b/src/lib/funding.ts @@ -0,0 +1,183 @@ +import type { AccountRole } from '@/lib/auth/store'; + +/** + * Funding-program grant domain. + * + * Independent of {@link AccountRole}: `verified` is a real-life meeting + * (forum badge); a grant is a human review of living-room posts against + * the three convictions. `basis` cannot hold a grant. Moderators decide; + * this module does not score posts. + */ + +/** Stored grant status. Missing row is not stored (`none` is effective-only). */ +export type FundingStatus = 'pending' | 'trial' | 'admitted' | 'rejected'; + +/** Status after lazy trial expiry. `none` when no row exists. */ +export type EffectiveFundingStatus = 'none' | FundingStatus; + +/** One persisted funding-program grant (unique on `accountId`). */ +export interface FundingGrant { + /** Account that applied. */ + accountId: string; + /** Stored status (expired trial is still `trial` until lazy persist). */ + status: FundingStatus; + /** Application time (epoch ms). Kept across trial expiry. */ + appliedAt: number; + /** Last staff decision time (epoch ms), or `null`. */ + decidedAt: number | null; + /** Staff account id that last decided, or `null`. */ + decidedBy: string | null; + /** UTC calendar day `YYYY-MM-DD` when status is `trial`, else `null`. */ + trialUtcDate: string | null; + /** Admission time (epoch ms), or `null`. */ + admittedAt: number | null; + /** Optional staff note. */ + note: string | null; +} + +/** Owner-facing funding JSON on `GET /me` / passkey finish (`role !== 'basis'`). */ +export interface OwnerFundingJson { + /** Effective status; never omit. `'none'` when there is no row. */ + status: EffectiveFundingStatus; + /** Trial UTC day when effective status is `trial`, else `null`. */ + trialUtcDate: string | null; + /** Admission time when effective status is `admitted`, else `null`. */ + admittedAt: number | null; + /** Live display name of `decidedBy` when admitted, else `null`. */ + reviewedByName: string | null; +} + +/** + * UTC calendar day `YYYY-MM-DD` from epoch ms. + * + * Same as `new Date(ms).toISOString().slice(0, 10)`. + * + * @param nowMs - Epoch milliseconds. + * @returns UTC day key. + */ +export function utcDayKey(nowMs: number): string { + return new Date(nowMs).toISOString().slice(0, 10); +} + +/** + * Effective grant status after lazy trial expiry. + * + * A trial whose `trialUtcDate` is a string strictly before today UTC is + * pending. Today's and future trial dates stay `'trial'`. A trial with + * `trialUtcDate === null` is not expired via the date comparison. Missing + * grant → `'none'`. Non-trial statuses return `grant.status`. + * + * @param grant - Stored grant, or `undefined` when no row. + * @param nowMs - Epoch milliseconds (UTC day). + * @returns Effective status. + */ +export function effectiveStatus( + grant: FundingGrant | undefined, + nowMs: number, +): EffectiveFundingStatus { + if (grant === undefined) { + return 'none'; + } + if ( + grant.status === 'trial' && + typeof grant.trialUtcDate === 'string' && + grant.trialUtcDate < utcDayKey(nowMs) + ) { + return 'pending'; + } + return grant.status; +} + +/** + * Whether the account may receive a spend ping / spend invoice today. + * + * True iff role is not `basis` AND (admitted OR (trial AND + * `trialUtcDate === utcDayKey(nowMs)`)). Expired trial is false. Missing + * grant is false. `basis` is always false. + * + * @param role - Live account role. + * @param grant - Stored grant, or `undefined` when no row. + * @param nowMs - Epoch milliseconds (UTC day). + * @returns `true` when money paths may proceed today. + */ +export function eligibleToday( + role: AccountRole, + grant: FundingGrant | undefined, + nowMs: number, +): boolean { + if (role === 'basis') { + return false; + } + if (grant === undefined) { + return false; + } + if (grant.status === 'admitted') { + return true; + } + return grant.status === 'trial' && grant.trialUtcDate === utcDayKey(nowMs); +} + +/** + * Owner `funding` field: `null` for `basis` (do not leak grants), else + * always an object (`none` when there is no row). + * + * @param role - Live account role. + * @param grant - Observed grant (lazy-persisted), or `undefined`. + * @param nowMs - Epoch milliseconds. + * @param reviewerName - Live `decidedBy` name; used only when admitted. + * @returns Owner funding JSON, or `null` for `basis`. + */ +export function serializeOwnerFunding( + role: AccountRole, + grant: FundingGrant | undefined, + nowMs: number, + reviewerName: string | null, +): OwnerFundingJson | null { + if (role === 'basis') { + return null; + } + const status = effectiveStatus(grant, nowMs); + const admitted = status === 'admitted'; + const trial = status === 'trial'; + return { + status, + trialUtcDate: trial ? (grant?.trialUtcDate ?? null) : null, + admittedAt: admitted ? (grant?.admittedAt ?? null) : null, + reviewedByName: admitted ? reviewerName : null, + }; +} + +/** + * Member-card `fundingReviewedAt`: `grant.admittedAt` when effective + * status is admitted, else `null`. Does not expose pending/trial/rejected. + * + * @param grant - Stored grant, or `undefined`. + * @param nowMs - Epoch milliseconds. + * @returns Admission epoch ms, or `null`. + */ +export function fundingReviewedAt(grant: FundingGrant | undefined, nowMs: number): number | null { + if (effectiveStatus(grant, nowMs) !== 'admitted') { + return null; + } + return grant?.admittedAt ?? null; +} + +/** + * Pending projection of an expired trial. Keeps `appliedAt` and the last + * decision actor/time; clears `trialUtcDate`; leaves `admittedAt` null. + * + * @param grant - Stored trial (possibly expired). + * @returns Pending grant to persist. + */ +export function expiredTrialAsPending(grant: FundingGrant): FundingGrant { + return { + accountId: grant.accountId, + status: 'pending', + appliedAt: grant.appliedAt, + decidedAt: grant.decidedAt, + decidedBy: grant.decidedBy, + trialUtcDate: null, + admittedAt: null, + note: grant.note, + }; +} diff --git a/src/lib/message-store.ts b/src/lib/message-store.ts index fe793018..a373872a 100644 --- a/src/lib/message-store.ts +++ b/src/lib/message-store.ts @@ -68,6 +68,11 @@ function posixHashtagTokenPattern(name: string): string { return `#${name.toLowerCase().replace(POSIX_REGEX_META, '\\$&')}([^a-z0-9_]|$)`; } +function textHasHashtagToken(text: string, name: string): boolean { + const escaped = name.replace(/[\\^$.|?*+()[\]{}]/g, '\\$&'); + return new RegExp(`#${escaped}(?![A-Za-z0-9_])`, 'i').test(text); +} + function extraHashtagBindings( extraHashtagsByAccountId: ReadonlyMap | undefined, ): { accountIds: string[]; patterns: string[] } | null { @@ -114,6 +119,8 @@ export type MessageFeedQuery = { cursor: { k: 't'; c: Date; i: string } | { k: 's'; s: number; c: Date; i: string } | null; /** Founder + moderator account ids; used only when mode==='active'. */ staffAccountIds: ReadonlySet; + /** Optional hashtag name without `#`. When set, only notes whose `text` contains that token. */ + hashtag?: string; }; /** Top-level list row with computed reply count. */ @@ -156,7 +163,7 @@ export interface MessageStore { * {@link listLatest} (live attributed direct children). Never * selects `photo` bytea. Replies and soft-hidden rows are excluded. * - * @param query - Mode, limit, exclusive cursor, and staff ids (`active` only). + * @param query - Mode, limit, exclusive cursor, staff ids (`active` only), and optional hashtag. * @returns At most `query.limit` list row copies. */ listFeed(query: MessageFeedQuery): Promise; @@ -1325,7 +1332,7 @@ export class InMemoryMessageStore implements MessageStore { * null), capped at `query.limit`, with `replyCount` of live attributed * children (`deletedAt` null and either an account or a recorded zapper pubkey). * - * @param query - Mode, limit, exclusive keyset cursor, and staff ids. + * @param query - Mode, limit, exclusive keyset cursor, staff ids, and optional hashtag. * @returns A new array of list row copies; mutating it does not change the store. */ listFeed(query: MessageFeedQuery): Promise { @@ -1344,7 +1351,12 @@ export class InMemoryMessageStore implements MessageStore { } return true; }); - const sorted = [...topLevel].sort((a, b) => { + const hashtag = query.hashtag; + const tagged = + typeof hashtag === 'string' && hashtag !== '' + ? topLevel.filter((row) => textHasHashtagToken(row.text, hashtag)) + : topLevel; + const sorted = [...tagged].sort((a, b) => { if (query.mode === 'popular') { const bySats = b.sats - a.sats; if (bySats !== 0) { @@ -2688,7 +2700,7 @@ export class PostgresMessageStore implements MessageStore { * Same {@link MESSAGE_SELECT_COLUMNS} as {@link listLatest} — never the * `photo` bytea column. * - * @param query - Mode, limit, exclusive keyset cursor, and staff ids. + * @param query - Mode, limit, exclusive keyset cursor, staff ids, and optional hashtag. * @returns Mapped list rows. */ async listFeed(query: MessageFeedQuery): Promise { @@ -2704,6 +2716,11 @@ export class PostgresMessageStore implements MessageStore { filters.push('sats > 0'); orderBy = 'sats DESC, created_at DESC, id DESC'; } + const hashtag = query.hashtag; + if (typeof hashtag === 'string' && hashtag !== '') { + params.push(posixHashtagTokenPattern(hashtag)); + filters.push(`text ~* $${params.length}`); + } if (query.cursor !== null) { if (query.mode === 'popular') { if (query.cursor.k === 's') { diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 38bf12e8..26eb4127 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -10,6 +10,7 @@ import { } from '@/lib/auth/passkey'; import { serializeOwnerAccountWithPosts } from '@/lib/auth/account-json'; import { WRONG_ACCOUNT_ERROR } from '@/lib/auth/wrong-account'; +import { InMemoryFundingStore, type FundingStore } from '@/lib/funding-store'; import type { AuthStore } from '@/lib/auth/store'; import type { PasskeyCeremony } from '@/lib/auth/webauthn'; import { logEvent } from '@/lib/log'; @@ -40,6 +41,10 @@ export interface AuthRouteDeps { nostrKek?: Uint8Array; /** Optional keygen (tests). */ nostrKeygen?: NostrKeygen; + /** + * Funding grants for owner JSON (default: empty {@link InMemoryFundingStore}). + */ + fundingStore?: FundingStore; } /** Body schema for passkey finish (registration or authentication). */ @@ -115,7 +120,11 @@ export function authRoutes(deps: AuthRouteDeps): Hono { return c.json( { token: result.value.token, - account: await serializeOwnerAccountWithPosts(result.value.account, deps.messages), + account: await serializeOwnerAccountWithPosts(result.value.account, deps.messages, { + store: deps.fundingStore ?? new InMemoryFundingStore(), + nowMs: deps.now(), + authStore: deps.store, + }), }, 200, ); @@ -160,7 +169,11 @@ export function authRoutes(deps: AuthRouteDeps): Hono { return c.json( { token: result.value.token, - account: await serializeOwnerAccountWithPosts(result.value.account, deps.messages), + account: await serializeOwnerAccountWithPosts(result.value.account, deps.messages, { + store: deps.fundingStore ?? new InMemoryFundingStore(), + nowMs: deps.now(), + authStore: deps.store, + }), }, 200, ); diff --git a/src/routes/conversations.ts b/src/routes/conversations.ts index 448bac21..1aa44c93 100644 --- a/src/routes/conversations.ts +++ b/src/routes/conversations.ts @@ -16,11 +16,20 @@ import { type PublicConversation, } from '@/lib/conversation'; import { notifyConversationMessage } from '@/lib/conversation-push'; -import type { ConversationStore } from '@/lib/conversation-store'; +import type { ConversationStore, ConversationThreadPageQuery } from '@/lib/conversation-store'; import { logEvent } from '@/lib/log'; import type { FetchFn } from '@/lib/lnurlp'; import { requestZapInvoice } from '@/lib/lnurl-pay'; -import { MESSAGE_LIST_LIMIT, normalizeForumText, truncatePubkeyDisplay } from '@/lib/message'; +import { + decodeForumPhoto, + decodeMessageFeedCursor, + encodeMessageFeedCursor, + forumPhotoResponse, + MESSAGE_LIST_LIMIT, + normalizeForumText, + truncatePubkeyDisplay, + type ForumPhoto, +} from '@/lib/message'; import type { MessageInvoiceAttempt, MessageInvoiceResult, @@ -33,6 +42,8 @@ import { signEventForAccount } from '@/lib/nostr/sign'; import { buildZapRequest } from '@/lib/nostr/zap-request'; import type { NotificationStore } from '@/lib/notification-store'; import type { PushStore } from '@/lib/push-store'; +import { eligibleToday } from '@/lib/funding'; +import { InMemoryFundingStore, type FundingStore } from '@/lib/funding-store'; import type { SpendPing } from '@/lib/spend-ping'; import { bearerToken } from '@/routes/me'; import { WAIT_SATS_POLL_MS, WAIT_SATS_TIMEOUT_MS } from '@/routes/messages'; @@ -55,6 +66,11 @@ export interface ConversationRouteDeps { now: () => number; /** Optional spend ping after a new moderator-group message. */ spendPing?: SpendPing; + /** + * Funding grants for moderator-group spend-ping eligibility (default: + * empty {@link InMemoryFundingStore}). + */ + fundingStore?: FundingStore; /** LNURL fetch (invoice path). */ fetchImpl?: FetchFn; /** Optional AES KEK; without it invoice signing is 503. */ @@ -74,8 +90,34 @@ export interface ConversationRouteDeps { } const CONVERSATION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const CONVERSATION_PHOTO_FILE_RE = /^([1-9])\.(jpg|jpeg|png|webp)$/; -const textBody = z.object({ text: z.string() }); +const conversationMessageBody = z + .object({ + text: z.string().optional(), + photo: z + .object({ + contentType: z.string(), + data: z.string(), + }) + .optional(), + photos: z + .array( + z.object({ + contentType: z.string(), + data: z.string(), + }), + ) + .max(10) + .optional(), + video: z.never().optional(), + }) + .refine( + (body) => + body.text !== undefined || + body.photo !== undefined || + (Array.isArray(body.photos) && body.photos.length > 0), + ); const forumMessageBody = z.object({ forumMessageId: z.string() }); const invoiceBody = z.object({ sats: z.number().int().positive(), text: z.string().optional() }); const defaultInvoiceLimiter = new InvoiceRateLimiter(); @@ -337,13 +379,71 @@ async function publicThread( ); } +/** + * Authenticated photo bytes for a conversation message still. + * + * Same handler for `/photo` (index 0) and `/photo/:file` (indices 1–9). + * Bearer + canAccess; bytes are never public. Index 0 is `/photo` only + * (no Damus `.jpg` aliases). + * + * @param deps - Conversation collaborators. + * @param conversationId - Path conversation id. + * @param messageId - Path message id. + * @param header - Authorization header (Bearer session). + * @param index - Extra still index (1–9). Omitted = photo 0 (`getPhoto`). + * @returns 200 bytes, 401, 404, or 503. + */ +async function serveConversationPhoto( + deps: ConversationRouteDeps, + conversationId: string, + messageId: string, + header: string | undefined, + index?: number, +): Promise { + const account = await authedAccount(deps, header); + if (account === null) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + if (!CONVERSATION_ID_RE.test(conversationId)) { + return Response.json({ error: 'Not found' }, { status: 404 }); + } + if (!CONVERSATION_ID_RE.test(messageId)) { + return Response.json({ error: 'Photo not found' }, { status: 404 }); + } + try { + const thread = await deps.store.getById(conversationId); + const platform = await platformAccount(deps.authStore); + if (thread === undefined || !canAccess(thread, account, platform?.id ?? null)) { + return Response.json({ error: 'Not found' }, { status: 404 }); + } + const row = await deps.store.getMessageById(messageId); + if (row === undefined || row.conversationId !== conversationId) { + return Response.json({ error: 'Photo not found' }, { status: 404 }); + } + const photo = + index === undefined + ? await deps.store.getPhoto(messageId) + : await deps.store.getExtraPhoto(messageId, index); + if (photo === null) { + return Response.json({ error: 'Photo not found' }, { status: 404 }); + } + const res = forumPhotoResponse(photo); + res.headers.set('Cache-Control', 'private, no-store'); + res.headers.delete('Access-Control-Allow-Origin'); + return res; + } catch { + logEvent('conversations.photo.failed'); + return Response.json({ error: 'Conversations are unavailable' }, { status: 503 }); + } +} + /** * Build the `/conversations` route group. `GET /` lists the inbox and never * pins `moderator_group`; `GET /moderator-group` is the closed-group tool - * for anyone at least moderator. + * for anyone at least moderator. Photo GET routes register before `GET /:id`. * * @param deps - Stores, clock, optional spend ping, invoice collaborators, wait injects, and optional push and notification stores. - * @returns A Hono app with list/open/read/reply/invoice routes and GET `/moderator-group`. + * @returns A Hono app with list/open/read/reply/invoice/photo routes and GET `/moderator-group`. */ export function conversationRoutes(deps: ConversationRouteDeps): Hono { const invoiceLimiter = deps.invoiceLimiter ?? defaultInvoiceLimiter; @@ -517,6 +617,30 @@ export function conversationRoutes(deps: ConversationRouteDeps): Hono { return c.json({ error: 'Conversations are unavailable' }, 503); } }) + .get('/:id/messages/:messageId/photo/:file', async (c) => { + if ((await authedAccount(deps, c.req.header('authorization'))) === null) { + return c.json({ error: 'Unauthorized' }, 401); + } + const match = CONVERSATION_PHOTO_FILE_RE.exec(c.req.param('file')); + if (match === null) { + return c.json({ error: 'Photo not found' }, 404); + } + return serveConversationPhoto( + deps, + c.req.param('id'), + c.req.param('messageId'), + c.req.header('authorization'), + Number(match[1]), + ); + }) + .get('/:id/messages/:messageId/photo', async (c) => + serveConversationPhoto( + deps, + c.req.param('id'), + c.req.param('messageId'), + c.req.header('authorization'), + ), + ) .get('/:id', async (c) => { const account = await authedAccount(deps, c.req.header('authorization')); if (account === null) { @@ -530,6 +654,28 @@ export function conversationRoutes(deps: ConversationRouteDeps): Hono { if (sinceMessageId !== undefined && !CONVERSATION_ID_RE.test(sinceMessageId)) { return c.json({ error: 'Expected sinceMessageId to be a UUID' }, 400); } + const limitQuery = c.req.query('limit'); + let limit: number; + if (limitQuery === undefined) { + limit = CONVERSATION_LIST_LIMIT; + } else if (/^\d+$/.test(limitQuery)) { + const n = Number(limitQuery); + if (n < 1 || n > CONVERSATION_LIST_LIMIT) { + return c.json({ error: 'Invalid limit' }, 400); + } + limit = n; + } else { + return c.json({ error: 'Invalid limit' }, 400); + } + const cursorQuery = c.req.query('cursor'); + let cursor: ConversationThreadPageQuery['cursor'] = null; + if (cursorQuery !== undefined) { + const decoded = decodeMessageFeedCursor(cursorQuery); + if (decoded === null || decoded.k !== 't' || !CONVERSATION_ID_RE.test(decoded.i)) { + return c.json({ error: 'Invalid cursor' }, 400); + } + cursor = { c: new Date(decoded.c), i: decoded.i }; + } try { const thread = await deps.store.getById(id); const platform = await platformAccount(deps.authStore); @@ -547,21 +693,38 @@ export function conversationRoutes(deps: ConversationRouteDeps): Hono { } await sleep(pollMs); } - const rows = await deps.store.listMessages(id, CONVERSATION_LIST_LIMIT); + const rows = await deps.store.listThreadPage({ + conversationId: id, + limit, + cursor: sinceMessageId === undefined ? cursor : null, + }); + const messages = rows.map((row) => + serializeConversationMessage( + row, + conversationFromMe({ + senderAccountId: row.senderAccountId, + actorAccountId: row.actorAccountId ?? null, + viewerId: account.id, + }), + { staff: roleAtLeast(account.role, 'moderator') }, + ), + ); + const oldest = rows[0]; + const nextCursor = + rows.length === limit && oldest !== undefined + ? encodeMessageFeedCursor({ + k: 't', + c: oldest.createdAt.toISOString(), + i: oldest.id, + }) + : undefined; return c.json( - { - messages: rows.map((row) => - serializeConversationMessage( - row, - conversationFromMe({ - senderAccountId: row.senderAccountId, - actorAccountId: row.actorAccountId ?? null, - viewerId: account.id, - }), - { staff: roleAtLeast(account.role, 'moderator') }, - ), - ), - }, + nextCursor === undefined + ? { messages } + : { + messages, + nextCursor, + }, 200, ); } catch { @@ -600,20 +763,64 @@ export function conversationRoutes(deps: ConversationRouteDeps): Hono { if (!CONVERSATION_ID_RE.test(id)) { return c.json({ error: 'Not found' }, 404); } - const parsed = textBody.safeParse(await c.req.json().catch(() => null)); + const raw: unknown = await c.req.json().catch(() => null); + if ( + raw !== null && + typeof raw === 'object' && + Array.isArray((raw as { photos?: unknown }).photos) && + (raw as { photos: unknown[] }).photos.length > 10 + ) { + return c.json({ error: 'At most 10 photos' }, 400); + } + const parsed = conversationMessageBody.safeParse(raw); if (!parsed.success) { - return c.json({ error: 'Expected a JSON body with a "text" string' }, 400); + return c.json({ error: 'Expected a JSON body with text and/or photo' }, 400); } - const text = normalizeForumText(parsed.data.text); - if (text === null || text === '') { + const text = normalizeForumText(parsed.data.text ?? ''); + if (text === null) { return c.json({ error: 'Text must be 1–500 characters' }, 400); } + let photo: ForumPhoto | undefined; + let extraPhotos: ForumPhoto[] = []; + const gallery = parsed.data.photos; + if (gallery !== undefined && gallery.length > 0) { + const decodedGallery: ForumPhoto[] = []; + for (const item of gallery) { + const decoded = decodeForumPhoto(item.contentType, item.data); + if (decoded === null) { + return c.json({ error: 'Photo must be a JPEG, PNG, or WebP under 1 MiB' }, 400); + } + decodedGallery.push(decoded); + } + const [first, ...rest] = decodedGallery; + /* v8 ignore next 3 -- gallery.length > 0 after successful decode */ + if (first === undefined) { + return c.json({ error: 'Photo must be a JPEG, PNG, or WebP under 1 MiB' }, 400); + } + photo = first; + extraPhotos = rest; + } else if (parsed.data.photo !== undefined) { + const decoded = decodeForumPhoto(parsed.data.photo.contentType, parsed.data.photo.data); + if (decoded === null) { + return c.json({ error: 'Photo must be a JPEG, PNG, or WebP under 1 MiB' }, 400); + } + photo = decoded; + } try { const thread = await deps.store.getById(id); const platform = await platformAccount(deps.authStore); if (thread === undefined || !canAccess(thread, account, platform?.id ?? null)) { return c.json({ error: 'Not found' }, 404); } + if (thread.kind !== 'moderator_group' && photo !== undefined) { + return c.json({ error: 'Photos are only allowed in the Moderators group' }, 400); + } + if (thread.kind === 'moderator_group' && text === '' && photo === undefined) { + return c.json({ error: 'Text must be 1–500 characters or include a photo' }, 400); + } + if (thread.kind !== 'moderator_group' && text === '') { + return c.json({ error: 'Text must be 1–500 characters' }, 400); + } const staffOnPlatform = thread.kind !== 'moderator_group' && roleAtLeast(account.role, 'moderator') && @@ -628,30 +835,34 @@ export function conversationRoutes(deps: ConversationRouteDeps): Hono { return c.json({ error: 'Set a name before posting' }, 400); } const actorName = account.name?.trim() ?? ''; - const created = await deps.store.appendMessage({ - id: crypto.randomUUID(), - conversationId: thread.id, - text, - createdAt: new Date(deps.now()), - senderAccountId: sender.id, - senderPubkey: (await deps.authStore.getNostrPublicKey(sender.id)) ?? null, - name: senderName !== '' ? senderName : '21.gifts', - ...(thread.kind === 'moderator_group' - ? { - sats: 0, - eventId: null, - nostrPublishState: 'skipped' as const, - nostrEvent: null, - claimedUntil: null, - actorAccountId: account.id, - actorName, - } - : { - ...unsignedConversationDefaults(), - actorAccountId: account.id, - actorName, - }), - }); + const created = await deps.store.appendMessage( + { + id: crypto.randomUUID(), + conversationId: thread.id, + text, + createdAt: new Date(deps.now()), + senderAccountId: sender.id, + senderPubkey: (await deps.authStore.getNostrPublicKey(sender.id)) ?? null, + name: senderName !== '' ? senderName : '21.gifts', + ...(thread.kind === 'moderator_group' + ? { + sats: 0, + eventId: null, + nostrPublishState: 'skipped' as const, + nostrEvent: null, + claimedUntil: null, + actorAccountId: account.id, + actorName, + } + : { + ...unsignedConversationDefaults(), + actorAccountId: account.id, + actorName, + }), + }, + photo, + extraPhotos.length > 0 ? extraPhotos : undefined, + ); if (thread.kind === 'moderator_group') { try { const address = account.lightningAddress?.trim() ?? ''; @@ -662,10 +873,17 @@ export function conversationRoutes(deps: ConversationRouteDeps): Hono { deps.now(), ); if (publicToday) { - try { - await deps.spendPing.ping(address, created.id, 'moderator'); - } catch { - /* persist must not fail */ + const grant = await ( + deps.fundingStore ?? new InMemoryFundingStore() + ).getByAccountId(account.id); + if (!eligibleToday(account.role, grant, deps.now())) { + logEvent('spend.ping.skipped', { reason: 'not_eligible' }); + } else { + try { + await deps.spendPing.ping(address, created.id, 'moderator'); + } catch { + /* persist must not fail */ + } } } else { logEvent('spend.ping.skipped', { reason: 'no_public_post' }); diff --git a/src/routes/funding.ts b/src/routes/funding.ts new file mode 100644 index 00000000..e02842f8 --- /dev/null +++ b/src/routes/funding.ts @@ -0,0 +1,457 @@ +import { Hono } from 'hono'; +import { z } from 'zod'; +import { resolveSession } from '@/lib/auth/service'; +import type { Account, AuthStore } from '@/lib/auth/store'; +import { + effectiveStatus, + serializeOwnerFunding, + utcDayKey, + type FundingGrant, + type OwnerFundingJson, +} from '@/lib/funding'; +import { loadGrantEffective, type FundingStore } from '@/lib/funding-store'; +import { logEvent } from '@/lib/log'; +import { MESSAGE_LIST_LIMIT, serializeMessage, type MessageRow } from '@/lib/message'; +import type { MessageStore } from '@/lib/message-store'; +import { roleAtLeast } from '@/lib/auth/roles'; +import { isStaffRole } from '@/lib/trust'; +import { forumVideoFilePresent, resolveMediaDir } from '@/lib/video'; +import { bearerToken } from '@/routes/me'; +import { MESSAGE_ID_RE } from '@/routes/messages'; + +/** + * Member apply and staff review for funding-program grants. + * Bearer session required. Independent of `account.role` except `basis` + * cannot apply or be granted. + */ + +/** Collaborators the funding routes need. */ +export interface FundingRouteDeps { + /** Shared auth persistence port. */ + authStore: AuthStore; + /** Funding-grant persistence port. */ + fundingStore: FundingStore; + /** Forum persistence for staff application detail. */ + messageStore: MessageStore; + /** Clock returning epoch milliseconds (injected for testability). */ + now: () => number; +} + +/** Body schema for staff POSTs that target one account. */ +const accountIdBody = z.object({ accountId: z.string() }); + +/** Resolve the account behind a request's bearer session, or `null`. */ +async function authedAccount( + deps: FundingRouteDeps, + header: string | undefined, +): Promise { + const token = bearerToken(header); + if (token === null) { + return null; + } + return resolveSession(deps.authStore, deps.now(), token); +} + +/** `{ id, name, role, funding }` for a successful staff write. */ +function decisionBody( + account: Account, + grant: FundingGrant, + nowMs: number, + reviewerName: string | null, +): { + id: string; + name: string | null; + role: Account['role']; + funding: OwnerFundingJson | null; +} { + return { + id: account.id, + name: account.name, + role: account.role, + funding: serializeOwnerFunding(account.role, grant, nowMs, reviewerName), + }; +} + +/** + * Load a target account. Missing → 404. Postgres/query throw → 503. + */ +async function loadTargetAccount( + store: AuthStore, + id: string, +): Promise<{ account: Account } | { error: string; status: 404 | 503 }> { + try { + const account = await store.getAccount(id); + if (account === undefined) { + return { error: 'Not found', status: 404 }; + } + return { account }; + } catch { + logEvent('funding.write.failed'); + return { error: 'Funding is unavailable', status: 503 }; + } +} + +/** + * Delete a `hasVideo` row whose file is missing or empty. Notes without + * video are unchanged. Same drop as member posts. + * + * @param store - Message store. + * @param row - Store row. + * @returns The row, or `null` when it was deleted. + */ +async function dropMissingVideoRow( + store: MessageStore, + row: MessageRow, +): Promise { + if ( + row.hasVideo !== true || + row.videoContentType === undefined || + row.videoContentType === null + ) { + return row; + } + const present = await forumVideoFilePresent(resolveMediaDir(), row.id, row.videoContentType); + if (present) { + return row; + } + await store.deleteById(row.id); + logEvent('messages.video.dropped'); + return null; +} + +/** Staff session or a 401/403 JSON response. */ +async function requireStaff( + deps: FundingRouteDeps, + header: string | undefined, +): Promise<{ caller: Account } | { error: string; status: 401 | 403 }> { + const caller = await authedAccount(deps, header); + if (caller === null) { + return { error: 'Unauthorized', status: 401 }; + } + if (!isStaffRole(caller.role)) { + return { error: 'Forbidden', status: 403 }; + } + return { caller }; +} + +/** Parse `{ accountId }` or a 400/404. */ +function parseAccountId( + raw: unknown, +): { accountId: string } | { error: string; status: 400 | 404 } { + const parsed = accountIdBody.safeParse(raw); + if (!parsed.success) { + return { error: 'Expected a JSON body with an "accountId" string', status: 400 }; + } + if (!MESSAGE_ID_RE.test(parsed.data.accountId)) { + return { error: 'Not found', status: 404 }; + } + return { accountId: parsed.data.accountId }; +} + +/** + * Build the `/funding` route group. + * + * Mounted at `/funding` so the public paths are `POST /funding/apply`, + * `GET /funding/applications`, `GET /funding/applications/:accountId`, + * `POST /funding/trial`, `POST /funding/admit`, and `POST /funding/reject`. + * + * @param deps - Auth store, funding store, message store, and clock. + * @returns A Hono app with member apply and staff review routes. + */ +export function fundingRoutes(deps: FundingRouteDeps): Hono { + return new Hono() + .post('/apply', async (c) => { + const caller = await authedAccount(deps, c.req.header('authorization')); + if (caller === null) { + return c.json({ error: 'Unauthorized' }, 401); + } + if (!roleAtLeast(caller.role, 'verified')) { + return c.json({ error: 'Forbidden' }, 403); + } + const nowMs = deps.now(); + try { + const observed = await loadGrantEffective(deps.fundingStore, caller.id, nowMs); + const status = effectiveStatus(observed, nowMs); + if (status === 'pending' || status === 'trial' || status === 'admitted') { + return c.json({ error: 'Conflict' }, 409); + } + const grant = await deps.fundingStore.transition( + { + accountId: caller.id, + status: 'pending', + appliedAt: nowMs, + decidedAt: null, + decidedBy: null, + trialUtcDate: null, + admittedAt: null, + note: null, + }, + ['none', 'rejected'], + ); + if (grant === undefined) { + return c.json({ error: 'Conflict' }, 409); + } + logEvent('funding.applied', { accountId: caller.id }); + return c.json({ funding: serializeOwnerFunding(caller.role, grant, nowMs, null) }, 200); + } catch { + logEvent('funding.write.failed'); + return c.json({ error: 'Funding is unavailable' }, 503); + } + }) + .get('/applications', async (c) => { + const staff = await requireStaff(deps, c.req.header('authorization')); + if ('status' in staff) { + return c.json({ error: staff.error }, staff.status); + } + const nowMs = deps.now(); + try { + const grants = await deps.fundingStore.listGrants(); + const applications: Array<{ + accountId: string; + name: string | null; + role: Account['role']; + appliedAt: number; + }> = []; + for (const stored of grants) { + const grant = await loadGrantEffective(deps.fundingStore, stored.accountId, nowMs); + if (grant === undefined || effectiveStatus(grant, nowMs) !== 'pending') { + continue; + } + const account = await deps.authStore.getAccount(grant.accountId); + if (account === undefined) { + continue; + } + applications.push({ + accountId: account.id, + name: account.name, + role: account.role, + appliedAt: grant.appliedAt, + }); + } + logEvent('funding.applications.listed', { count: applications.length }); + return c.json({ applications }, 200); + } catch { + logEvent('funding.list.failed'); + return c.json({ error: 'Funding is unavailable' }, 503); + } + }) + .get('/applications/:accountId', async (c) => { + const staff = await requireStaff(deps, c.req.header('authorization')); + if ('status' in staff) { + return c.json({ error: staff.error }, staff.status); + } + const accountId = c.req.param('accountId'); + if (accountId === undefined || !MESSAGE_ID_RE.test(accountId)) { + return c.json({ error: 'Not found' }, 404); + } + const nowMs = deps.now(); + try { + const account = await deps.authStore.getAccount(accountId); + const grant = await loadGrantEffective(deps.fundingStore, accountId, nowMs); + if (account === undefined || grant === undefined) { + return c.json({ error: 'Not found' }, 404); + } + const rows = await deps.messageStore.listPostsByAccount(account.id, MESSAGE_LIST_LIMIT); + const messages = []; + for (const row of rows) { + const kept = await dropMissingVideoRow(deps.messageStore, row); + if (kept === null) { + continue; + } + const children = await deps.messageStore.listReplies(kept.id, MESSAGE_LIST_LIMIT); + let dropped = 0; + for (const child of children) { + const keptChild = await dropMissingVideoRow(deps.messageStore, child); + if (keptChild === null) { + dropped += 1; + } + } + const payable = + kept.eventId !== null && + kept.eventId !== '' && + account.lightningAddress !== null && + account.lightningAddress.trim() !== ''; + messages.push( + serializeMessage( + kept, + payable, + account.role, + Math.max(0, row.replyCount - dropped), + true, + ), + ); + } + return c.json( + { + account: { + id: account.id, + name: account.name, + role: account.role, + lightningAddress: account.lightningAddress, + }, + grant: { + status: effectiveStatus(grant, nowMs), + appliedAt: grant.appliedAt, + trialUtcDate: grant.trialUtcDate, + admittedAt: grant.admittedAt, + decidedAt: grant.decidedAt, + }, + messages, + }, + 200, + ); + } catch { + logEvent('funding.list.failed'); + return c.json({ error: 'Funding is unavailable' }, 503); + } + }) + .post('/trial', async (c) => { + const staff = await requireStaff(deps, c.req.header('authorization')); + if ('status' in staff) { + return c.json({ error: staff.error }, staff.status); + } + const parsed = parseAccountId(await c.req.json().catch(() => null)); + if ('status' in parsed) { + return c.json({ error: parsed.error }, parsed.status); + } + const loaded = await loadTargetAccount(deps.authStore, parsed.accountId); + if ('status' in loaded) { + return c.json({ error: loaded.error }, loaded.status); + } + const subject = loaded.account; + if (subject.id === staff.caller.id) { + return c.json({ error: 'Conflict' }, 409); + } + if (subject.role === 'basis') { + return c.json({ error: 'Conflict' }, 409); + } + const nowMs = deps.now(); + try { + const observed = await loadGrantEffective(deps.fundingStore, subject.id, nowMs); + if (observed === undefined || effectiveStatus(observed, nowMs) !== 'pending') { + return c.json({ error: 'Conflict' }, 409); + } + const appliedAt = observed.appliedAt; + const grant = await deps.fundingStore.transition( + { + accountId: subject.id, + status: 'trial', + appliedAt, + decidedAt: nowMs, + decidedBy: staff.caller.id, + trialUtcDate: utcDayKey(nowMs), + admittedAt: null, + note: observed.note, + }, + ['pending'], + ); + if (grant === undefined) { + return c.json({ error: 'Conflict' }, 409); + } + logEvent('funding.trial', { accountId: subject.id, actorId: staff.caller.id }); + return c.json(decisionBody(subject, grant, nowMs, staff.caller.name), 200); + } catch { + logEvent('funding.write.failed'); + return c.json({ error: 'Funding is unavailable' }, 503); + } + }) + .post('/admit', async (c) => { + const staff = await requireStaff(deps, c.req.header('authorization')); + if ('status' in staff) { + return c.json({ error: staff.error }, staff.status); + } + const parsed = parseAccountId(await c.req.json().catch(() => null)); + if ('status' in parsed) { + return c.json({ error: parsed.error }, parsed.status); + } + const loaded = await loadTargetAccount(deps.authStore, parsed.accountId); + if ('status' in loaded) { + return c.json({ error: loaded.error }, loaded.status); + } + const subject = loaded.account; + if (subject.id === staff.caller.id) { + return c.json({ error: 'Conflict' }, 409); + } + if (subject.role === 'basis') { + return c.json({ error: 'Conflict' }, 409); + } + const nowMs = deps.now(); + try { + const observed = await loadGrantEffective(deps.fundingStore, subject.id, nowMs); + const status = effectiveStatus(observed, nowMs); + if (observed === undefined || (status !== 'pending' && status !== 'trial')) { + return c.json({ error: 'Conflict' }, 409); + } + const appliedAt = observed.appliedAt; + const grant = await deps.fundingStore.transition( + { + accountId: subject.id, + status: 'admitted', + appliedAt, + decidedAt: nowMs, + decidedBy: staff.caller.id, + trialUtcDate: null, + admittedAt: nowMs, + note: observed.note, + }, + ['pending', 'trial'], + ); + if (grant === undefined) { + return c.json({ error: 'Conflict' }, 409); + } + logEvent('funding.admitted', { accountId: subject.id, actorId: staff.caller.id }); + return c.json(decisionBody(subject, grant, nowMs, staff.caller.name), 200); + } catch { + logEvent('funding.write.failed'); + return c.json({ error: 'Funding is unavailable' }, 503); + } + }) + .post('/reject', async (c) => { + const staff = await requireStaff(deps, c.req.header('authorization')); + if ('status' in staff) { + return c.json({ error: staff.error }, staff.status); + } + const parsed = parseAccountId(await c.req.json().catch(() => null)); + if ('status' in parsed) { + return c.json({ error: parsed.error }, parsed.status); + } + const loaded = await loadTargetAccount(deps.authStore, parsed.accountId); + if ('status' in loaded) { + return c.json({ error: loaded.error }, loaded.status); + } + const subject = loaded.account; + if (subject.id === staff.caller.id) { + return c.json({ error: 'Conflict' }, 409); + } + const nowMs = deps.now(); + try { + const observed = await loadGrantEffective(deps.fundingStore, subject.id, nowMs); + const status = effectiveStatus(observed, nowMs); + if (observed === undefined || (status !== 'pending' && status !== 'trial')) { + return c.json({ error: 'Conflict' }, 409); + } + const appliedAt = observed.appliedAt; + const grant = await deps.fundingStore.transition( + { + accountId: subject.id, + status: 'rejected', + appliedAt, + decidedAt: nowMs, + decidedBy: staff.caller.id, + trialUtcDate: null, + admittedAt: null, + note: observed.note, + }, + ['pending', 'trial'], + ); + if (grant === undefined) { + return c.json({ error: 'Conflict' }, 409); + } + logEvent('funding.rejected', { accountId: subject.id, actorId: staff.caller.id }); + return c.json(decisionBody(subject, grant, nowMs, staff.caller.name), 200); + } catch { + logEvent('funding.write.failed'); + return c.json({ error: 'Funding is unavailable' }, 503); + } + }); +} diff --git a/src/routes/invoices.ts b/src/routes/invoices.ts index 9f14d460..5c4dff19 100644 --- a/src/routes/invoices.ts +++ b/src/routes/invoices.ts @@ -12,6 +12,8 @@ import type { FetchFn } from '@/lib/lnurlp'; import { MESSAGE_LIST_LIMIT, unsignedNostrDefaults } from '@/lib/message'; import type { MessageStore } from '@/lib/message-store'; import { preimageMatchesHash } from '@/lib/proof'; +import { eligibleToday } from '@/lib/funding'; +import { InMemoryFundingStore, type FundingStore } from '@/lib/funding-store'; import { checkSpendAuth } from '@/lib/spend-auth'; import { NoopGiftRecorder, @@ -22,16 +24,15 @@ import { logEvent } from '@/lib/log'; import { MESSAGE_ID_RE } from '@/routes/messages'; /** - * Spend-worker invoice routes: check passkey eligibility and a live - * top-level forum post, fetch a recipient BOLT11 via LNURL-pay, then accept - * the payment preimage as proof. A proof with `messageId` attaches a platform - * gift-reply when that message is a top-level post. If `messageId` is already - * a reply, the proof persists a deterministic `spendGiftReplyId` marker - * under that reply, `markDeleted` so live `listReplies` omits it, then - * `addSats`s the reply. A live existing marker is `markDeleted` only and - * does not `addSats`. Platform gift-replies do not notify. A proof with - * `groupMessageId` attaches a platform stipend message in the closed - * Moderators group. The api does not pay. + * Spend-worker invoice routes: check passkey eligibility, a funding grant + * (`eligibleToday`), and a live top-level forum post, fetch a recipient + * BOLT11 via LNURL-pay, then accept the payment preimage as proof. A proof + * with `messageId` attaches a platform gift-reply when that message is a + * top-level post. If `messageId` is already a reply, the proof persists a + * deterministic `spendGiftReplyId` marker under that reply, `markDeleted` + * so live `listReplies` omits it, then `addSats`s the reply. A live existing + * marker is `markDeleted` only and does not `addSats`. Platform gift-replies + * do not notify. The api does not pay. */ /** Collaborators the invoice routes need. */ @@ -79,6 +80,11 @@ export interface InvoiceRouteDeps { * when undefined, a `groupMessageId` is accepted but ignored (display only). */ conversationStore?: Pick; + /** + * Funding grants for spend eligibility (default: empty + * {@link InMemoryFundingStore}). + */ + fundingStore?: FundingStore; } const ISSUE_ERROR = 'Lightning Address did not issue an invoice'; @@ -193,11 +199,12 @@ async function addressHasPosted( * Build the `/invoices` route group. * * @param deps - Token, invoice store, auth store, message store, clock, fetch, - * optional gift recorder, optional conversation store. + * optional gift recorder, optional conversation store, optional funding store. * @returns Hono app mounted at `/invoices`. */ export function invoiceRoutes(deps: InvoiceRouteDeps): Hono { const giftRecorder = deps.giftRecorder ?? new NoopGiftRecorder(); + const fundingStore = deps.fundingStore ?? new InMemoryFundingStore(); async function persistProvenGift(invoice: GiftInvoice, paidAtMs: number): Promise { try { @@ -376,6 +383,27 @@ export function invoiceRoutes(deps: InvoiceRouteDeps): Hono { const hasPasskey = await addressHasPasskey(deps.authStore, address); return c.json({ hasPasskey }, 200); }) + .get('/eligible', async (c) => { + const denied = authGate( + checkSpendAuth(deps.spendApiToken, c.req.header('Authorization')), + (body, status) => c.json(body, status), + ); + if (denied !== null) { + return denied; + } + + const address = normalizeLightningAddress(c.req.query('address') ?? ''); + if (address === null) { + return c.json({ error: 'Not a valid Lightning Address (expected name@domain)' }, 400); + } + + const account = await deps.authStore.getAccountByLightningAddress(address); + if (account === undefined) { + return c.json({ eligible: false }, 200); + } + const grant = await fundingStore.getByAccountId(account.id); + return c.json({ eligible: eligibleToday(account.role, grant, deps.now()) }, 200); + }) .get('/posted', async (c) => { const denied = authGate( checkSpendAuth(deps.spendApiToken, c.req.header('Authorization')), @@ -457,6 +485,12 @@ export function invoiceRoutes(deps: InvoiceRouteDeps): Hono { return c.json({ error: 'Passkey required' }, 403); } + const grant = await fundingStore.getByAccountId(account.id); + if (!eligibleToday(account.role, grant, deps.now())) { + logEvent('invoice.funding_required', { address }); + return c.json({ error: 'Funding grant required' }, 403); + } + let resolvedGroupMessageId: string | undefined; if (parsed.data.messageId !== undefined) { const message = await deps.messageStore.getById(parsed.data.messageId); diff --git a/src/routes/me.ts b/src/routes/me.ts index 0f0d587a..7051eb66 100644 --- a/src/routes/me.ts +++ b/src/routes/me.ts @@ -1,7 +1,8 @@ import { Hono } from 'hono'; import { z } from 'zod'; import { buildAccountActivity } from '@/lib/account-activity'; -import { serializeOwnerAccountWithPosts } from '@/lib/auth/account-json'; +import { serializeOwnerAccountWithPosts, type OwnerAccountResponse } from '@/lib/auth/account-json'; +import { InMemoryFundingStore, type FundingStore } from '@/lib/funding-store'; import { ensureProfileMessage } from '@/lib/auth/profile-message'; import { MISSING_REQUIREMENTS_ERROR } from '@/lib/auth/requirements'; import { resolveSession } from '@/lib/auth/service'; @@ -80,6 +81,10 @@ export interface MeRouteDeps { * Missing fiat never 503s the page. */ fiatRates?: FiatRateBook; + /** + * Funding grants for owner JSON (default: empty {@link InMemoryFundingStore}). + */ + fundingStore?: FundingStore; } /** @@ -166,10 +171,19 @@ const notificationLevelBody = z.object({ level: z.enum(['all', 'active', 'mentions']), }); +/** Owner JSON including the live funding grant. */ +function ownerJson(deps: MeRouteDeps, account: Account): Promise { + return serializeOwnerAccountWithPosts(account, deps.messages, { + store: deps.fundingStore ?? new InMemoryFundingStore(), + nowMs: deps.now(), + authStore: deps.store, + }); +} + /** * Build the `/me` route group. * - * @param deps - Shared store, message store, clock, payer, fetch, optional push, optional notification and conversation stores, optional gift/rate/fiat stores for activity, and optional `nostrKek` for the NIP-57 mint probe. + * @param deps - Shared store, message store, clock, payer, fetch, optional push, optional notification and conversation stores, optional gift/rate/fiat stores for activity, optional funding store, and optional `nostrKek` for the NIP-57 mint probe. * @returns A Hono app exposing account, activity, display-name, username, location, About me, setup skip, forum-laws dismiss, * living-room rules agreement, notification level, link/unlink, and verification routes. */ @@ -197,7 +211,7 @@ export function meRoutes(deps: MeRouteDeps): Hono { if (isWrongAccount(account)) { return c.json({ error: WRONG_ACCOUNT_ERROR }, 403); } - return c.json(await serializeOwnerAccountWithPosts(account, deps.messages), 200); + return c.json(await ownerJson(deps, account), 200); }) .get('/activity', async (c) => { const account = await authedAccount(deps, c.req.header('authorization')); @@ -244,7 +258,7 @@ export function meRoutes(deps: MeRouteDeps): Hono { : { ...current, lightningAddressSkippedAt: skippedAt }; await deps.store.updateAccount(updated); logEvent('account.setup.skipped', { accountId: current.id, step: parsed.data.step }); - return c.json(await serializeOwnerAccountWithPosts(updated, deps.messages), 200); + return c.json(await ownerJson(deps, updated), 200); }) .post('/name', async (c) => { const account = await authedAccount(deps, c.req.header('authorization')); @@ -314,7 +328,7 @@ export function meRoutes(deps: MeRouteDeps): Hono { return c.json({ error: 'Unauthorized' }, 401); } logEvent('account.name.set', { accountId: current.id }); - return c.json(await serializeOwnerAccountWithPosts(stored, deps.messages), 200); + return c.json(await ownerJson(deps, stored), 200); }) .post('/username', async (c) => { const account = await authedAccount(deps, c.req.header('authorization')); @@ -359,7 +373,7 @@ export function meRoutes(deps: MeRouteDeps): Hono { return c.json({ error: 'Username is already in use' }, 409); } logEvent('account.username.set', { accountId: current.id }); - return c.json(await serializeOwnerAccountWithPosts(stored, deps.messages), 200); + return c.json(await ownerJson(deps, stored), 200); }) .post('/location', async (c) => { const account = await authedAccount(deps, c.req.header('authorization')); @@ -382,7 +396,7 @@ export function meRoutes(deps: MeRouteDeps): Hono { const updated: Account = { ...current, location: normalized.value }; await deps.store.updateAccount(updated); logEvent('account.location.set', { accountId: current.id }); - return c.json(await serializeOwnerAccountWithPosts(updated, deps.messages), 200); + return c.json(await ownerJson(deps, updated), 200); }) .get('/about/photo', async (c) => { const account = await authedAccount(deps, c.req.header('authorization')); @@ -478,7 +492,7 @@ export function meRoutes(deps: MeRouteDeps): Hono { return c.json({ error: 'Unauthorized' }, 401); } logEvent('account.about.set', { accountId: latest.id }); - return c.json(await serializeOwnerAccountWithPosts(latest, deps.messages), 200); + return c.json(await ownerJson(deps, latest), 200); } if (noteId === undefined) { const messageId = crypto.randomUUID(); @@ -609,7 +623,7 @@ export function meRoutes(deps: MeRouteDeps): Hono { return c.json({ error: 'Unauthorized' }, 401); } logEvent('account.about.set', { accountId: latest.id }); - return c.json(await serializeOwnerAccountWithPosts(latest, deps.messages), 200); + return c.json(await ownerJson(deps, latest), 200); } catch { logEvent('account.about.failed'); return c.json({ error: 'Messages are unavailable' }, 503); @@ -626,12 +640,12 @@ export function meRoutes(deps: MeRouteDeps): Hono { return c.json({ error: 'Unauthorized' }, 401); } if (current.forumLawsDismissed === true) { - return c.json(await serializeOwnerAccountWithPosts(current, deps.messages), 200); + return c.json(await ownerJson(deps, current), 200); } const updated: Account = { ...current, forumLawsDismissed: true }; await deps.store.updateAccount(updated); logEvent('account.forum_laws.dismissed', { accountId: current.id }); - return c.json(await serializeOwnerAccountWithPosts(updated, deps.messages), 200); + return c.json(await ownerJson(deps, updated), 200); }) .post('/notification-level', async (c) => { const account = await authedAccount(deps, c.req.header('authorization')); @@ -656,7 +670,7 @@ export function meRoutes(deps: MeRouteDeps): Hono { accountId: current.id, level: parsed.data.level, }); - return c.json(await serializeOwnerAccountWithPosts(updated, deps.messages), 200); + return c.json(await ownerJson(deps, updated), 200); }) .post('/rules-agreement', async (c) => { const account = await authedAccount(deps, c.req.header('authorization')); @@ -669,12 +683,12 @@ export function meRoutes(deps: MeRouteDeps): Hono { return c.json({ error: 'Unauthorized' }, 401); } if (current.rulesAgreedAt !== null) { - return c.json(await serializeOwnerAccountWithPosts(current, deps.messages), 200); + return c.json(await ownerJson(deps, current), 200); } const updated: Account = { ...current, rulesAgreedAt: deps.now() }; await deps.store.updateAccount(updated); logEvent('account.rules_agreement.set', { accountId: current.id }); - return c.json(await serializeOwnerAccountWithPosts(updated, deps.messages), 200); + return c.json(await ownerJson(deps, updated), 200); }) .post('/lightning-address', async (c) => { const account = await authedAccount(deps, c.req.header('authorization')); @@ -780,7 +794,7 @@ export function meRoutes(deps: MeRouteDeps): Hono { accountId: account.id, address, }); - return c.json(await serializeOwnerAccountWithPosts(live, deps.messages), 200); + return c.json(await ownerJson(deps, live), 200); }) .delete('/lightning-address', async (c) => { const account = await authedAccount(deps, c.req.header('authorization')); @@ -801,7 +815,7 @@ export function meRoutes(deps: MeRouteDeps): Hono { await deps.store.updateAccount(updated); await deps.store.deleteVerification(account.id); logEvent('account.lightning_address.unlinked', { accountId: account.id }); - return c.json(await serializeOwnerAccountWithPosts(updated, deps.messages), 200); + return c.json(await ownerJson(deps, updated), 200); }) .post('/lightning-address/verification', async (c) => { const account = await authedAccount(deps, c.req.header('authorization')); @@ -863,6 +877,6 @@ export function meRoutes(deps: MeRouteDeps): Hono { } } logEvent('account.verification.confirmed', { accountId: account.id }); - return c.json(await serializeOwnerAccountWithPosts(result.account, deps.messages), 200); + return c.json(await ownerJson(deps, result.account), 200); }); } diff --git a/src/routes/members.ts b/src/routes/members.ts index 5ad160f3..03fd9892 100644 --- a/src/routes/members.ts +++ b/src/routes/members.ts @@ -10,6 +10,8 @@ import { InMemoryFiatStore, type FiatRateBook } from '@/lib/usd-fiat-store'; import { logEvent } from '@/lib/log'; import { MESSAGE_LIST_LIMIT, serializeMessage, type MessageRow } from '@/lib/message'; import type { MessageStore } from '@/lib/message-store'; +import { fundingReviewedAt } from '@/lib/funding'; +import { InMemoryFundingStore, type FundingStore } from '@/lib/funding-store'; import { accountTrust } from '@/lib/trust'; import type { TrustStore } from '@/lib/trust-store'; import { forumVideoFilePresent, resolveMediaDir } from '@/lib/video'; @@ -29,6 +31,11 @@ export interface MembersRouteDeps { messageStore: MessageStore; /** Stored trust edges for the `trust` object on GET JSON. */ trustStore: TrustStore; + /** + * Funding grants for member-card `fundingReviewedAt` (default: empty + * {@link InMemoryFundingStore}). + */ + fundingStore?: FundingStore; /** Clock returning epoch milliseconds (injected for testability). */ now: () => number; /** @@ -140,13 +147,14 @@ async function loadMember(deps: MembersRouteDeps, c: Context): Promise { @@ -288,6 +296,7 @@ export function membersRoutes(deps: MembersRouteDeps): Hono { const counts = await deps.messageStore.countByAccount(account.id); const edges = await deps.trustStore.listEdgesForSubject(account.id); const accounts = await deps.authStore.listAccounts(); + const grant = await fundingStore.getByAccountId(account.id); return c.json( { id: account.id, @@ -303,6 +312,7 @@ export function membersRoutes(deps: MembersRouteDeps): Hono { postCount: counts.postCount, replyCount: counts.replyCount, trust: accountTrust(account.id, accounts, edges), + fundingReviewedAt: fundingReviewedAt(grant, deps.now()), }, 200, ); diff --git a/src/routes/messages.ts b/src/routes/messages.ts index b0120983..77154944 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -6,6 +6,8 @@ import { roleAtLeast } from '@/lib/auth/roles'; import type { Account, AccountRole, AuthStore } from '@/lib/auth/store'; import { inspectBolt11, isNip57Invoice } from '@/lib/bolt11'; import { GIFT_INVOICE_MAX_MSAT } from '@/lib/config'; +import { eligibleToday } from '@/lib/funding'; +import { InMemoryFundingStore, type FundingStore } from '@/lib/funding-store'; import { logEvent } from '@/lib/log'; import type { FetchFn } from '@/lib/lnurlp'; import { requestZapInvoice } from '@/lib/lnurl-pay'; @@ -210,10 +212,16 @@ export interface MessagesRouteDeps { pushStore?: PushStore; /** * Optional spend ping. After a new top-level persist with a Lightning - * Address, the route awaits `ping(address, created.id)`. Omitted → skip. - * Failures are logged and do not fail the 200. + * Address, the route awaits `ping(address, created.id)` only when + * `eligibleToday`. Omitted → skip. Failures are logged and do not fail + * the 200. */ spendPing?: SpendPing; + /** + * Funding grants for spend-ping eligibility (default: empty + * {@link InMemoryFundingStore}). + */ + fundingStore?: FundingStore; /** * Optional in-app notification store. When present, living-room events * fan out via {@link notifyForumPost} / {@link notifyForumReply} to every @@ -574,7 +582,14 @@ async function persistForumPost( deps.spendPing !== undefined ) { try { - await deps.spendPing.ping(account.lightningAddress, created.id); + const grant = await (deps.fundingStore ?? new InMemoryFundingStore()).getByAccountId( + account.id, + ); + if (!eligibleToday(account.role, grant, deps.now())) { + logEvent('spend.ping.skipped', { reason: 'not_eligible' }); + } else { + await deps.spendPing.ping(account.lightningAddress, created.id); + } } catch { logEvent('spend.ping.failed'); } @@ -839,12 +854,22 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { cursor = { k: 't', c: new Date(decoded.c), i: decoded.i }; } } + const hashtagQuery = c.req.query('hashtag'); + if (hashtagQuery !== undefined && !/^[A-Za-z0-9][A-Za-z0-9_]{0,63}$/.test(hashtagQuery)) { + return c.json({ error: 'Invalid hashtag' }, 400); + } try { const staffAccountIds = mode === 'active' ? new Set(await deps.authStore.listStaffAccountIds()) : new Set(); - const rows = await deps.store.listFeed({ limit, mode, cursor, staffAccountIds }); + const rows = await deps.store.listFeed({ + limit, + mode, + cursor, + staffAccountIds, + ...(hashtagQuery === undefined ? {} : { hashtag: hashtagQuery }), + }); const maybeKept = await Promise.all( rows.map(async (row) => { const kept = await dropMissingVideoRow(deps.store, row); diff --git a/src/server.ts b/src/server.ts index 609a4daf..209fd90b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -30,6 +30,7 @@ import { debugPushRoutes } from '@/routes/debug-push'; import { debugTrustRoutes } from '@/routes/debug-trust'; import { trustChainRoutes } from '@/routes/trust-chain'; import { trustRoutes } from '@/routes/trust'; +import { fundingRoutes } from '@/routes/funding'; import { InMemoryAuthStore } from '@/lib/auth/store'; import type { AuthStore } from '@/lib/auth/store'; import { InMemoryBtcUsdStore, type BtcUsdRateBook } from '@/lib/btc-usd-store'; @@ -48,6 +49,7 @@ import type { NotificationStore } from '@/lib/notification-store'; import { resolveVapidConfig } from '@/lib/push-config'; import { InMemoryPushStore, type PushStore } from '@/lib/push-store'; import { InMemoryTrustStore, type TrustStore } from '@/lib/trust-store'; +import { InMemoryFundingStore, type FundingStore } from '@/lib/funding-store'; import { resolveAllowedOrigins } from '@/lib/config'; import { UnconfiguredInvoicePayer } from '@/lib/invoice-payer'; import type { InvoicePayer } from '@/lib/invoice-payer'; @@ -123,7 +125,7 @@ export interface AppDeps { passkeyCeremony?: PasskeyCeremony; /** * Spend-worker shared secret (default: `process.env.SPEND_API_TOKEN`). - * Unset → `GET /invoices/passkey`, `GET /invoices/posted`, `POST /invoices`, + * Unset → `GET /invoices/passkey`, `GET /invoices/eligible`, `GET /invoices/posted`, `POST /invoices`, * and `POST /invoices/proof` return 503. */ spendApiToken?: string; @@ -204,6 +206,11 @@ export interface AppDeps { * this store. */ trustStore?: TrustStore; + /** + * Stored funding grants (default: empty {@link InMemoryFundingStore}). + * Boot injects {@link PostgresFundingStore} when `DATABASE_URL` is set. + */ + fundingStore?: FundingStore; } /** @@ -220,7 +227,9 @@ export interface AppDeps { * LNURL-pay fetch, LN-Address cache, brand reader, debugToken, gift store, * gift recorder, BTC-USD rates, USD-fiat rates, message store, contact store, * conversation store, notification store, push store, trust store, - * vapidPublicKey, nostrKek, nostrPublisher, env, WebAuthn RP, spend token, spend ping, and gift invoice store. + * funding store (injected into `/funding`, `/me`, `/auth`, `/members`, + * `/messages`, `/conversations`, and `/invoices`), vapidPublicKey, nostrKek, + * nostrPublisher, env, WebAuthn RP, spend token, spend ping, and gift invoice store. * @returns A Hono app with all routes and middleware attached. */ export function createApp(deps: AppDeps = {}): Hono { @@ -243,6 +252,7 @@ export function createApp(deps: AppDeps = {}): Hono { const notificationStore = deps.notificationStore ?? new InMemoryNotificationStore(); const pushStore = deps.pushStore ?? new InMemoryPushStore(); const trustStore = deps.trustStore ?? new InMemoryTrustStore(); + const fundingStore = deps.fundingStore ?? new InMemoryFundingStore(); const vapidPublicKey = deps.vapidPublicKey ?? resolveVapidConfig(process.env)?.publicKey; const webAuthnRpId = deps.webAuthnRpId ?? process.env['WEBAUTHN_RP_ID']; const webAuthnRpName = deps.webAuthnRpName ?? process.env['WEBAUTHN_RP_NAME']; @@ -297,6 +307,7 @@ export function createApp(deps: AppDeps = {}): Hono { webAuthnRpName, passkeyCeremony, messages: messageStore, + fundingStore, ...(nostrKek === undefined ? {} : { nostrKek }), }), ); @@ -314,6 +325,7 @@ export function createApp(deps: AppDeps = {}): Hono { giftStore, rates: btcUsdRates, fiatRates, + fundingStore, ...(nostrKek === undefined ? {} : { nostrKek }), }), ); @@ -323,6 +335,7 @@ export function createApp(deps: AppDeps = {}): Hono { authStore: store, messageStore, trustStore, + fundingStore, now, giftStore, rates: btcUsdRates, @@ -388,6 +401,15 @@ export function createApp(deps: AppDeps = {}): Hono { conversationStore, }), ); + app.route( + '/funding', + fundingRoutes({ + authStore: store, + fundingStore, + messageStore, + now, + }), + ); app.route('/gifts', giftsRoutes({ store: giftStore, rates: btcUsdRates, fiatRates, now })); app.route( '/gifts/stats', @@ -404,6 +426,7 @@ export function createApp(deps: AppDeps = {}): Hono { notificationStore, conversationStore, env: deps.env ?? process.env, + fundingStore, ...(nostrKek === undefined ? {} : { nostrKek }), ...(deps.nostrPublisher === undefined ? {} : { nostrPublisher: deps.nostrPublisher }), ...(spendPing === undefined ? {} : { spendPing }), @@ -428,6 +451,7 @@ export function createApp(deps: AppDeps = {}): Hono { messageStore, now, fetchImpl, + fundingStore, ...(nostrKek === undefined ? {} : { nostrKek }), ...(spendPing === undefined ? {} : { spendPing }), pushStore, @@ -453,6 +477,7 @@ export function createApp(deps: AppDeps = {}): Hono { now, fetchImpl, conversationStore, + fundingStore, ...(giftRecorder === undefined ? {} : { giftRecorder }), }), );