Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -2481,10 +2481,13 @@ Success → **Response** `200`:

Public member forum thread. Bearer session required. After auth,
`requireAction(account, 'forum.read')` (rules). Returns **only
top-level notes** (`parent_id IS NULL`) newest first (`createdAt`
descending, then `id`), capped at **200**. Replies are never listed here —
use `GET /messages/:id/replies`. This is the latest-200 **window** on the
wire; clients must render the thread as a **messenger group** (oldest at
top-level notes** (`parent_id IS NULL`) via `listFeed`. Query `mode`
(`all` default, `active`, `unpaid`, `popular`), `limit` (1–200, default
**200**), and opaque `cursor`. Response `{ messages }` plus `nextCursor`
only when the page is full. Newest first (`createdAt` descending, then
`id`) except `popular` (sats descending). Replies are never listed here —
use `GET /messages/:id/replies`. The list path does not load reply rows.
Clients must render the thread as a **messenger group** (oldest at
the top, newest at the bottom above the composer), reversing the array for
display. Each message exposes the author **name snapshotted at post time**,
`text` (may be empty when a photo or video is attached), ISO-8601
Expand Down Expand Up @@ -2514,6 +2517,22 @@ Missing/invalid/expired bearer → **Response** `401`:
{ "error": "Unauthorized" }
```

Unknown `mode`, `limit` outside 1–200, or a bad/mismatched `cursor` → **Response** `400`:

```json
{ "error": "Invalid mode" }
```

```json
{ "error": "Invalid limit" }
```

```json
{ "error": "Invalid cursor" }
```

`mode=active` is paid notes plus unpaid founder/moderator notes; `unpaid` is `sats = 0`; `popular` is paid notes ordered by sats descending.

Missing rules → **Response** `409`:

```json
Expand Down
4 changes: 2 additions & 2 deletions docs/handbook/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,8 +360,8 @@

## Endpoint: GET /messages

- **Purpose:** Bearer required. After auth, `requireAction(account, 'forum.read')` (needs rules). Lists **top-level** forum notes only (`parent_id` null, `deleted_at` null) newest-first (author name snapshotted at post unless stored `name` trims empty, then `truncatePubkeyDisplay(row.authorPubkey ?? '')` / `'npub'` if the pubkey is missing, `text`, ISO `createdAt`, `sats`, `payable`, `hasPhoto`, `photoCount` (0–10; `hasPhoto` still means photo 0 exists), `hasVideo`, `videoContentType`, live author `role`, and `replyCount` of live direct children that have either an account or a recorded zapper pubkey, `account_id IS NOT NULL OR (author_pubkey IS NOT NULL AND EXISTS (SELECT 1 FROM nostr_zapper WHERE pubkey = lower(author_pubkey)))`), 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`); those children already satisfy the same account-or-recorded-zapper-pubkey visibility predicate; `replyCount` is the live attributed 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 external top-level notes); a row with `accountId === null` and a recorded-zapper author pubkey carries `via: 'nostr'` and never the pubkey (same `serializeMessage` rule as `GET /messages/:id`); never includes `deletedAt` / `deletedBy`; omits `goalSats` when unset (null/0/absent) and includes the key only when a positive whole-sat goal is stored on a top-level note; `payable` is true when the note has a non-empty `eventId` and the author has a non-blank Lightning Address; missing author → `role` `"basis"` and `payable` false. `videoContentType` is `null` when `hasVideo` is false.
- **Errors:** 401 `{ error: 'Unauthorized' }` missing/invalid/expired bearer; 409 `{ error: 'missing_requirements', missing: ['rules'] }` when rules are not agreed; 503 `{ error: 'Messages are unavailable' }` when the store throws, `serializeMessage` throws (invalid `createdAt`), or author lookup throws (`messages.list.failed`).
- **Purpose:** Bearer required. After auth, `requireAction(account, 'forum.read')` (needs rules). Lists **top-level** forum notes only (`parent_id` null, `deleted_at` null) via `listFeed`. Query: `mode` (`all` default, `active` = paid notes plus unpaid founder/moderator notes, `unpaid` = `sats = 0`, `popular` = paid sats-desc), `limit` (1–200, default 200), opaque `cursor` (keyset). Body `{ messages }` plus `nextCursor` when `listFeed` returned `limit` rows (before missing-file video drops on listed parents). Newest-first except `popular`. Each row includes author name snapshotted at post unless stored `name` trims empty, then `truncatePubkeyDisplay(row.authorPubkey ?? '')` / `'npub'` if the pubkey is missing, `text`, ISO `createdAt`, `sats`, `payable`, `hasPhoto`, `photoCount` (0–10; `hasPhoto` still means photo 0 exists), `hasVideo`, `videoContentType`, live author `role`, and `replyCount` of live direct children that have either an account or a recorded zapper pubkey (`account_id IS NOT NULL OR (author_pubkey IS NOT NULL AND EXISTS (SELECT 1 FROM nostr_zapper WHERE pubkey = lower(author_pubkey)))`). Soft-hidden top-level notes are omitted. A `hasVideo` row whose file is missing or empty is deleted and omitted. The list path does **not** load replies or drop missing-file video children (`GET /messages/:id/replies` still does). Replies are never listed here. Clients render chronological messenger-group order (oldest top, newest bottom above the composer). Empty list is 200 `{ messages: [] }`. No photo/video bytes in JSON; signed-in list may include `accountId` (21gifts author id; omitted for external top-level notes); a row with `accountId === null` and a recorded-zapper author pubkey carries `via: 'nostr'` and never the pubkey (same `serializeMessage` rule as `GET /messages/:id`); never includes `deletedAt` / `deletedBy`; omits `goalSats` when unset (null/0/absent) and includes the key only when a positive whole-sat goal is stored on a top-level note; `payable` is true when the note has a non-empty `eventId` and the author has a non-blank Lightning Address; missing author → `role` `"basis"` and `payable` false. `videoContentType` is `null` when `hasVideo` is false.
- **Errors:** 401 `{ error: 'Unauthorized' }` missing/invalid/expired bearer; 400 `{ error: 'Invalid mode' }` / `{ error: 'Invalid limit' }` / `{ error: 'Invalid cursor' }`; 409 `{ error: 'missing_requirements', missing: ['rules'] }` when rules are not agreed; 503 `{ error: 'Messages are unavailable' }` when the store throws, `serializeMessage` throws (invalid `createdAt`), or author lookup throws (`messages.list.failed`).
- **Used by:** App public comment thread.
- **Auth:** `Authorization: Bearer` session.

Expand Down
26 changes: 20 additions & 6 deletions docs/handbook/functions.md

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion docs/schema/message.sql
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
-- Public forum messages (GET/POST /messages, GET /messages/:id/photo,
-- GET /messages/:id/video.*, POST /messages/:id/invoice). Author display name
-- is snapshotted at post time. Indexed newest-first for listLatest. Nostr
-- is snapshotted at post time. Indexed newest-first for listLatest; listFeed
-- keyset pages use message_feed_created_idx (created_at, id) and
-- message_feed_popular_idx (sats, created_at, id). Nostr
-- columns are filled by the worker (event_id, signed JSON, publish state,
-- sats). Optional photo (bytea) + photo_content_type; list queries must not
-- SELECT the photo column — use (photo IS NOT NULL) AS has_photo only.
Expand Down Expand Up @@ -132,6 +134,8 @@ CREATE UNIQUE INDEX IF NOT EXISTS account_profile_message_uidx
-- 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;
CREATE INDEX IF NOT EXISTS message_feed_created_idx ON message (created_at DESC, id DESC) WHERE parent_id IS NULL AND deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS message_feed_popular_idx ON message (sats DESC, created_at DESC, id DESC) WHERE parent_id IS NULL AND deleted_at IS NULL AND sats > 0;
CREATE INDEX IF NOT EXISTS message_nostr_event_unrepaired_idx
ON message (id)
WHERE nostr_event IS NOT NULL AND jsonb_typeof(nostr_event) = 'string';
Expand Down
14 changes: 14 additions & 0 deletions e2e/functions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,20 @@ test('Function: messagesRoutes — GET /messages without bearer is 401', async (
expect(res.status()).toBe(401);
});

test('Function: encodeMessageFeedCursor — GET /messages without bearer is 401', async ({
request,
}) => {
const res = await request.get('/messages');
expect(res.status()).toBe(401);
});

test('Function: decodeMessageFeedCursor — GET /messages without bearer is 401', async ({
request,
}) => {
const res = await request.get('/messages');
expect(res.status()).toBe(401);
});

test('Function: normalizeForumText — POST /messages without bearer is 401', async ({ request }) => {
const res = await request.post('/messages', {
data: { text: 'hi' },
Expand Down
10 changes: 10 additions & 0 deletions src/__tests__/lib/auth/postgres-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,16 @@ describe('PostgresAuthStore nostr keys', () => {
sql.nextRows = [{ id: 'acc' }];
expect(await store.listAccountIdsWithoutNostrKey(5)).toEqual(['acc']);
});

it('listStaffAccountIds selects founder and moderator ids', async () => {
const sql = new MockSql();
sql.nextRows = [{ id: 'f1' }, { id: 'm1' }];
const ids = await new PostgresAuthStore(sql).listStaffAccountIds();
expect(sql.queries[0]?.text).toBe(
`SELECT id FROM account WHERE role IN ('founder', 'moderator')`,
);
expect(ids).toEqual(['f1', 'm1']);
});
});

describe('migrateAuthSchema', () => {
Expand Down
58 changes: 58 additions & 0 deletions src/__tests__/lib/auth/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1369,6 +1369,64 @@ describe('InMemoryAuthStore', () => {
expect(await store.getNostrPublicKey('acc')).toBeUndefined();
});

it('listStaffAccountIds returns founder and moderator ids', async () => {
const store = new InMemoryAuthStore();
await store.createAccount({
id: 'founder-1',
linkingKey: null,
role: 'founder',
name: 'Founder',
lightningAddress: null,
lightningAddressVerified: false,
forumLawsDismissed: false,
location: null,
viewKey: 'a'.repeat(64),
createdAt: 1,
rulesAgreedAt: null,
});
await store.createAccount({
id: 'mod-1',
linkingKey: null,
role: 'moderator',
name: 'Mod',
lightningAddress: null,
lightningAddressVerified: false,
forumLawsDismissed: false,
location: null,
viewKey: 'b'.repeat(64),
createdAt: 2,
rulesAgreedAt: null,
});
await store.createAccount({
id: 'basis-1',
linkingKey: null,
role: 'basis',
name: 'Basis',
lightningAddress: null,
lightningAddressVerified: false,
forumLawsDismissed: false,
location: null,
viewKey: 'c'.repeat(64),
createdAt: 3,
rulesAgreedAt: null,
});
await store.createAccount({
id: 'verified-1',
linkingKey: null,
role: 'verified',
name: 'Verified',
lightningAddress: null,
lightningAddressVerified: false,
forumLawsDismissed: false,
location: null,
viewKey: 'd'.repeat(64),
createdAt: 4,
rulesAgreedAt: null,
});
const ids = await store.listStaffAccountIds();
expect(ids.sort()).toEqual(['founder-1', 'mod-1']);
});

it('claimProfileMessageId sets the pointer only when it still matches', async () => {
const store = new InMemoryAuthStore();
expect(await store.claimProfileMessageId('missing', null, 'note-1')).toBe(false);
Expand Down
Loading
Loading