From dfa46fb60979e2734dc99e7958ee26fb4680938b Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Wed, 9 Sep 2026 20:20:23 +0200 Subject: [PATCH] 01a086a5 - Hide forum posts instead of deleting rows (#105) * Hide forum posts with a delete timestamp instead of removing rows Staff DELETE /messages/:id stamps deleted_at and hides the note from reads. Rows, media, invoices, gifts, and relay copies stay. * Stamp inbound Damus replies with null deletedAt MessageRow now requires the hide fields; the inbound create path was the only src/ literal that did not spread unsignedNostrDefaults. * Format SPEC and staff-hide tests with Prettier * Cover deleted_at Date mapping and photo 404 on live notes * Document DELETE /messages/:id in CONTRIBUTING Staff hide uses markDeleted, not deleteById. --------- Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- CONTRIBUTING.md | 48 ++--- SPEC.md | 177 ++++++++++------ docs/handbook/endpoints.md | 37 ++-- docs/handbook/functions.md | 19 +- docs/schema/message.sql | 4 + e2e/forum-replies.spec.ts | 70 +++++++ e2e/http.spec.ts | 5 + src/__tests__/lib/message-store.test.ts | 188 ++++++++++++++++- src/__tests__/lib/nostr/zap-index.test.ts | 1 + src/__tests__/routes/members.test.ts | 38 ++++ src/__tests__/routes/messages.test.ts | 240 ++++++++++++++++++++++ src/lib/message-store.ts | 104 ++++++++-- src/lib/message.ts | 14 ++ src/lib/nostr/worker.ts | 2 + src/routes/members.ts | 2 +- src/routes/messages.ts | 60 +++++- 16 files changed, 875 insertions(+), 134 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bfd4f5f..6cf6978 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,7 @@ api/ │ │ ├── stats.ts # GET /gifts/stats (public gift totals) │ │ ├── gifts.ts # GET /gifts?day= (public per-day gift list) │ │ ├── invoices.ts # GET /invoices/passkey, POST /invoices, POST /invoices/proof (spend worker) -│ │ ├── messages.ts # GET/POST /messages, public GET /messages/:id, GET /messages/:id/replies, GET /messages/:id/photo, GET /messages/:id/video.*, POST /messages/:id/invoice +│ │ ├── messages.ts # GET/POST /messages, public GET /messages/:id, 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) │ │ ├── contact.ts # POST /contact (private mailbox + platform thread) │ │ └── conversations.ts # GET/POST /conversations, GET/POST /conversations/:id @@ -394,29 +394,29 @@ docker run -p 3000:3000 -e BIND_ADDR=0.0.0.0:3000 21gifts/api:dev Configuration is read from environment variables only — no config files. Currently: -| Variable | Default | Purpose | -| ---------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `BIND_ADDR` | `0.0.0.0:3000` | Listen address | -| `SERVICE_VERSION` | `0.1.0` | Surfaced via `/info` | -| `DATABASE_URL` | _(unset → in-memory)_ | Postgres connection string. When set, auth, `btc_usd_daily`, `message` (plus `message_invoice` and `nostr_zap_ingest`), `contact`, `conversation` / `conversation_message`, `push_subscription`, `push_outbox`, and `db_change` are migrated, `GET /gifts` and `GET /gifts/stats` read `gift` plus persisted BTC-USD daily closes (best-effort boot fill; failures log and do not kill the process), `GET/POST /messages`, `GET /messages/:id`, `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` and `GET/POST /conversations/:id` use `PostgresConversationStore`, `GET /debug/invoices` and `GET /debug/zap-ingests` list invoice attempts and zap ingest rows, and a matching `POST /invoices/proof` inserts into `gift`. Unset keeps `InMemoryAuthStore`, in-memory forum, contact, conversation, and push stores, empty gift stats, empty day lists, and a no-op gift recorder. | -| `DEBUG_TOKEN` | _(unset → debug off)_ | Operator bearer for `GET /debug/accounts`, `POST /debug/accounts`, `PATCH /debug/accounts/:id`, `POST /debug/accounts/:id/session`, `GET /debug/contacts`, `GET /debug/invoices`, `GET /debug/zap-ingests`, `PUT /debug/messages/:id/video`, and `POST /debug/push-ping`. 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_API_TOKEN` | _(none — optional)_ | Bearer for spend-worker `GET /invoices/passkey`, `POST /invoices`, and `POST /invoices/proof`. Unset/blank → **503**; the process still boots. | -| `BTC_USD_CANDLES_URL` | Coinbase Exchange BTC-USD candles URL | Optional override for daily close fetch used by `GET /gifts` and `GET /gifts/stats`. Blank/unset → default Coinbase URL; the process still boots. | -| `NOSTR_NSEC_KEK` | _(required with `DATABASE_URL`)_ | 32-byte hex AES-GCM KEK for custodial nsec. With `DATABASE_URL`, missing or malformed KEK **throws at boot**. Memory boots omit it. | -| `NOSTR_PUBLISH` | _(unset → sign only)_ | Set to `1` to fan out signed kind:1 notes, replaceable kind:0 profiles, and NIP-65 kind:10002 relay lists over WebSockets. Unchanged kind:0 / kind:10002 content is skipped for the life of the AuthStore instance. Other values do not publish. | -| `NOSTR_PUBLISH_PUBLIC` | _(unset → space-only published)_ | Set to `1` (with `NOSTR_PUBLISH=1`) to also write kind:1 notes, kind:0 profiles, and kind:10002 relay lists to Damus / Primal / nos.lol. Unset: space ACK is terminal `published`. Does not gate zap ingest or invoice `relays`. | -| `NOSTR_RELAY_URL` | `wss://relay.nostr.space` | Compose durability relay (nostr.space). Used when `NOSTR_RELAY_SPACE` is unset. | -| `NOSTR_RELAY_SPACE` | _(falls back to `NOSTR_RELAY_URL`)_ | Optional override of the durability relay WebSocket URL. | -| `NOSTR_RELAY_PUBLIC` | Damus, Primal, nos.lol | Optional comma-separated public relays. Used for kind:1, kind:0, and kind:10002 write when `NOSTR_PUBLISH_PUBLIC=1`, and always for zap ingest plus invoice `relays` tags (even when that flag is off). | -| `PUBLIC_BASE_URL` | _(unset → no 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. Not required at boot. Playwright pins it to `http://127.0.0.1:3000`. | -| `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`, `message` (plus `message_invoice` and `nostr_zap_ingest`), `contact`, `conversation` / `conversation_message`, `push_subscription`, `push_outbox`, and `db_change` are migrated, `GET /gifts` and `GET /gifts/stats` read `gift` plus persisted BTC-USD daily closes (best-effort boot fill; failures log and do not kill the process), `GET/POST /messages`, `GET /messages/:id`, `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` and `GET/POST /conversations/:id` use `PostgresConversationStore`, `GET /debug/invoices` and `GET /debug/zap-ingests` list invoice attempts and zap ingest rows, and a matching `POST /invoices/proof` inserts into `gift`. Unset keeps `InMemoryAuthStore`, in-memory forum, contact, conversation, and push stores, empty gift stats, empty day lists, and a no-op gift recorder. | +| `DEBUG_TOKEN` | _(unset → debug off)_ | Operator bearer for `GET /debug/accounts`, `POST /debug/accounts`, `PATCH /debug/accounts/:id`, `POST /debug/accounts/:id/session`, `GET /debug/contacts`, `GET /debug/invoices`, `GET /debug/zap-ingests`, `PUT /debug/messages/:id/video`, and `POST /debug/push-ping`. 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_API_TOKEN` | _(none — optional)_ | Bearer for spend-worker `GET /invoices/passkey`, `POST /invoices`, and `POST /invoices/proof`. Unset/blank → **503**; the process still boots. | +| `BTC_USD_CANDLES_URL` | Coinbase Exchange BTC-USD candles URL | Optional override for daily close fetch used by `GET /gifts` and `GET /gifts/stats`. Blank/unset → default Coinbase URL; the process still boots. | +| `NOSTR_NSEC_KEK` | _(required with `DATABASE_URL`)_ | 32-byte hex AES-GCM KEK for custodial nsec. With `DATABASE_URL`, missing or malformed KEK **throws at boot**. Memory boots omit it. | +| `NOSTR_PUBLISH` | _(unset → sign only)_ | Set to `1` to fan out signed kind:1 notes, replaceable kind:0 profiles, and NIP-65 kind:10002 relay lists over WebSockets. Unchanged kind:0 / kind:10002 content is skipped for the life of the AuthStore instance. Other values do not publish. | +| `NOSTR_PUBLISH_PUBLIC` | _(unset → space-only published)_ | Set to `1` (with `NOSTR_PUBLISH=1`) to also write kind:1 notes, kind:0 profiles, and kind:10002 relay lists to Damus / Primal / nos.lol. Unset: space ACK is terminal `published`. Does not gate zap ingest or invoice `relays`. | +| `NOSTR_RELAY_URL` | `wss://relay.nostr.space` | Compose durability relay (nostr.space). Used when `NOSTR_RELAY_SPACE` is unset. | +| `NOSTR_RELAY_SPACE` | _(falls back to `NOSTR_RELAY_URL`)_ | Optional override of the durability relay WebSocket URL. | +| `NOSTR_RELAY_PUBLIC` | Damus, Primal, nos.lol | Optional comma-separated public relays. Used for kind:1, kind:0, and kind:10002 write when `NOSTR_PUBLISH_PUBLIC=1`, and always for zap ingest plus invoice `relays` tags (even when that flag is off). | +| `PUBLIC_BASE_URL` | _(unset → no 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. Not required at boot. Playwright pins it to `http://127.0.0.1:3000`. | +| `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/SPEC.md b/SPEC.md index 26b6a7b..78a5a5b 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-01 (`PUT /debug/messages/:id/video`). +**Status**: living document. Last revised 2026-09-09 (`DELETE /messages/:id` soft-hide). --- @@ -57,58 +57,59 @@ Public base URLs used in examples: | PRD | `https://api.21.gifts` | `https://21.gifts` | | DEV | `https://dev-api.21.gifts` | `https://dev.21.gifts` | -| Method | Path | Auth | Purpose | -| ------ | -------------------------------------------- | ------------------------ | -------------------------------------------------------------------------- | -| GET | `/healthz` | none | Liveness | -| GET | `/info` | none | Service identity | -| GET | `/favicon.ico` | none | Brand mark (favicon) | -| GET | `/favicon.svg` | none | Brand mark (SVG favicon) | -| GET | `/apple-touch-icon.png` | none | Brand mark (Apple touch icon) | -| POST | `/auth/passkey/register/begin` | none | Issue WebAuthn creation options | -| POST | `/auth/passkey/register/finish` | none | Verify attestation, issue session | -| POST | `/auth/passkey/authenticate/begin` | none | Issue WebAuthn request options | -| POST | `/auth/passkey/authenticate/finish` | none | Verify assertion, issue session | -| GET | `/me` | `Authorization: Bearer` | Account (`setup` + factual `missing`) | -| GET | `/view/:viewKey` | none | Public profile card by view key | -| POST | `/me/setup/skip` | Bearer | Skip name or Lightning Address wizard step | -| POST | `/me/name` | Bearer | Set/replace display name (profile note when name + LN are both set) | -| POST | `/me/forum-laws-dismissed` | Bearer | Dismiss welcome-forum living-room laws | -| POST | `/me/rules-agreement` | Bearer | Record living-room rules agreement | -| POST | `/me/lightning-address` | Bearer | Link/replace after live LNURL resolve + NIP-57 mint probe | -| 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 | -| GET | `/messages` | Bearer | List top-level forum notes (+ `replyCount`); 409 if rules missing | -| POST | `/messages` | Bearer | Post text/photo; 409 if rules/name/Lightning Address missing | -| GET | `/messages/:id` | none | Public single-note JSON | -| GET | `/messages/:id/replies` | Bearer | Oldest-first replies for a parent note | -| GET | `/messages/:id/photo` | none | Fetch forum message photo bytes | -| GET | `/messages/:id/video.*` | none | Fetch forum video bytes (Range / 206) | -| 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 | -| POST | `/conversations` | Bearer | Open thread from a forum note (`forumMessageId`) | -| GET | `/conversations/:id` | Bearer | Oldest-first messages in one thread | -| POST | `/conversations/:id` | Bearer | Send `{ text }` in a private thread | -| 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` (`isPlatform`) | -| POST | `/debug/accounts/:id/session` | `Authorization: Bearer` | Operator mint of a member bearer (`DEBUG_TOKEN`) | -| GET | `/debug/contacts` | `Authorization: Bearer` | Operator contact listing (`DEBUG_TOKEN`) | -| GET | `/debug/invoices` | `Authorization: Bearer` | Operator forum invoice attempts (`DEBUG_TOKEN`) | -| GET | `/debug/zap-ingests` | `Authorization: Bearer` | Operator kind:9735 ingest log (`DEBUG_TOKEN`) | -| PUT | `/debug/messages/:id/video` | `Authorization: Bearer` | Operator restore of missing forum-video bytes (`DEBUG_TOKEN`) | -| GET | `/push/vapid-public` | Bearer | VAPID public key for Web Push subscribe | -| POST | `/me/push-subscriptions` | Bearer | Upsert a browser PushSubscription | -| DELETE | `/me/push-subscriptions` | Bearer | Remove a browser PushSubscription | -| POST | `/debug/push-ping` | Bearer `DEBUG_TOKEN` | Enqueue a test push for one account | -| GET | `/gifts` | none | Outbound gifts for one UTC day (`?day=`) | -| GET | `/gifts/stats` | none | Aggregated outbound gift statistics | -| GET | `/invoices/passkey` | Bearer `SPEND_API_TOKEN` | Whether a Lightning Address has a passkey-backed account | -| POST | `/invoices` | Bearer `SPEND_API_TOKEN` | Fetch a recipient BOLT11 (LNURL-pay; passkey 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 | `/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`) | +| GET | `/view/:viewKey` | none | Public profile card by view key | +| POST | `/me/setup/skip` | Bearer | Skip name or Lightning Address wizard step | +| POST | `/me/name` | Bearer | Set/replace display name (profile note when name + LN are both set) | +| POST | `/me/forum-laws-dismissed` | Bearer | Dismiss welcome-forum living-room laws | +| POST | `/me/rules-agreement` | Bearer | Record living-room rules agreement | +| POST | `/me/lightning-address` | Bearer | Link/replace after live LNURL resolve + NIP-57 mint probe | +| 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 | +| GET | `/messages` | Bearer | List top-level forum notes (+ `replyCount`); 409 if rules missing | +| POST | `/messages` | Bearer | Post text/photo; 409 if rules/name/Lightning Address missing | +| GET | `/messages/:id` | none | Public single-note JSON | +| GET | `/messages/:id/replies` | Bearer | Oldest-first replies for a parent note | +| GET | `/messages/:id/photo` | none | Fetch forum message photo bytes | +| GET | `/messages/:id/video.*` | none | Fetch forum video bytes (Range / 206) | +| DELETE | `/messages/:id` | Bearer (founder/moderator) | Soft-hide note + direct replies (`deleted_at` / `deleted_by`) | +| 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 | +| POST | `/conversations` | Bearer | Open thread from a forum note (`forumMessageId`) | +| GET | `/conversations/:id` | Bearer | Oldest-first messages in one thread | +| POST | `/conversations/:id` | Bearer | Send `{ text }` in a private thread | +| 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` (`isPlatform`) | +| POST | `/debug/accounts/:id/session` | `Authorization: Bearer` | Operator mint of a member bearer (`DEBUG_TOKEN`) | +| GET | `/debug/contacts` | `Authorization: Bearer` | Operator contact listing (`DEBUG_TOKEN`) | +| GET | `/debug/invoices` | `Authorization: Bearer` | Operator forum invoice attempts (`DEBUG_TOKEN`) | +| GET | `/debug/zap-ingests` | `Authorization: Bearer` | Operator kind:9735 ingest log (`DEBUG_TOKEN`) | +| PUT | `/debug/messages/:id/video` | `Authorization: Bearer` | Operator restore of missing forum-video bytes (`DEBUG_TOKEN`) | +| GET | `/push/vapid-public` | Bearer | VAPID public key for Web Push subscribe | +| POST | `/me/push-subscriptions` | Bearer | Upsert a browser PushSubscription | +| DELETE | `/me/push-subscriptions` | Bearer | Remove a browser PushSubscription | +| POST | `/debug/push-ping` | Bearer `DEBUG_TOKEN` | Enqueue a test push for one account | +| GET | `/gifts` | none | Outbound gifts for one UTC day (`?day=`) | +| GET | `/gifts/stats` | none | Aggregated outbound gift statistics | +| GET | `/invoices/passkey` | Bearer `SPEND_API_TOKEN` | Whether a Lightning Address has a passkey-backed account | +| POST | `/invoices` | Bearer `SPEND_API_TOKEN` | Fetch a recipient BOLT11 (LNURL-pay; passkey required) | +| POST | `/invoices/proof` | Bearer `SPEND_API_TOKEN` | Accept payment preimage as proof | ### `GET /healthz` @@ -1690,7 +1691,8 @@ Missing/invalid/expired bearer → **Response** `401`: { "error": "Unauthorized" } ``` -`:id` is not a UUID, or the parent is missing → **Response** `404`: +`:id` is not a UUID, the parent is missing, or the parent is soft-hidden +→ **Response** `404`: ```json { "error": "Not found" } @@ -1723,19 +1725,22 @@ Success → **Response** `200`: } ``` -An empty reply thread is **200** with `"messages": []`. +An empty reply thread is **200** with `"messages": []`. Soft-hidden +children are omitted from the list. ### `GET /messages/:id` Public single-note fetch. **No Bearer.** `:id` is a UUID. Registered -**after** photo, video, and `GET /messages/:id/replies` so those paths are -not captured as `:id`. Returns the public message JSON (`sats`, `payable`, -`hasPhoto`, `hasVideo`, `videoContentType`; live `role` for 21gifts -authors). Never includes `accountId`. Damus-only notes (`accountId` null) -omit `role` and set `payable` false. `replyCount` is omitted. Photo and -video bytes are never included. +**after** photo, video, `GET /messages/:id/replies`, and +`DELETE /messages/:id` so those paths are not captured as `:id`. Returns +the public message JSON (`sats`, `payable`, `hasPhoto`, `hasVideo`, +`videoContentType`; live `role` for 21gifts authors). Never includes +`accountId`, `deletedAt`, or `deletedBy`. Damus-only notes (`accountId` +null) omit `role` and set `payable` false. `replyCount` is omitted. Photo +and video bytes are never included. Soft-hidden rows (`deletedAt` set) +are treated as missing (404) before any missing-video hard-delete cleanup. -Non-UUID `:id` or missing row → **Response** `404`: +Non-UUID `:id`, missing row, or soft-hidden row → **Response** `404`: ```json { "error": "Not found" } @@ -1764,6 +1769,48 @@ Success → **Response** `200`: } ``` +### `DELETE /messages/:id` + +Staff soft-hide. Bearer session required. Live role must be `founder` or +`moderator` (authors with `basis` / `verified` get 403 even on their own +post). Stamps `deleted_at` / `deleted_by` on the target row and every +**direct** reply that is not yet tagged. Does **not** hard-delete the +Postgres row, photo bytes, on-disk video, invoices, zap receipts, or gift +records; does **not** call `deleteById` / `DELETE FROM message`. Already +tagged targets keep their original stamps and still return 204. +`getById` continues to return tagged rows for workers; public/member HTTP +reads treat them as missing. + +Missing/invalid/expired bearer → **Response** `401`: + +```json +{ "error": "Unauthorized" } +``` + +Live role is not founder and not moderator → **Response** `403`: + +```json +{ "error": "Forbidden" } +``` + +`:id` is not a UUID, or no row with that id → **Response** `404`: + +```json +{ "error": "Not found" } +``` + +Success (including already tagged) → **Response** `204` empty body. + +Store failure → **Response** `503`: + +```json +{ "error": "Messages are unavailable" } +``` + +On success the process logs `messages.deleted` with `messageId`, +`accountId`, and the staff `role` (never the post text). On store throw +it logs `messages.delete.failed`. + ### `POST /contact` Private in-app contact mailbox. Bearer session required. Body: @@ -1948,8 +1995,10 @@ downstream dependencies is still planned. The LUD-16 metadata cache on `GET /lightning-address` is in-memory only. Gift statistics read Postgres when `DATABASE_URL` is set. -**Moderator-only endpoints.** Content hide/unhide and related Moderator -actions. Role values exist on the account model; `GET /debug/accounts` and +**Moderator-only endpoints.** Soft-hide is implemented as +`DELETE /messages/:id` (founder/moderator session). Content **unhide** / +UNDELETE and other Moderator actions are not HTTP routes yet. Role values +exist on the account model; `GET /debug/accounts` and `PATCH /debug/accounts/:id` are operator token routes, not a moderator session. --- diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index c2b6877..42218e0 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -9,25 +9,32 @@ ## Endpoint: GET /messages/:id/video.mp4 -- **Purpose:** Public MP4 bytes as a sized body (`Content-Length` = body byte length) with `Accept-Ranges` / HTTP 206 `Content-Range` so clients can seek. Best-effort faststart (`moov` before `mdat`) on write; heal-on-read remuxes when the stored file is still mdat-first. `Access-Control-Allow-Origin: *`. After deploy, purge or wait out CDN cache for URLs previously served without `Content-Length` (chunked streams that ignored `Range`). -- **Errors:** 404 `{ error: 'Video not found' }`; 416 unsatisfiable `Range` (`Content-Range: bytes */SIZE`); 503 `{ error: 'Messages are unavailable' }`. +- **Purpose:** Public MP4 bytes as a sized body (`Content-Length` = body byte length) with `Accept-Ranges` / HTTP 206 `Content-Range` so clients can seek. Best-effort faststart (`moov` before `mdat`) on write; heal-on-read remuxes when the stored file is still mdat-first. `Access-Control-Allow-Origin: *`. Soft-hidden rows (`deletedAt` set) are 404 even when the on-disk file remains. After deploy, purge or wait out CDN cache for URLs previously served without `Content-Length` (chunked streams that ignored `Range`). +- **Errors:** 404 `{ error: 'Video not found' }` (missing / soft-hidden / wrong ext / empty); 416 unsatisfiable `Range` (`Content-Range: bytes */SIZE`); 503 `{ error: 'Messages are unavailable' }`. - **Used by:** Damus/Primal/Safari kind:1 video URLs. - **Auth:** none. ## Endpoint: GET /messages/:id/video.webm -- **Purpose:** Same as `video.mp4` for WebM posts (sized body + Range; WebM is not remuxed). +- **Purpose:** Same as `video.mp4` for WebM posts (sized body + Range; WebM is not remuxed). Soft-hidden rows are 404. - **Errors:** Same 404 / 416 / 503. - **Used by:** Damus/Primal/Safari. - **Auth:** none. ## Endpoint: GET /messages/:id/video.mov -- **Purpose:** Same as `video.mp4` for QuickTime posts (sized body + Range + faststart). +- **Purpose:** Same as `video.mp4` for QuickTime posts (sized body + Range + faststart). Soft-hidden rows are 404. - **Errors:** Same 404 / 416 / 503. - **Used by:** Damus/Primal/Safari. - **Auth:** none. +## Endpoint: DELETE /messages/:id + +- **Purpose:** Bearer required. Founder or moderator soft-hides a forum note: stamps `deleted_at` / `deleted_by` on the target and every untagged **direct** reply via `MessageStore.markDeleted`. Does not hard-delete rows, media, invoices, zap receipts, or gifts; does not call `deleteById`. Already-tagged targets keep original stamps and still return 204. Public JSON never exposes `deletedAt` / `deletedBy`. Logs `messages.deleted` with `messageId`, `accountId`, and staff `role` (never post text). +- **Errors:** 401 `{ error: 'Unauthorized' }` without a session; 403 `{ error: 'Forbidden' }` when the live role is not founder/moderator (including the author); 404 `{ error: 'Not found' }` for a non-UUID `:id` or missing row; 503 `{ error: 'Messages are unavailable' }` when the store throws (`messages.delete.failed`). +- **Used by:** Staff hide controls in the app forum. +- **Auth:** `Authorization: Bearer` session (founder or moderator). + ## Endpoint: GET /.well-known/nostr.json - **Purpose:** NIP-05 directory `{ names, relays }`. CORS `*`. Optional `?name=`. @@ -233,7 +240,7 @@ ## Endpoint: GET /members/:accountId -- **Purpose:** Bearer required. Live member profile card for `:accountId` (UUID): `id`, `name`, `role`, `lightningAddress`, ISO `createdAt`, and `profileMessage` (`serializeMessage` with `accountId` / `replyCount` like the signed-in forum list, or `null` when no note). Never includes `viewKey`, linkingKey, npub, nsec, or `eventId`. +- **Purpose:** Bearer required. Live member profile card for `:accountId` (UUID): `id`, `name`, `role`, `lightningAddress`, ISO `createdAt`, and `profileMessage` (`serializeMessage` with `accountId` / `replyCount` like the signed-in forum list, or `null` when no note or when the profile note is soft-hidden via `deletedAt`). 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. @@ -247,29 +254,29 @@ ## Endpoint: GET /messages -- **Purpose:** Bearer required. After auth, `requireAction(account, 'forum.read')` (needs rules). Lists **top-level** forum notes only (`parent_id` null) newest-first (author name snapshotted at post, `text`, ISO `createdAt`, `sats`, `payable`, `hasPhoto`, `hasVideo`, `videoContentType`, live author `role`, and `replyCount`), capped at 200 (latest-200 window). A `hasVideo` row whose file is missing or empty is deleted and omitted. For each kept top-level note, missing-file `hasVideo` direct replies in the replies window (cap 200) are deleted (`messages.video.dropped`); `replyCount` is the stored direct-reply count minus those dropped. Replies are never listed here. Clients render chronological messenger-group order (oldest top, newest bottom above the composer). Empty list is 200 `{ messages: [] }`. No photo/video bytes in JSON; signed-in list may include `accountId` (21gifts author id; omitted for Damus-only); `payable` is true when the note has an `eventId` and the author has a Lightning Address; missing author → `role` `"basis"` and `payable` false. `videoContentType` is `null` when `hasVideo` is false. +- **Purpose:** Bearer required. After auth, `requireAction(account, 'forum.read')` (needs rules). Lists **top-level** forum notes only (`parent_id` null, `deleted_at` null) newest-first (author name snapshotted at post, `text`, ISO `createdAt`, `sats`, `payable`, `hasPhoto`, `hasVideo`, `videoContentType`, live author `role`, and `replyCount` of live direct children), capped at 200 (latest-200 window). Soft-hidden top-level notes are omitted. A `hasVideo` row whose file is missing or empty is deleted and omitted. For each kept top-level note, missing-file `hasVideo` direct replies in the replies window (cap 200) are deleted (`messages.video.dropped`); `replyCount` is the live direct-reply count minus those dropped. Replies are never listed here. Clients render chronological messenger-group order (oldest top, newest bottom above the composer). Empty list is 200 `{ messages: [] }`. No photo/video bytes in JSON; signed-in list may include `accountId` (21gifts author id; omitted for Damus-only); never includes `deletedAt` / `deletedBy`; `payable` is true when the note has an `eventId` and the author has a Lightning Address; missing author → `role` `"basis"` and `payable` false. `videoContentType` is `null` when `hasVideo` is false. - **Errors:** 401 `{ error: 'Unauthorized' }` missing/invalid/expired bearer; 409 `{ error: 'missing_requirements', missing: ['rules'] }` when rules are not agreed; 503 `{ error: 'Messages are unavailable' }` if the store throws (`messages.list.failed`). - **Used by:** App public comment thread. - **Auth:** `Authorization: Bearer` session. ## Endpoint: GET /messages/:id -- **Purpose:** Public single-note fetch (no Bearer). Returns the public message JSON via `serializeMessage` (`sats`, `payable`, `hasPhoto`, `hasVideo`, `videoContentType`; live `role` for 21gifts authors; Damus-only `accountId: null` omits `role` and sets `payable` false). Never includes `accountId`. Photo/video bytes are never included. A `hasVideo` row whose file is missing or empty is deleted (`messages.video.dropped`) and then 404. -- **Errors:** 404 `{ error: 'Not found' }` when `:id` is not a UUID, the row is missing, or a missing-file video row was dropped; 503 `{ error: 'Messages are unavailable' }` when the store throws (`messages.get.failed`). +- **Purpose:** Public single-note fetch (no Bearer). Returns the public message JSON via `serializeMessage` (`sats`, `payable`, `hasPhoto`, `hasVideo`, `videoContentType`; live `role` for 21gifts authors; Damus-only `accountId: null` omits `role` and sets `payable` false). Never includes `accountId`, `deletedAt`, or `deletedBy`. Photo/video bytes are never included. Soft-hidden rows (`deletedAt` set) are 404 before any missing-video cleanup. A live `hasVideo` row whose file is missing or empty is deleted (`messages.video.dropped`) and then 404. +- **Errors:** 404 `{ error: 'Not found' }` when `:id` is not a UUID, the row is missing, soft-hidden, or a missing-file video row was dropped; 503 `{ error: 'Messages are unavailable' }` when the store throws (`messages.get.failed`). - **Used by:** App deep links / share URLs for one forum note. - **Auth:** none (public). ## Endpoint: GET /messages/:id/replies -- **Purpose:** Bearer required. Lists direct replies for parent `:id` oldest-first (`createdAt` then `id` ASC), capped at 200. A `hasVideo` reply whose file is missing or empty is deleted (`messages.video.dropped`) and omitted from `{ messages }`. Body is `{ messages: [...] }` (same key as `GET /messages`, not `replies`). Each item is public message JSON with `payable` false; may include `accountId` for 21gifts authors (omitted for Damus-only); Damus-only replies omit `role`. -- **Errors:** 401 `{ error: 'Unauthorized' }` without a session; 404 `{ error: 'Not found' }` when `:id` is not a UUID or the parent is missing; 503 `{ error: 'Messages are unavailable' }` (`messages.replies.failed`). +- **Purpose:** Bearer required. Lists direct **live** replies for parent `:id` oldest-first (`createdAt` then `id` ASC), capped at 200. Soft-hidden children are omitted. A soft-hidden or missing parent is 404. A `hasVideo` reply whose file is missing or empty is deleted (`messages.video.dropped`) and omitted from `{ messages }`. Body is `{ messages: [...] }` (same key as `GET /messages`, not `replies`). Each item is public message JSON with `payable` false; may include `accountId` for 21gifts authors (omitted for Damus-only); Damus-only replies omit `role`; never includes `deletedAt` / `deletedBy`. +- **Errors:** 401 `{ error: 'Unauthorized' }` without a session; 404 `{ error: 'Not found' }` when `:id` is not a UUID, the parent is missing, or the parent is soft-hidden; 503 `{ error: 'Messages are unavailable' }` (`messages.replies.failed`). - **Used by:** App reply thread under a top-level note. - **Auth:** `Authorization: Bearer` session. ## Endpoint: GET /messages/:id/photo -- **Purpose:** Public. Returns raw photo bytes for one message (`Content-Type` jpeg/png/webp, `Cache-Control: public, max-age=86400`, `Access-Control-Allow-Origin: *`, `Content-Disposition: inline; filename="photo.jpg|png|webp"`) so Nostr clients can load NIP-92 `imeta` URLs. Same bytes at `/photo.jpg`, `/photo.jpeg`, `/photo.png`, and `/photo.webp` because Damus only embeds URLs that look like image files. List JSON never embeds bytes — clients fetch here when `hasPhoto` is true. -- **Errors:** 404 `{ error: 'Photo not found' }` when the id is missing, not a UUID, or has no photo; 503 `{ error: 'Messages are unavailable' }` (`messages.photo.failed`). +- **Purpose:** Public. Returns raw photo bytes for one message (`Content-Type` jpeg/png/webp, `Cache-Control: public, max-age=86400`, `Access-Control-Allow-Origin: *`, `Content-Disposition: inline; filename="photo.jpg|png|webp"`) so Nostr clients can load NIP-92 `imeta` URLs. Same bytes at `/photo.jpg`, `/photo.jpeg`, `/photo.png`, and `/photo.webp` because Damus only embeds URLs that look like image files. List JSON never embeds bytes — clients fetch here when `hasPhoto` is true. Soft-hidden rows 404 even when photo bytes remain in the store (handler checks `getById` / `deletedAt` before `getPhoto`). +- **Errors:** 404 `{ error: 'Photo not found' }` when the id is missing, not a UUID, soft-hidden, or has no photo; 503 `{ error: 'Messages are unavailable' }` (`messages.photo.failed`). - **Used by:** App forum photo display; Damus/Primal via kind:1 photo URLs. - **Auth:** none. @@ -304,14 +311,14 @@ ## Endpoint: POST /messages - **Purpose:** Bearer required. After auth, `requireAction(account, 'forum.post')` (needs rules + name + Lightning Address; skip timestamps do not satisfy). JSON `{ text?, photo?: { contentType, data }, inReplyTo? }` (base64 JPEG/PNG/WebP ≤ 1 MiB) or `multipart/form-data` with `text`, `video` (MP4/WebM/MOV ≤ 32 MiB), and optional JPEG/PNG/WebP `poster`. Optional `inReplyTo` is a **top-level** parent message UUID (sets `parentId` for a one-level NIP-10 reply; JSON only). Text-only stays valid; photo-only or video-only allowed; at least one of non-empty trimmed text, photo, or video required. Name snapshot. 200 is the public message including `sats`, `payable`, `hasPhoto`, `hasVideo`, `videoContentType`, the session account's live `role`, and `accountId` (not wrapped). New notes have `sats` 0 and `payable` false until signed (and stay `payable` false without author LN). Top-level creates may enqueue push; replies do not. -- **Errors:** 401 Unauthorized; 409 `{ error: 'missing_requirements', missing: [...] }` when rules, name, and/or Lightning Address are missing (order `rules`, then `name`, then `lightning-address`); 400 Expected a JSON body with text and/or photo; 400 Text must be 1–500 characters; 400 Text must be 1–500 characters or include a photo; 400 Text must be 1–500 characters or include a photo or video; 400 Photo must be a JPEG, PNG, or WebP under 1 MiB; 400 Poster must be a JPEG, PNG, or WebP under 1 MiB; 400 Video must be an MP4, WebM, or MOV under 32 MiB; 404 `{ error: 'Not found' }` when `inReplyTo` is present but not a UUID, the parent is missing, or the parent is itself a reply (`parentId !== null`); 429 Too many messages (`Retry-After: 10`); 503 Messages are unavailable (`messages.create.failed`). +- **Errors:** 401 Unauthorized; 409 `{ error: 'missing_requirements', missing: [...] }` when rules, name, and/or Lightning Address are missing (order `rules`, then `name`, then `lightning-address`); 400 Expected a JSON body with text and/or photo; 400 Text must be 1–500 characters; 400 Text must be 1–500 characters or include a photo; 400 Text must be 1–500 characters or include a photo or video; 400 Photo must be a JPEG, PNG, or WebP under 1 MiB; 400 Poster must be a JPEG, PNG, or WebP under 1 MiB; 400 Video must be an MP4, WebM, or MOV under 32 MiB; 404 `{ error: 'Not found' }` when `inReplyTo` is present but not a UUID, the parent is missing, soft-hidden (`deletedAt` set), or the parent is itself a reply (`parentId !== null`); 429 Too many messages (`Retry-After: 10`); 503 Messages are unavailable (`messages.create.failed`). - **Used by:** App forum composer and reply composer. - **Auth:** `Authorization: Bearer` session. ## Endpoint: POST /messages/:id/invoice -- **Purpose:** Bearer required. After auth, `requireAction(payer, 'forum.pay')` (payer needs rules only — never 409 `lightning-address` for the payer). `:id` is a UUID. Body `{ sats }` (integer 1..10_000_000). Builds a NIP-57 kind:9734 zap request for the note, signs it with the payer's custodial key (ensuring one exists when KEK is present), and returns `{ pr, amountSats }` only when the minted BOLT11 is a NIP-57 `description_hash` invoice (`isNip57Invoice`); otherwise persists `not_zap` (with rejected `pr` for debug) and responds 400 `The author's wallet cannot receive this Bitcoin payment` without `pr` in the body. Same author's-wallet 400 for LNURL `noZap`; other LNURL transport failures (`unreachable`) keep `Could not start the Bitcoin payment`. Author LN / unsigned note stay 400 `This message cannot be paid yet` (resource state, not payer `missing`). After auth, valid-UUID attempts are persisted best-effort (`message_invoice`); persist failures do not change the HTTP response. The invoice rate limit is applied only after auth, amount, payable, and KEK checks (NIP-57 reject still counts, same as other LNURL failures). -- **Errors:** 401 Unauthorized; 409 `{ error: 'missing_requirements', missing: ['rules'] }` when the payer has not agreed to rules; 400 bad body / This message cannot be paid yet / The author's wallet cannot receive this Bitcoin payment (`noZap`, `not_zap`) / Could not start the Bitcoin payment (`unreachable` and other LNURL transport failures); 404 Not found (unknown id or non-UUID `:id`, the latter without a persist row); 429 Too many payments (`Retry-After: 10`, after payable checks); 503 Messages are unavailable (missing KEK before limiter, or keygen/sign failure after). +- **Purpose:** Bearer required. After auth, `requireAction(payer, 'forum.pay')` (payer needs rules only — never 409 `lightning-address` for the payer). `:id` is a UUID. Body `{ sats }` (integer 1..10_000_000). Builds a NIP-57 kind:9734 zap request for the note, signs it with the payer's custodial key (ensuring one exists when KEK is present), and returns `{ pr, amountSats }` only when the minted BOLT11 is a NIP-57 `description_hash` invoice (`isNip57Invoice`); otherwise persists `not_zap` (with rejected `pr` for debug) and responds 400 `The author's wallet cannot receive this Bitcoin payment` without `pr` in the body. Same author's-wallet 400 for LNURL `noZap`; other LNURL transport failures (`unreachable`) keep `Could not start the Bitcoin payment`. Author LN / unsigned note stay 400 `This message cannot be paid yet` (resource state, not payer `missing`). Soft-hidden notes are treated as missing (`not_found` persist + 404). After auth, valid-UUID attempts are persisted best-effort (`message_invoice`); persist failures do not change the HTTP response. The invoice rate limit is applied only after auth, amount, payable, and KEK checks (NIP-57 reject still counts, same as other LNURL failures). +- **Errors:** 401 Unauthorized; 409 `{ error: 'missing_requirements', missing: ['rules'] }` when the payer has not agreed to rules; 400 bad body / This message cannot be paid yet / The author's wallet cannot receive this Bitcoin payment (`noZap`, `not_zap`) / Could not start the Bitcoin payment (`unreachable` and other LNURL transport failures); 404 Not found (unknown id, soft-hidden id, or non-UUID `:id`, the latter without a persist row); 429 Too many payments (`Retry-After: 10`, after payable checks); 503 Messages are unavailable (missing KEK before limiter, or keygen/sign failure after). - **Used by:** App pay sheet for forum notes. - **Auth:** `Authorization: Bearer` session. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index f71a6d1..80cef77 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -107,7 +107,7 @@ ## Function: migrateMessageSchema -- **Purpose:** Applies `MESSAGE_SCHEMA_SQL` in order (`CREATE TABLE IF NOT EXISTS message` with nullable `photo`/`photo_content_type`, newest-first index, additive `ALTER … ADD COLUMN IF NOT EXISTS` for existing databases including `video_content_type` (MIME in Postgres; video bytes on disk under `MEDIA_DIR`, not bytea), `parent_id uuid REFERENCES message (id)`, `author_pubkey text`, then `ALTER TABLE message ALTER COLUMN account_id DROP NOT NULL` and `CREATE INDEX IF NOT EXISTS message_parent_id_idx ON message (parent_id, created_at ASC, id ASC)`, then `message_invoice` and `nostr_zap_ingest` without FKs plus `ALTER TABLE message_invoice ADD COLUMN IF NOT EXISTS lnurl_response jsonb` and their `created_at`/`message_id` and `receipt_id` indexes). After `message` exists, adds `account_profile_message_id_fkey` (`ON DELETE SET NULL`) and unique partial index `account_profile_message_uidx`. +- **Purpose:** Applies `MESSAGE_SCHEMA_SQL` in order (`CREATE TABLE IF NOT EXISTS message` with nullable `photo`/`photo_content_type`, newest-first index, additive `ALTER … ADD COLUMN IF NOT EXISTS` for existing databases including `video_content_type` (MIME in Postgres; video bytes on disk under `MEDIA_DIR`, not bytea), `parent_id uuid REFERENCES message (id)`, `author_pubkey text`, then `ALTER TABLE message ALTER COLUMN account_id DROP NOT NULL` and `CREATE INDEX IF NOT EXISTS message_parent_id_idx ON message (parent_id, created_at ASC, id ASC)`, then `message_invoice` and `nostr_zap_ingest` without FKs plus `ALTER TABLE message_invoice ADD COLUMN IF NOT EXISTS lnurl_response jsonb` and their `created_at`/`message_id` and `receipt_id` indexes, then soft-hide columns `deleted_at timestamptz` and `deleted_by uuid`). After `message` exists, adds `account_profile_message_id_fkey` (`ON DELETE SET NULL`) and unique partial index `account_profile_message_uidx`. - **Inputs:** `SqlClient`. - **Returns / side effects:** Void; idempotent DDL execute matching `docs/schema/message.sql`. - **Used by:** `openBootStores` when SQL opens. @@ -163,7 +163,7 @@ ## Function: PostgresMessageStore -- **Purpose:** Durable `MessageStore` over Postgres (`message` table plus `message_invoice` and `nostr_zap_ingest`). `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. `listLatest` is **top-level only** (`WHERE parent_id IS NULL`) with subquery `replyCount` (direct children), selecting Nostr columns plus `(photo IS NOT NULL) AS has_photo` and never the `photo` bytea column (HTTP window newest-first; product UX is a messenger group — clients reverse); `listReplies` is oldest-first (`WHERE parent_id = $1`, `created_at ASC, id ASC`); `listPublishedEventIds` returns non-null top-level `event_id`s newest-first for inbound reply REQ; `create` inserts optional photo bytes and optional `video_content_type` (disk write via `writeForumVideo`; `removeForumVideo` unlink on INSERT failure); `getPhoto` loads bytes by id; `getById`; `getByEventId` (`WHERE event_id`); `claimUnsigned`/`claimUnpublished` lease rows (`claimed_until <= now` is expired; unsigned requires `pending` + null `event_id`); `listPendingSigned` returns pending rows whose kind:1 lacks `t=bitcoin` (`created_at ASC, id ASC`); `clearSignedEvent` nulls `event_id` / `nostr_event` / `claimed_until` only while `pending` and `event_id` still matches the listed id and no child reply exists (`NOT EXISTS`); `listSignedMissingPhoto` returns published **top-level** rows (`parent_id IS NULL`) with a photo whose kind:1 content lacks `/messages/:id/photo.` plus an image extension (`sats = 0`, pending excluded so fan-out is not starved, 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** rows (`parent_id IS NULL`) with `video_content_type` set whose kind:1 content lacks `/messages/:id/video.` (`sats = 0`, pending excluded, parents with children skipped via `NOT EXISTS`, `created_at ASC, id ASC`); `listSignedMissingHashtags` returns published unpaid **top-level** rows (`parent_id 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`, pending excluded so fan-out is not starved, includes null / non-string content, `created_at ASC, id ASC`); `resetSignedEvent` nulls `event_id` / `nostr_event` / `claimed_until`, parks `pending`, and clears the epoch only when `event_id` still matches, `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); `recordZapIngest` / `listZapIngests`. +- **Purpose:** Durable `MessageStore` over Postgres (`message` table plus `message_invoice` and `nostr_zap_ingest`). `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`). 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 direct children), 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); `listReplies` is oldest-first live children (`WHERE parent_id = $1 AND deleted_at IS NULL`); `listPublishedEventIds` returns non-null live top-level `event_id`s newest-first for inbound reply REQ; `create` inserts optional photo bytes and optional `video_content_type` (disk write via `writeForumVideo`; `removeForumVideo` unlink on INSERT failure); `getPhoto` loads bytes by id; `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` / `listSignedMissingPhoto` / `listSignedMissingVideo` / `listSignedMissingHashtags` skip soft-hidden rows; `clearSignedEvent` / `resetSignedEvent` unchanged; `updateSignedEvent` (false on `event_id` collision); `updatePublishState`; `addSats`; `recordZapReceipt`; `recordInvoiceAttempt` / `listInvoiceAttempts`; `recordZapIngest` / `listZapIngests`. - **Inputs:** Constructor takes a shared boot `SqlClient` (already migrated). - **Returns / side effects:** Parameter-bound SQL; maps snake_case rows to `MessageRow` / `ForumPhoto` / invoice and ingest rows. Claim uses `FOR UPDATE SKIP LOCKED`. Errors propagate to the route (503) except invoice/ingest persist failures which are caught by callers. - **Used by:** `openBootStores` when `DATABASE_URL` is set. @@ -385,10 +385,17 @@ - **Returns / side effects:** Hono app `POST /` mounted at `/debug/push-ping`. Debug 503/401 before JSON; then unconfigured 503; unknown account 404. Calls `enqueueDebugPush`. - **Used by:** `createApp`. +## Function: markDeleted + +- **Purpose:** Soft-hide a forum note and every untagged **direct** reply by stamping `deletedAt` / `deletedBy` (Postgres columns `deleted_at` / `deleted_by`). Does not hard-delete rows, media, invoices, zap receipts, or gifts; does not call `deleteById`. An already-tagged target keeps its original stamps; untagged direct replies get this call's `at` / `byAccountId`. Public JSON never exposes the stamps. +- **Inputs:** `id` (message id string), `at` (`Date`, cloned onto newly tagged rows), `byAccountId` (staff account id recorded as `deletedBy`). +- **Returns / side effects:** `Promise` — `false` when no row has that id; `true` when the id exists (already tagged or newly tagged). In-memory mutates store rows; Postgres uses one UPDATE CTE (`deleted_at IS NULL` on the target and direct children). +- **Used by:** `messagesRoutes` (`DELETE /messages/:id`). + ## Function: InMemoryMessageStore -- **Purpose:** Process-local `MessageStore` for the public member forum. Default empty so the process boots without a database. Photos live in a private map, not on listed rows. Same port as Postgres: `getById`, `deleteById` (row, direct replies, photos, invoices, zap receipt ids, on-disk videos), `getByEventId`, `listLatest` (top-level only, `parentId` null, each row has `replyCount`), `listReplies` (oldest-first for a parent), `listPublishedEventIds` (non-null top-level `eventId`s newest-first), claim/sign/publish (`claimUnsigned` is pending + null `eventId`; lease expires at `claimedUntil`), `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`), `listSignedMissingPhoto` (top-level only, no children, published + photo, kind:1 content lacks `/messages/:id/photo.` plus extension, oldest-first, `sats === 0`, pending excluded, video rows excluded so posters are not treated as missing photos), `listSignedMissingVideo` (top-level only, no children, published + video MIME, kind:1 content lacks `/messages/:id/video.`, oldest-first, `sats === 0`, pending excluded), `listSignedMissingHashtags` (top-level only, no children, published unpaid, kind:1 content lacks a `#bitcoin` or `#21gifts` token, oldest-first, `sats === 0`, pending excluded so fan-out is not starved), `resetSignedEvent` (nulls `eventId` / `nostrEvent` / `claimedUntil`, parks `pending`, no-op unless `eventId` still matches, `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), `recordZapIngest` / `listZapIngests`; `updateSignedEvent` returns false on duplicate `eventId`. Store/HTTP order is newest-first; product UX is a messenger group (clients reverse). -- **Inputs:** Optional seed `MessageRow[]` (copied; `hasPhoto` defaults false). `listLatest(limit)` is top-level only (`parentId === null`) with `replyCount`, sorts newest `createdAt` then `id` DESC and caps at `limit`. `listReplies(parentId, limit?)` is oldest-first (default 200). `listPublishedEventIds(limit)` is newest-first non-null top-level `eventId`s. `create(row, photo?, video?)` appends a copy; `getPhoto(id)` returns a photo copy or null. +- **Purpose:** Process-local `MessageStore` for the public member forum. Default empty so the process boots without a database. Photos live in a private map, not on listed rows. Same port as Postgres: `getById` (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), `getByEventId`, live-only `listLatest` (top-level, `parentId` null and `deletedAt` null, each row has live `replyCount`), live-only `listReplies`, live-only `listPublishedEventIds`, claim/sign/publish (`claimUnsigned` / `claimUnpublished` skip soft-hidden; unsigned is pending + null `eventId`; lease expires at `claimedUntil`), live-only `listPendingSigned` / `listSignedMissingPhoto` / `listSignedMissingVideo` / `listSignedMissingHashtags`, `clearSignedEvent`, `resetSignedEvent`, `addSats`, `recordZapReceipt`, `recordInvoiceAttempt` / `listInvoiceAttempts`, `recordZapIngest` / `listZapIngests`; `updateSignedEvent` returns false on duplicate `eventId`. Store/HTTP order is newest-first; product UX is a messenger group (clients reverse). +- **Inputs:** Optional seed `MessageRow[]` (copied; `hasPhoto` defaults false; missing `deletedAt` / `deletedBy` become null). `listLatest(limit)` is live top-level only with live `replyCount`. `listReplies(parentId, limit?)` is oldest-first live children (default 200). `listPublishedEventIds(limit)` is newest-first non-null live top-level `eventId`s. `create(row, photo?, video?)` appends a copy; `getPhoto(id)` returns a photo copy or null; `markDeleted(id, at, byAccountId)` returns false when missing. - **Returns / side effects:** Promise of row/photo copies; mutating results does not change the store. Listed objects never expose bytes. When `video` is set, `create` awaits `writeForumVideo` (disk under `MEDIA_DIR`); if that write throws, the row is never pushed (no unlink). - **Used by:** `createApp` default `messageStore`. @@ -597,9 +604,9 @@ ## 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 + Lightning Address), and `POST /:id/invoice` (`forum.pay` → payer rules only). Bearer `GET /` lists **top-level** notes only newest-first (cap 200, `hasPhoto`, `hasVideo`, `videoContentType`, `sats`, `payable`, live `role`, `replyCount`); missing-file `hasVideo` rows are deleted (`messages.video.dropped`); `POST /` creates text/photo/video; public `GET /:id` stays unauthenticated without `accountId`; Bearer `GET /:id/replies`; photo/video byte routes; invoice returns `{ pr, amountSats }` only for NIP-57 invoices (author LN / unsigned stay 400 resource errors, never 409 `lightning-address` for the payer). Optional `pushStore` enqueues on top-level create. +- **Purpose:** Hono sub-app for the public member forum. After Bearer auth, `requireAction` gates `GET /` (`forum.read` → rules), `POST /` (`forum.post` → rules + name + Lightning Address), and `POST /:id/invoice` (`forum.pay` → payer rules only). Bearer `GET /` lists **live top-level** notes only newest-first (cap 200, `hasPhoto`, `hasVideo`, `videoContentType`, `sats`, `payable`, live `role`, live `replyCount`); soft-hidden rows are omitted; missing-file `hasVideo` rows are deleted (`messages.video.dropped`); `POST /` creates text/photo/video (soft-hidden `inReplyTo` parents are 404); public `GET /:id` stays unauthenticated without `accountId` and 404s soft-hidden rows; Bearer `GET /:id/replies` 404s soft-hidden parents; photo/video byte routes 404 soft-hidden ids; staff `DELETE /:id` soft-hides via `markDeleted` (founder/moderator → 204; basis/verified → 403); 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 `pushStore` enqueues on top-level create. - **Inputs:** `MessagesRouteDeps`: message `store`, shared `authStore`, `now`, optional `nostrKek`, `fetchImpl`, `postLimiter`, `invoiceLimiter`, optional `pushStore`. -- **Returns / side effects:** Hono app mounted at `/messages`. 401 without session on list/create/replies/invoice; 409 `{ error: 'missing_requirements', missing }` when action gates fail; 400 on bad body / invalid text / bad media / unpaid note / author's-wallet / LNURL failures; 404 for bad `inReplyTo` / missing rows; 429 rate limits; 503 on store/KEK/sign failure. Signed-in list/replies/create may include `accountId`; public `GET /:id` never includes it. +- **Returns / side effects:** Hono app mounted at `/messages`. 401 without session on list/create/replies/DELETE/invoice; 403 on DELETE when not founder/moderator; 409 `{ error: 'missing_requirements', missing }` when action gates fail; 400 on bad body / invalid text / bad media / unpaid note / author's-wallet / LNURL failures; 404 for bad `inReplyTo` / missing or soft-hidden rows; 204 empty body on successful DELETE; 429 rate limits; 503 on store/KEK/sign failure. Signed-in list/replies/create may include `accountId`; public JSON never includes `accountId`, `deletedAt`, or `deletedBy`. - **Used by:** `createApp`. ## Function: contactRoutes diff --git a/docs/schema/message.sql b/docs/schema/message.sql index b226d8c..80b41e1 100644 --- a/docs/schema/message.sql +++ b/docs/schema/message.sql @@ -89,3 +89,7 @@ ALTER TABLE account ADD CONSTRAINT account_profile_message_id_fkey FOREIGN KEY (profile_message_id) REFERENCES message (id) ON DELETE SET NULL; CREATE UNIQUE INDEX IF NOT EXISTS account_profile_message_uidx ON account (profile_message_id) WHERE profile_message_id IS NOT NULL; + +-- Soft-hide stamps (HTTP DELETE /messages/:id). No FK on deleted_by. +ALTER TABLE message ADD COLUMN IF NOT EXISTS deleted_at timestamptz; +ALTER TABLE message ADD COLUMN IF NOT EXISTS deleted_by uuid; diff --git a/e2e/forum-replies.spec.ts b/e2e/forum-replies.spec.ts index 3241c68..a83e946 100644 --- a/e2e/forum-replies.spec.ts +++ b/e2e/forum-replies.spec.ts @@ -101,3 +101,73 @@ test('Function: issueSession — POST /debug/accounts/:id/session with the e2e t const me = await request.get('/me', { headers: { authorization: `Bearer ${token}` } }); expect(me.status()).toBe(200); }); + +test('Function: markDeleted — DELETE /messages/:id hides the note', async ({ request }) => { + const stamp = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + const hideName = `E2eHide${stamp.slice(0, 8)}`; + const provision = await request.post('/debug/accounts', { + headers: DEBUG, + data: { + accounts: [ + { + name: hideName, + lightningAddress: `e2e-hide-${stamp}@walletofsatoshi.com`, + }, + ], + }, + }); + expect(provision.status()).toBe(200); + + const listed = await request.get('/debug/accounts', { headers: DEBUG }); + expect(listed.status()).toBe(200); + const accounts = ((await listed.json()) as { accounts: Array<{ id: string; name: string }> }) + .accounts; + const account = accounts.find((row) => row.name === hideName); + expect(account).toBeDefined(); + + const session = await request.post(`/debug/accounts/${account?.id}/session`, { headers: DEBUG }); + expect(session.status()).toBe(200); + const token = ((await session.json()) as { token: string }).token; + const auth = { authorization: `Bearer ${token}` }; + const agreed = await request.post('/me/rules-agreement', { headers: auth }); + expect(agreed.status()).toBe(200); + + const posted = await request.post('/messages', { + headers: { ...auth, 'content-type': 'application/json' }, + data: { text: 'e2e hide me' }, + }); + expect(posted.status()).toBe(200); + const note = (await posted.json()) as { id: string }; + + const beforeHide = await request.get(`/messages/${note.id}`); + expect(beforeHide.status()).toBe(200); + + const basisDenied = await request.delete(`/messages/${note.id}`, { headers: auth }); + expect(basisDenied.status()).toBe(403); + const stillVisible = await request.get(`/messages/${note.id}`); + expect(stillVisible.status()).toBe(200); + + const promoted = await request.patch(`/debug/accounts/${account?.id}`, { + headers: DEBUG, + data: { role: 'moderator' }, + }); + expect(promoted.status()).toBe(200); + + const hidden = await request.delete(`/messages/${note.id}`, { headers: auth }); + expect(hidden.status()).toBe(204); + expect(await hidden.text()).toBe(''); + + const afterHide = await request.get(`/messages/${note.id}`); + expect(afterHide.status()).toBe(404); + + const list = await request.get('/messages', { headers: auth }); + expect(list.status()).toBe(200); + const listedNotes = ((await list.json()) as { messages: Array<{ id: string }> }).messages; + expect(listedNotes.some((row) => row.id === note.id)).toBe(false); + + const photo = await request.get(`/messages/${note.id}/photo`); + expect(photo.status()).toBe(404); + + const again = await request.delete(`/messages/${note.id}`, { headers: auth }); + expect(again.status()).toBe(204); +}); diff --git a/e2e/http.spec.ts b/e2e/http.spec.ts index 42131bc..fd68b10 100644 --- a/e2e/http.spec.ts +++ b/e2e/http.spec.ts @@ -107,6 +107,11 @@ test('GET /messages/:id without bearer is 404 on default boot', async ({ request expect(res.status()).toBe(404); }); +test('DELETE /messages/:id without bearer is 401', async ({ request }) => { + const res = await request.delete('/messages/:id'); + expect(res.status()).toBe(401); +}); + test('GET /messages/:id/replies without bearer is 401', async ({ request }) => { const res = await request.get('/messages/:id/replies'); expect(res.status()).toBe(401); diff --git a/src/__tests__/lib/message-store.test.ts b/src/__tests__/lib/message-store.test.ts index e59cc84..8212fe5 100644 --- a/src/__tests__/lib/message-store.test.ts +++ b/src/__tests__/lib/message-store.test.ts @@ -109,6 +109,12 @@ describe('MESSAGE_SCHEMA_SQL', () => { expect(MESSAGE_SCHEMA_SQL.join('\n')).toMatch(/account_profile_message_id_fkey/); expect(MESSAGE_SCHEMA_SQL.join('\n')).toMatch(/ON DELETE SET NULL/); expect(MESSAGE_SCHEMA_SQL.join('\n')).toMatch(/account_profile_message_uidx/); + expect(MESSAGE_SCHEMA_SQL.join('\n')).toMatch( + /ALTER TABLE message ADD COLUMN IF NOT EXISTS deleted_at timestamptz/, + ); + expect(MESSAGE_SCHEMA_SQL.join('\n')).toMatch( + /ALTER TABLE message ADD COLUMN IF NOT EXISTS deleted_by uuid/, + ); }); }); @@ -133,6 +139,106 @@ describe('InMemoryMessageStore', () => { expect((await store.listLatest(10)).map((row) => row.id)).toEqual(['b']); }); + it('markDeleted returns false when missing and tags the row plus direct replies', async () => { + const store = new InMemoryMessageStore(); + await store.create({ ...EARLY, id: 'p-hide', text: 'parent' }, JPEG); + await store.create({ ...LATE, id: 'c-hide', parentId: 'p-hide', text: 'child' }); + await store.create({ ...LATE, id: 'c2-live', parentId: 'other', text: 'other-child' }); + const invoice: MessageInvoiceAttempt = { + id: 'inv-hide', + createdAt: new Date('2026-08-01T00:00:00.000Z'), + messageId: 'p-hide', + payerAccountId: 'payer', + authorAccountId: 'author', + amountSats: 21, + lightningAddress: 'a@b.com', + zapRequest: { kind: 9734 }, + result: 'ok', + httpStatus: 200, + pr: 'lnbc1', + paymentHash: 'aa'.repeat(32), + description: null, + descriptionHash: 'bb'.repeat(32), + isNip57Invoice: true, + lnurlResponse: null, + }; + await store.recordInvoiceAttempt(invoice); + expect(await store.recordZapReceipt('receipt-hide', 'p-hide', 21)).toBe(true); + const at = new Date('2026-09-01T12:00:00.000Z'); + expect(await store.markDeleted('missing', at, 'staff')).toBe(false); + expect(await store.markDeleted('p-hide', at, 'staff')).toBe(true); + const parent = await store.getById('p-hide'); + const child = await store.getById('c-hide'); + expect(parent?.deletedAt?.toISOString()).toBe(at.toISOString()); + expect(parent?.deletedBy).toBe('staff'); + expect(child?.deletedAt?.toISOString()).toBe(at.toISOString()); + expect(child?.deletedBy).toBe('staff'); + expect(await store.getPhoto('p-hide')).toEqual(JPEG); + expect((await store.listInvoiceAttempts(10)).map((row) => row.id)).toContain('inv-hide'); + expect(await store.recordZapReceipt('receipt-hide', 'p-hide', 1)).toBe(false); + expect((await store.listLatest(10)).map((row) => row.id)).not.toContain('p-hide'); + expect(await store.listReplies('p-hide')).toEqual([]); + }); + + it('markDeleted keeps original stamps on an already-tagged target and stamps live children', async () => { + const store = new InMemoryMessageStore(); + const firstAt = new Date('2026-08-01T00:00:00.000Z'); + const secondAt = new Date('2026-09-01T00:00:00.000Z'); + await store.create({ + ...EARLY, + id: 'p-retag', + deletedAt: firstAt, + deletedBy: 'first-staff', + }); + await store.create({ ...LATE, id: 'c-retag', parentId: 'p-retag', text: 'child' }); + expect(await store.markDeleted('p-retag', secondAt, 'second-staff')).toBe(true); + const parent = await store.getById('p-retag'); + const child = await store.getById('c-retag'); + expect(parent?.deletedAt?.toISOString()).toBe(firstAt.toISOString()); + expect(parent?.deletedBy).toBe('first-staff'); + expect(child?.deletedAt?.toISOString()).toBe(secondAt.toISOString()); + expect(child?.deletedBy).toBe('second-staff'); + }); + + it('replyCount and worker scans omit soft-deleted rows', async () => { + const store = new InMemoryMessageStore(); + const eventId = '11'.repeat(32); + await store.create({ + ...EARLY, + id: 'p-scan', + eventId, + nostrPublishState: 'published', + hasPhoto: true, + text: 'live parent', + }); + await store.create({ + ...LATE, + id: 'c-scan', + parentId: 'p-scan', + text: 'live child', + }); + await store.create({ + ...LATE, + id: 'c-dead', + parentId: 'p-scan', + text: 'dead child', + deletedAt: new Date('2026-09-01T00:00:00.000Z'), + deletedBy: 'staff', + }); + const listed = await store.listLatest(10); + expect(listed.find((row) => row.id === 'p-scan')?.replyCount).toBe(1); + expect((await store.listReplies('p-scan')).map((row) => row.id)).toEqual(['c-scan']); + expect(await store.listPublishedEventIds(10)).toEqual([eventId]); + await store.markDeleted('p-scan', new Date('2026-09-02T00:00:00.000Z'), 'staff'); + expect(await store.listPublishedEventIds(10)).toEqual([]); + expect(await store.listPendingSigned(10)).toEqual([]); + expect(await store.listSignedMissingPhoto(10)).toEqual([]); + expect(await store.listSignedMissingVideo(10)).toEqual([]); + expect(await store.listSignedMissingHashtags(10)).toEqual([]); + expect(await store.claimUnsigned(10, 1_000, 60_000)).toEqual([]); + expect(await store.claimUnpublished(10, 1_000, 60_000)).toEqual([]); + }); + it('deleteById cascades replies, invoices, zap receipts, photo, and video', async () => { const store = new InMemoryMessageStore(); const mp4 = new Uint8Array(32); @@ -1017,8 +1123,9 @@ describe('PostgresMessageStore', () => { const listed = await store.listLatest(50); expect(sql.queries[0]?.text).toMatch(/has_photo/); expect(sql.queries[0]?.text).toMatch(/event_id/); - expect(sql.queries[0]?.text).toMatch(/parent_id IS NULL/); + expect(sql.queries[0]?.text).toMatch(/parent_id IS NULL AND deleted_at IS NULL/); expect(sql.queries[0]?.text).toMatch(/reply_count/); + expect(sql.queries[0]?.text).toMatch(/child\.deleted_at IS NULL/); expect(sql.queries[0]?.text).toMatch(/ORDER BY created_at DESC, id DESC\s+LIMIT \$1/); expect(sql.queries[0]?.text).not.toMatch(/SELECT[^;]*\bphoto\b(?!\s+IS\s+NOT\s+NULL)/i); expect(sql.queries[0]?.params).toEqual([50]); @@ -1235,6 +1342,49 @@ describe('PostgresMessageStore', () => { expect(sql.executes.some((e) => e.text.includes('sats = sats +'))).toBe(true); }); + it('getById maps deleted_at Date and ISO string', async () => { + const sql = new MockSql(); + const deletedAtDate = new Date('2026-09-01T12:00:00.000Z'); + sql.nextRows = [ + { + id: 'm1', + account_id: 'acc', + name: 'Ada', + text: 'hi', + created_at: new Date(0), + has_photo: false, + event_id: null, + nostr_publish_state: 'pending', + sats: 0, + deleted_at: deletedAtDate, + deleted_by: 'staff-acc', + }, + ]; + const store = new PostgresMessageStore(sql); + const mappedDate = await store.getById('m1'); + expect(mappedDate?.deletedAt?.getTime()).toBe(deletedAtDate.getTime()); + expect(mappedDate?.deletedBy).toBe('staff-acc'); + + const deletedAtIso = '2026-09-02T00:00:00.000Z'; + sql.nextRows = [ + { + id: 'm2', + account_id: 'acc', + name: 'Ada', + text: 'hi', + created_at: new Date(0), + has_photo: false, + event_id: null, + nostr_publish_state: 'pending', + sats: 0, + deleted_at: deletedAtIso, + }, + ]; + const mappedIso = await store.getById('m2'); + expect(mappedIso?.deletedAt?.getTime()).toBe(Date.parse(deletedAtIso)); + expect(mappedIso?.deletedBy).toBeNull(); + }); + it('deleteById issues one CTE query for receipts, invoices, and rows', async () => { const sql = new MockSql(); sql.nextRows = [ @@ -1262,6 +1412,42 @@ describe('PostgresMessageStore', () => { expect(sql.queries[0]?.text).toMatch(/WITH/); }); + it('markDeleted issues an UPDATE CTE and returns false when missing', async () => { + const sql = new MockSql(); + sql.nextRows = [{ id: 'm1' }]; + const at = new Date('2026-09-01T12:00:00.000Z'); + expect(await new PostgresMessageStore(sql).markDeleted('m1', at, 'staff')).toBe(true); + expect(sql.executes).toEqual([]); + expect(sql.queries).toHaveLength(1); + const text = sql.queries[0]?.text ?? ''; + expect(text).toMatch(/UPDATE message SET deleted_at = \$2, deleted_by = \$3/); + expect(text).toMatch(/deleted_at IS NULL AND \(id = \$1 OR parent_id = \$1\)/); + expect(text).not.toMatch(/DELETE FROM message/); + expect(sql.queries[0]?.params).toEqual(['m1', at, 'staff']); + + const missing = new MockSql(); + missing.nextRows = []; + expect(await new PostgresMessageStore(missing).markDeleted('gone', at, 'staff')).toBe(false); + }); + + it('list and claim SQL require deleted_at IS NULL', async () => { + const sql = new MockSql(); + sql.nextRows = []; + const store = new PostgresMessageStore(sql); + await store.listLatest(10); + await store.listReplies('p1', 10); + await store.listPublishedEventIds(10); + await store.listPendingSigned(10); + await store.listSignedMissingPhoto(10); + await store.listSignedMissingVideo(10); + await store.listSignedMissingHashtags(10); + await store.claimUnsigned(5, 1_000, 60_000); + await store.claimUnpublished(5, 1_000, 60_000); + for (const query of sql.queries) { + expect(query.text).toMatch(/deleted_at IS NULL/); + } + }); + it('propagates create execute errors', async () => { const sql = new MockSql(); sql.executeError = new Error('create boom'); diff --git a/src/__tests__/lib/nostr/zap-index.test.ts b/src/__tests__/lib/nostr/zap-index.test.ts index a18ea7d..a73390c 100644 --- a/src/__tests__/lib/nostr/zap-index.test.ts +++ b/src/__tests__/lib/nostr/zap-index.test.ts @@ -1106,6 +1106,7 @@ describe('indexOpenZapReceipts', () => { create: (...args: Parameters) => base.create(...args), getPhoto: (id: string) => base.getPhoto(id), deleteById: (id: string) => base.deleteById(id), + markDeleted: (id: string, at: Date, by: string) => base.markDeleted(id, at, by), getById: (id: string) => base.getById(id), getByEventId: (id: string) => base.getByEventId(id), claimUnsigned: (...args: Parameters) => diff --git a/src/__tests__/routes/members.test.ts b/src/__tests__/routes/members.test.ts index 6439e23..5f7c03a 100644 --- a/src/__tests__/routes/members.test.ts +++ b/src/__tests__/routes/members.test.ts @@ -138,6 +138,44 @@ describe('GET /members/:accountId', () => { expect(body.profileMessage).toBeNull(); }); + it('returns profileMessage null when the profile note is soft-deleted but keeps profileMessageId', async () => { + const authStore = await seededCaller(); + const messageStore = new InMemoryMessageStore(); + const noteId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + await authStore.createAccount({ + id: ACCOUNT_ID, + linkingKey: null, + role: 'verified', + name: 'Ada', + lightningAddress: 'ada@walletofsatoshi.com', + lightningAddressVerified: true, + forumLawsDismissed: false, + viewKey: 'b'.repeat(64), + createdAt: 1_700_000_000_000, + rulesAgreedAt: now(), + profileMessageId: noteId, + }); + await messageStore.create({ + id: noteId, + accountId: ACCOUNT_ID, + name: 'Ada', + text: 'Ada', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + eventId: 'ee'.repeat(32), + }); + expect(await messageStore.markDeleted(noteId, new Date(now()), 'staff')).toBe(true); + const res = await mount(authStore, messageStore).request(`/members/${ACCOUNT_ID}`, { + headers: AUTH, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { profileMessage: null }; + expect(body.profileMessage).toBeNull(); + const account = await authStore.getAccount(ACCOUNT_ID); + expect(account?.profileMessageId).toBe(noteId); + }); + it('returns 503 when getAccount throws', async () => { const authStore = await seededCaller(); const original = authStore.getAccount.bind(authStore); diff --git a/src/__tests__/routes/messages.test.ts b/src/__tests__/routes/messages.test.ts index f704677..6eb7c7c 100644 --- a/src/__tests__/routes/messages.test.ts +++ b/src/__tests__/routes/messages.test.ts @@ -127,6 +127,7 @@ function throwingStore(overrides: Partial = {}): MessageStore { create: boom, getPhoto: boom, deleteById: boom, + markDeleted: boom, getById: boom, getByEventId: boom, claimUnsigned: boom, @@ -2055,6 +2056,7 @@ describe('POST /messages/:id/invoice', () => { getPhoto: (id) => base.getPhoto(id), getById: (id) => base.getById(id), deleteById: (id) => base.deleteById(id), + markDeleted: (id, at, by) => base.markDeleted(id, at, by), getByEventId: (id) => base.getByEventId(id), claimUnsigned: (...args) => base.claimUnsigned(...args), claimUnpublished: (...args) => base.claimUnpublished(...args), @@ -2676,6 +2678,23 @@ describe('GET /messages/:id/photo', () => { expect(await res.json()).toEqual({ error: 'Photo not found' }); }); + it('returns 404 when a live text-only note has no photo bytes', async () => { + const store = new InMemoryMessageStore(); + const id = '00000000-0000-4000-8000-0000000000a1'; + await store.create({ + id, + accountId: 'acc', + name: 'Ada', + text: 'note', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + }); + const res = await mount(await seededStore(), store).request(`/messages/${id}/photo`); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'Photo not found' }); + }); + it('returns 404 for a non-UUID id without calling the store', async () => { const getPhoto = vi.fn(async () => { throw new Error('boom'); @@ -3237,3 +3256,224 @@ describe('forum video', () => { } }); }); + +describe('DELETE /messages/:id', () => { + const NOTE_ID = '11111111-1111-4111-8111-111111111111'; + + async function staffStore( + role: 'founder' | 'moderator', + ): Promise<{ auth: InMemoryAuthStore; messages: InMemoryMessageStore }> { + const auth = await namedStore('Ada'); + const account = await auth.getAccount('acc'); + expect(account).toBeDefined(); + if (account === undefined) { + throw new Error('expected account'); + } + await auth.updateAccount({ ...account, role }); + const messages = new InMemoryMessageStore(); + await messages.create( + { + id: NOTE_ID, + accountId: 'acc', + name: 'Ada', + text: 'hide me', + createdAt: new Date(now()), + hasPhoto: true, + ...unsignedNostrDefaults(), + }, + { contentType: 'image/jpeg', bytes: JPEG_BYTES }, + ); + return { auth, messages }; + } + + it('returns 401 without a bearer', async () => { + const res = await mount(new InMemoryAuthStore()).request(`/messages/${NOTE_ID}`, { + method: 'DELETE', + }); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: 'Unauthorized' }); + }); + + it('returns 403 for basis including the author', async () => { + const auth = await namedStore('Ada'); + const messages = new InMemoryMessageStore(); + await messages.create({ + id: NOTE_ID, + accountId: 'acc', + name: 'Ada', + text: 'mine', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + }); + const res = await mount(auth, messages).request(`/messages/${NOTE_ID}`, { + method: 'DELETE', + headers: AUTH, + }); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: 'Forbidden' }); + }); + + it('returns 403 for verified', async () => { + const auth = await namedStore('Ada'); + const account = await auth.getAccount('acc'); + expect(account).toBeDefined(); + if (account === undefined) { + throw new Error('expected account'); + } + await auth.updateAccount({ ...account, role: 'verified' }); + const messages = new InMemoryMessageStore(); + await messages.create({ + id: NOTE_ID, + accountId: 'acc', + name: 'Ada', + text: 'mine', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + }); + const res = await mount(auth, messages).request(`/messages/${NOTE_ID}`, { + method: 'DELETE', + headers: AUTH, + }); + expect(res.status).toBe(403); + }); + + it('returns 404 for a non-uuid id', async () => { + const { auth, messages } = await staffStore('founder'); + const res = await mount(auth, messages).request('/messages/not-a-uuid', { + method: 'DELETE', + headers: AUTH, + }); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'Not found' }); + }); + + it('returns 404 when the note is missing', async () => { + const { auth } = await staffStore('founder'); + const res = await mount(auth, new InMemoryMessageStore()).request(`/messages/${NOTE_ID}`, { + method: 'DELETE', + headers: AUTH, + }); + expect(res.status).toBe(404); + }); + + it('returns 204 for founder and logs messages.deleted without text', async () => { + const { auth, messages } = await staffStore('founder'); + warn.mockClear(); + const res = await mount(auth, messages).request(`/messages/${NOTE_ID}`, { + method: 'DELETE', + headers: AUTH, + }); + expect(res.status).toBe(204); + expect(await res.text()).toBe(''); + const events = parsedEvents(warn); + const deleted = events.find((e) => e['event'] === 'messages.deleted'); + expect(deleted).toMatchObject({ + messageId: NOTE_ID, + accountId: 'acc', + role: 'founder', + }); + expect(JSON.stringify(deleted)).not.toContain('hide me'); + const row = await messages.getById(NOTE_ID); + expect(row?.deletedAt).not.toBeNull(); + expect(await messages.getPhoto(NOTE_ID)).not.toBeNull(); + }); + + it('returns 204 for moderator', async () => { + const { auth, messages } = await staffStore('moderator'); + const res = await mount(auth, messages).request(`/messages/${NOTE_ID}`, { + method: 'DELETE', + headers: AUTH, + }); + expect(res.status).toBe(204); + }); + + it('returns 204 when already tagged', async () => { + const { auth, messages } = await staffStore('founder'); + expect(await messages.markDeleted(NOTE_ID, new Date(now() - 1_000), 'acc')).toBe(true); + const first = await messages.getById(NOTE_ID); + const res = await mount(auth, messages).request(`/messages/${NOTE_ID}`, { + method: 'DELETE', + headers: AUTH, + }); + expect(res.status).toBe(204); + const again = await messages.getById(NOTE_ID); + expect(again?.deletedAt?.getTime()).toBe(first?.deletedAt?.getTime()); + }); + + it('returns 503 and logs messages.delete.failed when markDeleted throws', async () => { + const { auth } = await staffStore('founder'); + warn.mockClear(); + const res = await mount(auth, throwingStore()).request(`/messages/${NOTE_ID}`, { + method: 'DELETE', + headers: AUTH, + }); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ error: 'Messages are unavailable' }); + expect(parsedEvents(warn).some((e) => e['event'] === 'messages.delete.failed')).toBe(true); + }); + + it('hides the note from public reads, list, invoice, and inReplyTo', async () => { + const { auth, messages } = await staffStore('founder'); + const mp4Bytes = new Uint8Array(32); + mp4Bytes.set([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d]); + const videoId = '22222222-2222-4222-8222-222222222222'; + await messages.create( + { + id: videoId, + accountId: 'acc', + name: 'Ada', + text: 'clip', + createdAt: new Date(now()), + hasPhoto: false, + ...unsignedNostrDefaults(), + }, + undefined, + { contentType: 'video/mp4', bytes: mp4Bytes }, + ); + const app = mount(auth, messages); + const live = await app.request(`/messages/${NOTE_ID}`); + expect(live.status).toBe(200); + const liveBody = (await live.json()) as Record; + expect(liveBody).not.toHaveProperty('deletedAt'); + expect(liveBody).not.toHaveProperty('deletedBy'); + + expect( + (await app.request(`/messages/${NOTE_ID}`, { method: 'DELETE', headers: AUTH })).status, + ).toBe(204); + expect((await app.request(`/messages/${NOTE_ID}`)).status).toBe(404); + expect((await app.request(`/messages/${NOTE_ID}/photo`)).status).toBe(404); + expect((await app.request(`/messages/${NOTE_ID}/replies`, { headers: AUTH })).status).toBe(404); + const list = await app.request('/messages', { headers: AUTH }); + expect(list.status).toBe(200); + expect( + ((await list.json()) as { messages: Array<{ id: string }> }).messages.map((row) => row.id), + ).not.toContain(NOTE_ID); + + expect( + ( + await app.request(`/messages/${NOTE_ID}/invoice`, { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ sats: 21 }), + }) + ).status, + ).toBe(404); + expect( + ( + await app.request('/messages', { + method: 'POST', + headers: { ...AUTH, 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'reply', inReplyTo: NOTE_ID }), + }) + ).status, + ).toBe(404); + + expect( + (await app.request(`/messages/${videoId}`, { method: 'DELETE', headers: AUTH })).status, + ).toBe(204); + expect((await app.request(`/messages/${videoId}/video.mp4`)).status).toBe(404); + expect(await messages.getById(videoId)).toBeDefined(); + }); +}); diff --git a/src/lib/message-store.ts b/src/lib/message-store.ts index 89de20c..ea8d583 100644 --- a/src/lib/message-store.ts +++ b/src/lib/message-store.ts @@ -118,6 +118,19 @@ export interface MessageStore { */ deleteById(id: string): Promise; + /** + * Soft-hide a note and its direct replies by stamping `deletedAt` / + * `deletedBy`. Does not remove rows, media, invoices, or zap receipts. + * + * @param id - Message id. + * @param at - Hide timestamp (cloned onto newly tagged rows). + * @param byAccountId - Staff account id recorded as `deletedBy`. + * @returns `false` when no row has that id; `true` when the id exists + * (already tagged or newly tagged). An already-tagged target keeps its + * original stamps; untagged direct replies get this call's `at`/`by`. + */ + markDeleted(id: string, at: Date, byAccountId: string): Promise; + /** One row by id, or `undefined`. */ getById(id: string): Promise; @@ -379,6 +392,8 @@ export const MESSAGE_SCHEMA_SQL: readonly string[] = [ FOREIGN KEY (profile_message_id) REFERENCES message (id) ON DELETE SET NULL`, `CREATE UNIQUE INDEX IF NOT EXISTS account_profile_message_uidx ON account (profile_message_id) WHERE profile_message_id IS NOT NULL`, + `ALTER TABLE message ADD COLUMN IF NOT EXISTS deleted_at timestamptz`, + `ALTER TABLE message ADD COLUMN IF NOT EXISTS deleted_by uuid`, ]; /** @@ -400,6 +415,7 @@ function copyPhoto(photo: ForumPhoto): ForumPhoto { /** Copy a row so callers cannot mutate store internals. */ function copyRow(row: MessageRow): MessageRow { + const deletedAt = row.deletedAt ?? null; return { ...row, hasPhoto: row.hasPhoto === true, @@ -409,6 +425,8 @@ function copyRow(row: MessageRow): MessageRow { authorPubkey: row.authorPubkey ?? null, accountId: row.accountId ?? null, createdAt: new Date(row.createdAt.getTime()), + deletedAt: deletedAt === null ? null : new Date(deletedAt.getTime()), + deletedBy: row.deletedBy ?? null, nostrEvent: row.nostrEvent === null ? null : { ...row.nostrEvent }, }; } @@ -462,7 +480,7 @@ export class InMemoryMessageStore implements MessageStore { * photo or video bytes (video lives on disk under `MEDIA_DIR`). */ listLatest(limit: number): Promise { - const topLevel = this.#rows.filter((row) => row.parentId === null); + const topLevel = this.#rows.filter((row) => row.parentId === null && row.deletedAt === null); const sorted = [...topLevel].sort((a, b) => { const byTime = b.createdAt.getTime() - a.createdAt.getTime(); if (byTime !== 0) { @@ -476,7 +494,9 @@ export class InMemoryMessageStore implements MessageStore { copy.hasPhoto = this.#photos.has(row.id) || row.hasPhoto === true; copy.hasVideo = row.hasVideo === true; copy.videoContentType = row.videoContentType ?? null; - const replyCount = this.#rows.filter((child) => child.parentId === row.id).length; + const replyCount = this.#rows.filter( + (child) => child.parentId === row.id && child.deletedAt === null, + ).length; return { ...copy, replyCount }; }), ); @@ -491,7 +511,7 @@ export class InMemoryMessageStore implements MessageStore { */ listReplies(parentId: string, limit: number = 200): Promise { const replies = this.#rows - .filter((row) => row.parentId === parentId) + .filter((row) => row.parentId === parentId && row.deletedAt === null) .sort((a, b) => { const byTime = a.createdAt.getTime() - b.createdAt.getTime(); if (byTime !== 0) { @@ -519,7 +539,7 @@ export class InMemoryMessageStore implements MessageStore { */ listPublishedEventIds(limit: number): Promise { const ids = this.#rows - .filter((row) => row.eventId !== null && row.parentId === null) + .filter((row) => row.eventId !== null && row.parentId === null && row.deletedAt === null) .sort((a, b) => { const byTime = b.createdAt.getTime() - a.createdAt.getTime(); return byTime !== 0 ? byTime : b.id.localeCompare(a.id); @@ -591,6 +611,9 @@ export class InMemoryMessageStore implements MessageStore { return Promise.resolve( this.#claim( (row) => { + if (row.deletedAt !== null) { + return false; + } if (row.eventId !== null || row.nostrPublishState !== 'pending') { return false; } @@ -617,7 +640,8 @@ export class InMemoryMessageStore implements MessageStore { claimUnpublished(limit: number, nowMs: number, leaseMs: number): Promise { return Promise.resolve( this.#claim( - (row) => row.eventId !== null && row.nostrPublishState === 'pending', + (row) => + row.deletedAt === null && row.eventId !== null && row.nostrPublishState === 'pending', limit, nowMs, leaseMs, @@ -629,6 +653,7 @@ export class InMemoryMessageStore implements MessageStore { const rows = this.#rows .filter( (row) => + row.deletedAt === null && row.parentId === null && row.eventId !== null && row.nostrPublishState === 'pending' && @@ -662,6 +687,7 @@ export class InMemoryMessageStore implements MessageStore { const rows = this.#rows .filter( (row) => + row.deletedAt === null && row.parentId === null && row.eventId !== null && row.hasPhoto && @@ -684,6 +710,7 @@ export class InMemoryMessageStore implements MessageStore { const rows = this.#rows .filter( (row) => + row.deletedAt === null && row.parentId === null && row.eventId !== null && row.hasVideo === true && @@ -707,6 +734,7 @@ export class InMemoryMessageStore implements MessageStore { const rows = this.#rows .filter( (row) => + row.deletedAt === null && row.parentId === null && row.eventId !== null && row.sats === 0 && @@ -848,6 +876,25 @@ export class InMemoryMessageStore implements MessageStore { return true; } + markDeleted(id: string, at: Date, byAccountId: string): Promise { + const target = this.#rows.find((item) => item.id === id); + if (target === undefined) { + return Promise.resolve(false); + } + if (target.deletedAt === null) { + target.deletedAt = new Date(at.getTime()); + target.deletedBy = byAccountId; + } + for (const child of this.#rows) { + if (child.parentId !== id || child.deletedAt !== null) { + continue; + } + child.deletedAt = new Date(at.getTime()); + child.deletedBy = byAccountId; + } + return Promise.resolve(true); + } + #claim( predicate: (row: MessageRow) => boolean, limit: number, @@ -891,6 +938,8 @@ interface MessageSqlRow { nostr_first_attempt_at?: Date | string | null; nostr_publish_epoch?: string | null; nostr_attempts?: number | null; + deleted_at?: Date | string | null; + deleted_by?: string | null; reply_count?: string | number | null; } @@ -945,6 +994,13 @@ function mapMessageRow(row: MessageSqlRow): MessageRow { nostrFirstAttemptAt: optionalDate(row.nostr_first_attempt_at), nostrPublishEpoch: row.nostr_publish_epoch ?? defaults.nostrPublishEpoch, nostrAttempts: row.nostr_attempts ?? defaults.nostrAttempts, + deletedAt: + row.deleted_at === null || row.deleted_at === undefined + ? null + : row.deleted_at instanceof Date + ? row.deleted_at + : new Date(row.deleted_at), + deletedBy: row.deleted_by ?? null, }; } @@ -962,7 +1018,8 @@ const MESSAGE_SELECT_COLUMNS = `id, account_id, name, text, created_at, video_content_type, parent_id, author_pubkey, event_id, nostr_publish_state, sats, - nostr_event, claimed_until, nostr_first_attempt_at, nostr_publish_epoch, nostr_attempts`; + nostr_event, claimed_until, nostr_first_attempt_at, nostr_publish_epoch, nostr_attempts, + deleted_at, deleted_by`; /** * Durable {@link MessageStore} backed by Postgres. @@ -990,9 +1047,10 @@ export class PostgresMessageStore implements MessageStore { async listLatest(limit: number): Promise { const rows = await this.#sql.query( `SELECT ${MESSAGE_SELECT_COLUMNS}, - (SELECT COUNT(*)::int FROM message child WHERE child.parent_id = message.id) AS reply_count + (SELECT COUNT(*)::int FROM message child + WHERE child.parent_id = message.id AND child.deleted_at IS NULL) AS reply_count FROM message - WHERE parent_id IS NULL + WHERE parent_id IS NULL AND deleted_at IS NULL ORDER BY created_at DESC, id DESC LIMIT $1`, [limit], @@ -1014,7 +1072,7 @@ export class PostgresMessageStore implements MessageStore { const rows = await this.#sql.query( `SELECT ${MESSAGE_SELECT_COLUMNS} FROM message - WHERE parent_id = $1 + WHERE parent_id = $1 AND deleted_at IS NULL ORDER BY created_at ASC, id ASC LIMIT $2`, [parentId, limit], @@ -1031,7 +1089,7 @@ export class PostgresMessageStore implements MessageStore { async listPublishedEventIds(limit: number): Promise { const rows = await this.#sql.query<{ event_id: string }>( `SELECT event_id FROM message - WHERE event_id IS NOT NULL AND parent_id IS NULL + WHERE event_id IS NOT NULL AND parent_id IS NULL AND deleted_at IS NULL ORDER BY created_at DESC, id DESC LIMIT $1`, [limit], @@ -1132,6 +1190,22 @@ export class PostgresMessageStore implements MessageStore { return true; } + async markDeleted(id: string, at: Date, byAccountId: string): Promise { + const rows = await this.#sql.query<{ id: string }>( + `WITH target AS ( + SELECT id FROM message WHERE id = $1 + ), tagged AS ( + UPDATE message SET deleted_at = $2, deleted_by = $3 + WHERE deleted_at IS NULL AND (id = $1 OR parent_id = $1) + AND EXISTS (SELECT 1 FROM target) + RETURNING id + ) + SELECT id FROM target`, + [id, at, byAccountId], + ); + return rows[0] !== undefined; + } + async getById(id: string): Promise { const rows = await this.#sql.query( `SELECT ${MESSAGE_SELECT_COLUMNS} @@ -1160,6 +1234,7 @@ export class PostgresMessageStore implements MessageStore { SELECT m.id FROM message m WHERE m.event_id IS NULL AND m.nostr_publish_state = 'pending' AND m.account_id IS NOT NULL + AND m.deleted_at IS NULL AND (m.claimed_until IS NULL OR m.claimed_until <= $2) AND ( m.parent_id IS NULL @@ -1185,6 +1260,7 @@ export class PostgresMessageStore implements MessageStore { WHERE id IN ( SELECT id FROM message WHERE event_id IS NOT NULL AND nostr_publish_state = 'pending' + AND deleted_at IS NULL AND (claimed_until IS NULL OR claimed_until <= $2) ORDER BY created_at ASC, id ASC LIMIT $3 @@ -1201,6 +1277,7 @@ export class PostgresMessageStore implements MessageStore { `SELECT ${MESSAGE_SELECT_COLUMNS} FROM message WHERE parent_id IS NULL + AND deleted_at IS NULL AND event_id IS NOT NULL AND nostr_publish_state = 'pending' AND ( nostr_event IS NULL @@ -1236,7 +1313,8 @@ export class PostgresMessageStore implements MessageStore { const rows = await this.#sql.query( `SELECT ${MESSAGE_SELECT_COLUMNS} FROM message - WHERE parent_id IS NULL AND event_id IS NOT NULL AND photo IS NOT NULL AND sats = 0 + WHERE parent_id IS NULL AND deleted_at IS NULL + AND event_id IS NOT NULL AND photo IS NOT NULL AND sats = 0 AND nostr_publish_state = 'published' AND NOT EXISTS (SELECT 1 FROM message child WHERE child.parent_id = message.id) AND (video_content_type IS NULL OR video_content_type = '') @@ -1255,7 +1333,7 @@ export class PostgresMessageStore implements MessageStore { const rows = await this.#sql.query( `SELECT ${MESSAGE_SELECT_COLUMNS} FROM message - WHERE parent_id IS NULL AND event_id IS NOT NULL + WHERE parent_id IS NULL AND deleted_at IS NULL AND event_id IS NOT NULL AND video_content_type IN ('video/mp4', 'video/webm', 'video/quicktime') AND sats = 0 AND nostr_publish_state = 'published' @@ -1275,7 +1353,7 @@ export class PostgresMessageStore implements MessageStore { const rows = await this.#sql.query( `SELECT ${MESSAGE_SELECT_COLUMNS} FROM message - WHERE parent_id IS NULL AND event_id IS NOT NULL AND sats = 0 + WHERE parent_id IS NULL AND deleted_at IS NULL AND event_id IS NOT NULL AND sats = 0 AND nostr_publish_state = 'published' AND NOT EXISTS (SELECT 1 FROM message child WHERE child.parent_id = message.id) AND ( diff --git a/src/lib/message.ts b/src/lib/message.ts index a5a4f73..b733288 100644 --- a/src/lib/message.ts +++ b/src/lib/message.ts @@ -88,6 +88,16 @@ export interface MessageRow { nostrPublishEpoch: string | null; /** Sign/publish attempts in the current epoch. */ nostrAttempts: number; + /** + * Soft-delete stamp when staff hid this note (or a parent hide tagged it). + * `null` while the row is live. Default `null` on create. + */ + deletedAt: Date | null; + /** + * Account id of the staff member who stamped `deletedAt`, or `null` while + * live. Default `null` on create. Not a foreign key. + */ + deletedBy: string | null; } /** @@ -237,6 +247,8 @@ export function unsignedNostrDefaults(): Pick< | 'nostrAttempts' | 'parentId' | 'authorPubkey' + | 'deletedAt' + | 'deletedBy' > { return { eventId: null, @@ -249,6 +261,8 @@ export function unsignedNostrDefaults(): Pick< nostrAttempts: 0, parentId: null, authorPubkey: null, + deletedAt: null, + deletedBy: null, }; } diff --git a/src/lib/nostr/worker.ts b/src/lib/nostr/worker.ts index 70f5e0d..240b5f2 100644 --- a/src/lib/nostr/worker.ts +++ b/src/lib/nostr/worker.ts @@ -364,6 +364,8 @@ async function indexInboundForumReplies( nostrFirstAttemptAt: null, nostrPublishEpoch: null, nostrAttempts: 0, + deletedAt: null, + deletedBy: null, }); } catch { logEvent('nostr.reply.inbound.failed', { eventId: event.id }); diff --git a/src/routes/members.ts b/src/routes/members.ts index ecd37f3..2b34fb8 100644 --- a/src/routes/members.ts +++ b/src/routes/members.ts @@ -65,7 +65,7 @@ export function membersRoutes(deps: MembersRouteDeps): Hono { const profileId = account.profileMessageId; if (typeof profileId === 'string' && profileId.trim() !== '') { const row = await deps.messageStore.getById(profileId); - if (row !== undefined) { + if (row !== undefined && row.deletedAt === null) { const payable = row.eventId !== null && account.lightningAddress !== null && diff --git a/src/routes/messages.ts b/src/routes/messages.ts index 5c1319a..e2a0d3d 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -2,7 +2,7 @@ import { Hono, type Context } from 'hono'; import { z } from 'zod'; import { resolveSession } from '@/lib/auth/service'; import { MISSING_REQUIREMENTS_ERROR, requireAction } from '@/lib/auth/requirements'; -import type { Account, AuthStore } from '@/lib/auth/store'; +import type { Account, AccountRole, AuthStore } from '@/lib/auth/store'; import { inspectBolt11, isNip57Invoice } from '@/lib/bolt11'; import { GIFT_INVOICE_MAX_MSAT } from '@/lib/config'; import { logEvent } from '@/lib/log'; @@ -188,6 +188,11 @@ async function authedAccount( return resolveSession(deps.authStore, deps.now(), token); } +/** True when the live role may soft-hide forum notes. */ +function isStaffRole(role: AccountRole): boolean { + return role === 'founder' || role === 'moderator'; +} + /** Hex UUID as stored on `message.id` (rejects values Postgres would error on). */ export const MESSAGE_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -204,6 +209,10 @@ async function serveForumPhoto(deps: MessagesRouteDeps, id: string): Promise { + const account = await authedAccount(deps, c.req.header('authorization')); + if (account === null) { + return c.json({ error: 'Unauthorized' }, 401); + } + if (!isStaffRole(account.role)) { + return c.json({ error: 'Forbidden' }, 403); + } + const id = c.req.param('id'); + if (!MESSAGE_ID_RE.test(id)) { + return c.json({ error: 'Not found' }, 404); + } + try { + const tagged = await deps.store.markDeleted(id, new Date(deps.now()), account.id); + if (!tagged) { + return c.json({ error: 'Not found' }, 404); + } + logEvent('messages.deleted', { + messageId: id, + accountId: account.id, + role: account.role, + }); + return c.body(null, 204); + } catch { + logEvent('messages.delete.failed'); + return c.json({ error: 'Messages are unavailable' }, 503); + } + }) .get('/:id', async (c) => { const id = c.req.param('id'); if (!MESSAGE_ID_RE.test(id)) { @@ -593,7 +633,7 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { } try { const row = await deps.store.getById(id); - if (row === undefined) { + if (row === undefined || row.deletedAt !== null) { return c.json({ error: 'Not found' }, 404); } const author = @@ -672,7 +712,7 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { return c.json({ error: 'Expected a JSON body with a positive "sats" integer' }, 400); } const row = await deps.store.getById(messageIdParam); - if (row === undefined) { + if (row === undefined || row.deletedAt !== null) { await persistInvoiceAttempt( deps.store, invoiceAttemptBase({