From 5a56224772c1776dc15ca3977318bb28eeeb2b03 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Tue, 22 Sep 2026 05:44:01 +0200 Subject: [PATCH 1/2] 01a0c542 - Bind funding status filters as Postgres text-array literals (#242) * Bind funding status filters as Postgres text-array literals Bun SQL does not encode a JavaScript array for text[]. The funding transition sent that array, so Postgres rejected the apply. Bind one array literal instead, and run that statement against Postgres in CI. * Name the Postgres driver test in the project tree and CI list The driver test lived outside the documented tree, and the CI row still omitted it. * List the Postgres driver test with the other local checks The README says those commands are the gates CI runs. CI now runs test:postgres, so the list was missing that step. --------- Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- .github/workflows/ci.yaml | 21 ++- CONTRIBUTING.md | 22 ++- README.md | 2 + docs/handbook/functions.md | 9 +- e2e/functions.spec.ts | 5 + integration/sql-driver.test.ts | 145 ++++++++++++++++++ package.json | 1 + src/__tests__/lib/funding-store.test.ts | 4 +- src/__tests__/lib/postgres-text-array.test.ts | 20 +++ src/lib/funding-store.ts | 5 +- src/lib/message-store.ts | 7 +- src/lib/postgres-text-array.ts | 20 +++ 12 files changed, 244 insertions(+), 17 deletions(-) create mode 100644 integration/sql-driver.test.ts create mode 100644 src/__tests__/lib/postgres-text-array.test.ts create mode 100644 src/lib/postgres-text-array.ts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 35f502552..0650aaadf 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -13,9 +13,23 @@ permissions: jobs: check: - name: Typecheck, Lint, Handbook, E2E-check, Test (100% coverage), Build, E2E + name: Typecheck, Lint, Handbook, E2E-check, Test (100% coverage), Postgres, Build, E2E runs-on: ubuntu-latest timeout-minutes: 15 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: gifts + POSTGRES_PASSWORD: gifts + POSTGRES_DB: gifts + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U gifts -d gifts" + --health-interval 5s + --health-timeout 5s + --health-retries 20 steps: - name: Checkout uses: actions/checkout@v4 @@ -43,6 +57,11 @@ jobs: - name: Test with 100% coverage gate run: bun run test:coverage + - name: Postgres driver + env: + DATABASE_URL: postgres://gifts:gifts@localhost:5432/gifts + run: bun run test:postgres + - name: Build run: bun run build diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3ba26dfed..0e15b6eba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,6 +80,7 @@ api/ │ │ ├── trust-store.ts # TrustStore port, InMemoryTrustStore, PostgresTrustStore, TRUST_SCHEMA_SQL │ │ ├── funding.ts # Funding-grant types, utcDayKey, FUNDING_REQUIRED_FROM_UTC, fundingGrantRequired, eligibleToday, serializeOwnerFunding, fundingReviewedAt, expiredTrialAsPending │ │ ├── funding-store.ts # FundingStore port, InMemoryFundingStore, PostgresFundingStore, FUNDING_SCHEMA_SQL, loadGrantEffective +│ │ ├── postgres-text-array.ts # postgresTextArrayLiteral (one Postgres text-array literal; Bun SQL cannot bind a JavaScript array) │ │ ├── conversation.ts # PN public JSON (optional counterpart/sender accountId; hasPhoto/photoCount; no eventId / npub / bytes) │ │ ├── api-log.ts # HTTP audit log store (`api_log`) │ │ ├── debug-db.ts # Operator read of every public table (`GET /debug/db`) @@ -191,6 +192,7 @@ api/ │ │ ├── request-auth.test.ts │ │ ├── funding.test.ts │ │ ├── funding-store.test.ts +│ │ ├── postgres-text-array.test.ts │ │ ├── conversation.test.ts │ │ ├── conversation-store.test.ts │ │ ├── conversation-push.test.ts @@ -271,6 +273,8 @@ api/ │ ├── check-handbook.mjs # CI gate: missing heading → exit 1 │ ├── check-e2e.mjs # CI gate: missing endpoint request or Function: title → exit 1 │ └── gifts-debug.sh # Operator CLI: list, account-by-id, dump tables, set role, refuse-session, unlink Lightning Address, messages, external-pubkeys, video-put, restore, spend, trust-edges, trust-edge, trust-edge-delete, api-log (DEBUG_TOKEN) +├── integration/ +│ └── sql-driver.test.ts # Bun test:postgres; needs DATABASE_URL; text[] binding against Postgres ├── e2e/ │ ├── http.spec.ts # Playwright endpoint smokes against bun src/index.ts │ ├── forum-replies.spec.ts # Playwright: provision, session, note, public GET, reply, replyCount @@ -451,6 +455,10 @@ undeclared deviation and is rejected. - Coverage gate: 100% lines, branches, functions, statements on the activated surface (see `vitest.config.ts`). Unreachable defensive code can be exempted with a `v8 ignore` annotation that names a concrete reason — never to silence the gate. +- Vitest stays free of `DATABASE_URL`. `bun run test:postgres` is a separate Bun test against + Postgres. `$n::text[]` and `$n::uuid[]` parameters must be one array-literal string + (`postgresTextArrayLiteral` for text). A JavaScript array is `malformed array literal` under + Bun `SQL.unsafe`. CI runs this script and fails if `DATABASE_URL` is missing. ### Before every push (the same checks CI runs) @@ -460,6 +468,8 @@ bun run lint bun run handbook:check bun run e2e:check bun run test:coverage +# Postgres driver (Bun test, not Vitest); fails if DATABASE_URL is missing +DATABASE_URL=postgres://gifts:gifts@127.0.0.1:5432/gifts bun run test:postgres bun run build bun run e2e ``` @@ -512,12 +522,12 @@ More will be added as concrete subsystems that need runtime configuration ## CI / CD -| Workflow | Trigger | Action | -| ---------------------- | --------------------- | ---------------------------------------------------------------------------- | -| `ci.yaml` | PR (including drafts) | Typecheck + lint + handbook + e2e-check + test (100% coverage) + build + e2e | -| `deploy-dev.yaml` | push to `develop` | Docker build → push `21gifts/api:beta` → notify → wait for deploy | -| `deploy-prd.yaml` | push to `main` | Docker build → push `21gifts/api:latest` → notify → wait for deploy | -| `auto-release-pr.yaml` | push to `develop` | Auto-create Release PR (`develop → main`) | +| Workflow | Trigger | Action | +| ---------------------- | --------------------- | -------------------------------------------------------------------------------------------- | +| `ci.yaml` | PR (including drafts) | Typecheck + lint + handbook + e2e-check + test (100% coverage) + test:postgres + build + e2e | +| `deploy-dev.yaml` | push to `develop` | Docker build → push `21gifts/api:beta` → notify → wait for deploy | +| `deploy-prd.yaml` | push to `main` | Docker build → push `21gifts/api:latest` → notify → wait for deploy | +| `auto-release-pr.yaml` | push to `develop` | Auto-create Release PR (`develop → main`) | Images target `linux/arm64`. diff --git a/README.md b/README.md index 663b0a926..74a3ea3af 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,8 @@ bun run lint # eslint + prettier --check bun run handbook:check # every function and HTTP endpoint must be documented bun run e2e:check # every HTTP endpoint request and Function: title bun run test:coverage # vitest with 100% threshold +# Postgres driver (Bun test, not Vitest); fails if DATABASE_URL is missing +DATABASE_URL=postgres://gifts:gifts@127.0.0.1:5432/gifts bun run test:postgres bun run build # bun build to dist/ bun run e2e # Playwright against bun src/index.ts ``` diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 044746bb3..b5d0c087a 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -2308,9 +2308,16 @@ Builds the operator-only external-pubkey inspection route. - **Returns / side effects:** Promise of grant copies; mutating results or the seed does not change the store. No I/O. - **Used by:** `createApp` default `fundingStore`. +## Function: postgresTextArrayLiteral + +- **Purpose:** Encodes strings as one Postgres text-array literal (`{}` when empty; each value double-quoted; a backslash or double quote inside a value is escaped). Bun SQL does not encode a JavaScript array (`malformed array literal`). +- **Inputs:** `readonly string[]`. +- **Returns / side effects:** One text-array literal string. No I/O. +- **Used by:** `PostgresFundingStore.transition` and `PostgresMessageStore` (active feed staff ids, missing-hashtag unnest, exclude ids). + ## Function: PostgresFundingStore -- **Purpose:** Durable `FundingStore` over Postgres (`funding_grant` table). `getByAccountId` binds `$1`. `listGrants` is `ORDER BY applied_at ASC, account_id ASC`. `upsert` is `INSERT … ON CONFLICT (account_id) DO UPDATE SET` every grant column. `transition` is `INSERT … ON CONFLICT DO UPDATE WHERE status = ANY($9::text[])` when `from` includes `'none'`, else `UPDATE … WHERE account_id=$1 AND status = ANY($9::text[]) RETURNING *` (0 rows → `undefined`). `expireTrialIfUnchanged` is `UPDATE … WHERE account_id=$1 AND status='trial' AND trial_utc_date=$2 RETURNING *` (0 rows → `getByAccountId`). Maps `timestamptz` (Date or ISO string) to epoch ms and `trial_utc_date` Date/string to `YYYY-MM-DD`; `null` stays `null`. +- **Purpose:** Durable `FundingStore` over Postgres (`funding_grant` table). `getByAccountId` binds `$1`. `listGrants` is `ORDER BY applied_at ASC, account_id ASC`. `upsert` is `INSERT … ON CONFLICT (account_id) DO UPDATE SET` every grant column. `transition` is `INSERT … ON CONFLICT DO UPDATE WHERE status = ANY($9::text[])` when `from` includes `'none'`, else `UPDATE … WHERE account_id=$1 AND status = ANY($9::text[]) RETURNING *` (0 rows → `undefined`). `expireTrialIfUnchanged` is `UPDATE … WHERE account_id=$1 AND status='trial' AND trial_utc_date=$2 RETURNING *` (0 rows → `getByAccountId`). Maps `timestamptz` (Date or ISO string) to epoch ms and `trial_utc_date` Date/string to `YYYY-MM-DD`; `null` stays `null`. `$9` is one `postgresTextArrayLiteral` string, not a JavaScript array. - **Inputs:** Constructor takes a shared boot `SqlClient` (already migrated). - **Returns / side effects:** Parameter-bound SQL; copies on return. Query and execute errors propagate. - **Used by:** `openBootStores` when `DATABASE_URL` is set. diff --git a/e2e/functions.spec.ts b/e2e/functions.spec.ts index b9867192d..a7882fe53 100644 --- a/e2e/functions.spec.ts +++ b/e2e/functions.spec.ts @@ -1817,6 +1817,11 @@ test('Function: migrateFundingSchema — default boot has no DATABASE_URL', asyn test('Function: InMemoryFundingStore — default boot has no DATABASE_URL', async ({ request }) => { expect((await request.get('/healthz')).status()).toBe(200); }); +test('Function: postgresTextArrayLiteral — default boot has no DATABASE_URL', async ({ + request, +}) => { + expect((await request.get('/healthz')).status()).toBe(200); +}); test('Function: PostgresFundingStore — default boot has no DATABASE_URL', async ({ request }) => { expect((await request.get('/healthz')).status()).toBe(200); }); diff --git a/integration/sql-driver.test.ts b/integration/sql-driver.test.ts new file mode 100644 index 000000000..711059bcd --- /dev/null +++ b/integration/sql-driver.test.ts @@ -0,0 +1,145 @@ +import { SQL } from 'bun'; +import { describe, expect, test } from 'bun:test'; +import { migrateAuthSchema } from '@/lib/auth/postgres-store'; +import type { SqlClient } from '@/lib/auth/sql'; +import type { FundingGrant } from '@/lib/funding'; +import { migrateFundingSchema, PostgresFundingStore } from '@/lib/funding-store'; +import { postgresTextArrayLiteral } from '@/lib/postgres-text-array'; + +const databaseUrl = process.env['DATABASE_URL']; +if (databaseUrl === undefined || databaseUrl === '') { + throw new Error('DATABASE_URL is required'); +} + +/** + * `query` / `execute` body matches `createBunSqlClient` in `src/index.ts`. + * `sql` is that same instance so the test can close it. + */ +function createBunSqlClient(databaseUrl: string): { client: SqlClient; sql: SQL } { + const sql = new SQL(databaseUrl); + const client: SqlClient = { + async query(text: string, params: readonly unknown[] = []): Promise { + const rows = (await sql.unsafe(text, [...params])) as T[]; + return rows; + }, + async execute(text: string, params: readonly unknown[] = []): Promise { + await sql.unsafe(text, [...params]); + }, + }; + return { client, sql }; +} + +async function closeIfPossible(sql: SQL): Promise { + if (typeof sql.close === 'function') { + await sql.close(); + } +} + +describe('Bun SQL text[] binding', () => { + test('driver rejects a JavaScript array', async () => { + const sql = new SQL(databaseUrl); + try { + let thrown: unknown; + try { + await sql.unsafe('SELECT $1::text[] AS tags', [['rejected']]); + } catch (error) { + thrown = error; + } + if (thrown === undefined) { + throw new Error('expected sql.unsafe to reject a JavaScript array'); + } + const message = String(thrown); + if (!message.includes('malformed array literal')) { + throw thrown; + } + } finally { + await closeIfPossible(sql); + } + }); + + test('driver accepts the literal', async () => { + const sql = new SQL(databaseUrl); + try { + const rows = (await sql.unsafe('SELECT $1::text[] AS tags', [ + postgresTextArrayLiteral(['rejected']), + ])) as { tags: unknown }[]; + const tags = rows[0]?.tags; + if (Array.isArray(tags)) { + expect(tags.includes('rejected')).toBe(true); + } else if (typeof tags === 'string') { + expect(tags.includes('rejected')).toBe(true); + } else { + throw new Error(`unexpected tags shape: ${typeof tags}`); + } + } finally { + await closeIfPossible(sql); + } + }); + + test('funding apply through PostgresFundingStore', async () => { + const { client, sql } = createBunSqlClient(databaseUrl); + try { + await migrateAuthSchema(client); + await migrateFundingSchema(client); + + const accountId = crypto.randomUUID(); + await client.execute( + `INSERT INTO account (id, role, lightning_address_verified, forum_laws_dismissed, created_at) +VALUES ($1, 'verified', false, false, $2)`, + [accountId, new Date()], + ); + + const store = new PostgresFundingStore(client); + const appliedAt = Date.now(); + const pending: FundingGrant = { + accountId, + status: 'pending', + appliedAt, + decidedAt: null, + decidedBy: null, + trialUtcDate: null, + admittedAt: null, + note: null, + }; + + const created = await store.transition(pending, ['none', 'rejected']); + expect(created?.status).toBe('pending'); + + const second = await store.transition( + { + accountId, + status: 'pending', + appliedAt: appliedAt + 1, + decidedAt: null, + decidedBy: null, + trialUtcDate: null, + admittedAt: null, + note: null, + }, + ['none', 'rejected'], + ); + expect(second).toBeUndefined(); + + const stillPending = await store.getByAccountId(accountId); + expect(stillPending?.status).toBe('pending'); + + const decidedAt = Date.now(); + const admitted = await store.transition( + { + accountId, + status: 'admitted', + appliedAt, + decidedAt, + decidedBy: null, + trialUtcDate: null, + admittedAt: decidedAt, + note: null, + }, + ['pending', 'trial'], + ); + expect(admitted?.status).toBe('admitted'); + } finally { + await closeIfPossible(sql); + } + }); +}); diff --git a/package.json b/package.json index 6e943e2e9..535ae3a7e 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", + "test:postgres": "bun test integration/sql-driver.test.ts", "handbook:check": "node scripts/check-handbook.mjs", "e2e:check": "node scripts/check-e2e.mjs", "e2e": "playwright test" diff --git a/src/__tests__/lib/funding-store.test.ts b/src/__tests__/lib/funding-store.test.ts index 1cd95000d..d57171631 100644 --- a/src/__tests__/lib/funding-store.test.ts +++ b/src/__tests__/lib/funding-store.test.ts @@ -572,7 +572,7 @@ describe('PostgresFundingStore', () => { const created = await new PostgresFundingStore(sql).transition(input, ['none', 'rejected']); expect(sql.queries[0]?.text).toMatch(/INSERT INTO funding_grant/); expect(sql.queries[0]?.text).toMatch(/WHERE funding_grant.status = ANY\(\$9::text\[\]\)/); - expect(sql.queries[0]?.params[8]).toEqual(['rejected']); + expect(sql.queries[0]?.params[8]).toBe('{"rejected"}'); expect(created?.status).toBe('pending'); }); @@ -585,7 +585,7 @@ describe('PostgresFundingStore', () => { ); expect(sql.queries[0]?.text).toMatch(/UPDATE funding_grant SET/); expect(sql.queries[0]?.text).toMatch(/status = ANY\(\$9::text\[\]\)/); - expect(sql.queries[0]?.params[8]).toEqual(['pending', 'trial']); + expect(sql.queries[0]?.params[8]).toBe('{"pending","trial"}'); expect(missed).toBeUndefined(); }); }); diff --git a/src/__tests__/lib/postgres-text-array.test.ts b/src/__tests__/lib/postgres-text-array.test.ts new file mode 100644 index 000000000..77651cc22 --- /dev/null +++ b/src/__tests__/lib/postgres-text-array.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import { postgresTextArrayLiteral } from '@/lib/postgres-text-array'; + +describe('postgresTextArrayLiteral', () => { + it('encodes an empty list as {}', () => { + expect(postgresTextArrayLiteral([])).toBe('{}'); + }); + + it('encodes one value as a quoted element', () => { + expect(postgresTextArrayLiteral(['rejected'])).toBe('{"rejected"}'); + }); + + it('escapes a backslash and a double quote inside a value', () => { + expect(postgresTextArrayLiteral(['a"b', 'c\\d'])).toBe('{"a\\"b","c\\\\d"}'); + }); + + it('joins two plain values with a comma between quoted elements', () => { + expect(postgresTextArrayLiteral(['pending', 'trial'])).toBe('{"pending","trial"}'); + }); +}); diff --git a/src/lib/funding-store.ts b/src/lib/funding-store.ts index 395097604..e03a91547 100644 --- a/src/lib/funding-store.ts +++ b/src/lib/funding-store.ts @@ -12,6 +12,7 @@ import { type FundingGrant, type FundingStatus, } from '@/lib/funding'; +import { postgresTextArrayLiteral } from '@/lib/postgres-text-array'; /** * Persistence port for funding grants. @@ -332,6 +333,8 @@ export class PostgresFundingStore implements FundingStore { /** * Write `grant` only when the stored status is in `from`. `'none'` uses * `INSERT … ON CONFLICT DO UPDATE WHERE status = ANY(from without none)`. + * `$9` is {@link postgresTextArrayLiteral} of `from` without `'none'`, not a + * JavaScript array (Bun SQL cannot bind a JavaScript array to `text[]`). * * @param grant - Fully formed next grant. * @param from - Allowed current statuses, including `'none'` for insert. @@ -351,7 +354,7 @@ export class PostgresFundingStore implements FundingStore { grant.trialUtcDate, grant.admittedAt === null ? null : new Date(grant.admittedAt), grant.note, - fromStatus, + postgresTextArrayLiteral(fromStatus), ]; const rows = from.includes('none') ? await this.#sql.query( diff --git a/src/lib/message-store.ts b/src/lib/message-store.ts index 778f11d1f..9c7a14a07 100644 --- a/src/lib/message-store.ts +++ b/src/lib/message-store.ts @@ -11,6 +11,7 @@ */ import { isUniqueViolation, type SqlClient } from '@/lib/auth/sql'; +import { postgresTextArrayLiteral } from '@/lib/postgres-text-array'; import { forumContentFingerprint, unsignedNostrDefaults, @@ -90,12 +91,6 @@ function extraHashtagBindings( return { accountIds, patterns }; } -function postgresTextArrayLiteral(values: readonly string[]): string { - return `{${values - .map((value) => `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`) - .join(',')}}`; -} - function pendingKind1LacksBitcoinTag(event: Record | null): boolean { if (event === null) { return true; diff --git a/src/lib/postgres-text-array.ts b/src/lib/postgres-text-array.ts new file mode 100644 index 000000000..1fd15df02 --- /dev/null +++ b/src/lib/postgres-text-array.ts @@ -0,0 +1,20 @@ +/** + * Encode strings as one Postgres text-array literal. + * + * Bun `SQL.unsafe` cannot bind a JavaScript array to `text[]`. + */ + +/** + * Encode `values` as one Postgres text-array literal. + * + * Empty input is `{}`. A backslash or double quote inside a value is escaped. + * Bun SQL cannot bind a JavaScript array to `text[]`. + * + * @param values - Strings to include in the array. + * @returns A literal such as `{"rejected"}` or `{}`. + */ +export function postgresTextArrayLiteral(values: readonly string[]): string { + return `{${values + .map((value) => `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`) + .join(',')}}`; +} From 4cc9f9d968922d677c75e3116b8ca66aa8ddc6ff Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Tue, 22 Sep 2026 05:44:56 +0200 Subject: [PATCH 2/2] 01a0c00b - Let staff reject a moderator proposal and notify on open ones (#226) * Let staff reject a moderator proposal and keep the history. Open proposals fan out in-app and Web Push to other staff and stay unread until confirm or reject. After reject the subject stays verified and can be proposed again; every propose and reject is an append-only trust_edge row. * Access unreadCount via index signature in the proposal push test. tsc noPropertyAccessFromIndexSignature failed CI on the inbox-unread payload assertion. * Name reject on the public trust-chain contract and on appoint notify. GET /trust-chain never projects moderator_reject, matching confirm. Appoint 200 also drops open-proposal notifications before notifying the subject. * Align the trust_edge schema header with the public graph. GET /trust-chain never projects confirm or reject; propose is public only after the subject is a moderator. * Reconcile concurrent propose, confirm, and reject, and project one incoming edge. After insert, keep the oldest open propose, undo confirm if a reject landed, and undo reject if confirm or appoint already closed the grant. The public graph uses the winning edge id so two rows of the same kind cannot both show. * 409 a lost concurrent propose and pass chain actors into the hop filter. After insert, 409 unless this row is still the oldest open propose, and delete the insert rather than the newest extra. The trust-chain hop loads sibling actors so a non-chain oldest contact does not hide a later displayable edge. * Tick the clock between reject and re-propose so same-ms ids cannot 409. The pending rule is latest propose/reject by createdAt then id. A frozen test clock made a second propose at the reject timestamp flaky. Also document reject on the trust route inventory and cover a proposed-notify no-op plus repeated debug reject. * Name reject on isStaffRole and pendingModeratorProposals handbook callers. Staff reject uses the same role guard and pending helper as propose/confirm. Propose push unreadCount uses the same conversation store as appointed notify. * Delete concurrent propose, confirm, and reject extras by edge id. TrustStore gains deleteEdgeById so a lost insert cannot remove the winning propose or an older history row. Propose also 409s if confirm or appoint landed, confirm undoes when the pending propose is no longer latest, and a winning propose refreshes staff proposal notifications. * Say propose purges moderator_proposal rows before staff notify. SPEC and trustRoutes now match the endpoint: propose, confirm, reject, and appoint delete those rows; only confirm and appoint then notify the subject. * Keep reject history when a newer propose reopens, and roll back a 503 insert. After reject, drop staff proposal rows only when pending is empty. A same-ms id loss undoes the reject. A failed re-list deletes the insert so a retry can succeed. * Roll back a confirm insert on re-list failure and keep same-ms re-propose. A 503 after confirm no longer leaves a unique confirm edge that would block reject. Reject treats a different pending propose as a reopen even when createdAt ties. * Drop proposal notifications on reject only when pending is empty. SPEC now matches the route: a newer propose that reopened the queue keeps the reject in history and leaves moderator_proposal rows. * Identify pending proposals by edge id and refresh notify after races. Confirm and reject compare the pending propose by id so a same-actor same-ms re-propose is a different row. After propose notify, re-list and drop or refresh moderator_proposal rows when that insert is no longer pending. * Confirm pending by propose id without the confirm row. After confirm insert, pendingModeratorProposals ignores that confirm so the original propose id can still match. Refresh notify loads the actor name. Reject re-lists before dropping proposal rows so a concurrent re-propose keeps the bell. * Re-list pending before and after a proposal refresh notify. A concurrent reject can empty the queue between seeing a newer propose and writing moderator_proposal rows. Fan-out only when that same edge id is still pending, and drop the rows if it is not after notify. * Confirm only the unique open propose and restore notify after reject clear. Two concurrent proposes must not confirm the newer extra that propose cleanup is about to delete. After reject drops proposal rows, re-list and fan out if a newer propose already reopened. Name reject on createApp. * Share proposal fan-out and re-list after notify on reject too. Reject after-clear uses the same best-effort helper as propose refresh: lookup and notify cannot roll back the reject, and a second re-list drops stale moderator_proposal rows if the queue closed during fan-out. * Drop reject notifications only after the reject row is kept. Clear and fan-out run after the persist try so a list throw cannot undo the reject. If pending id changes during fan-out, one extra round notifies the new propose instead of leaving the bell empty. * Drop proposal rows after a second pending-id change during fan-out. A third propose during the refresh round is treated as a close for notification purposes. CONCEPT marks the reject pending rule and the winning-edge public graph as superseding the 2026-09-17 rows. * Include deleteEdgeById on the throwing debug trust-store mock. * Format the CONCEPT decisions table after the develop rebase. * Clear proposal rows before a pending-id fan-out refresh. * Name confirm 409 as an older open propose and drop proposal rows on appoint. * Say proposal notification rows drop on confirm, empty reject, or appoint. * List post-insert 409s on propose, confirm, and reject error bullets. * Fan out the current pending actor when fan-out opens on a new id. * Call the confirm unique-open check the oldest open propose. * Align unreadCount wording and name proposal notify as staff fan-out. * Keep a won propose or reject when a later extra delete or re-list throws. * Refresh proposal rows onto the oldest open propose after a lost insert. * Clear proposal rows before fan-out after a lost concurrent propose. * Document 409 remaining-pending fan-out and align unreadCount in the status line. * Name reject on the moderator role row. The last rebase onto inbox-photo develop dropped that word from the capabilities table. --------- Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- CONCEPT.md | 286 +- CONTRIBUTING.md | 4 +- SPEC.md | 149 +- docs/handbook/endpoints.md | 33 +- docs/handbook/functions.md | 51 +- docs/schema/notification.sql | 5 +- docs/schema/trust_edge.sql | 14 +- e2e/functions.spec.ts | 7 + e2e/http.spec.ts | 5 + scripts/gifts-debug.sh | 2 +- src/__tests__/lib/notification-store.test.ts | 53 + src/__tests__/lib/notification.test.ts | 138 + src/__tests__/lib/trust-store.test.ts | 174 +- src/__tests__/lib/trust.test.ts | 88 + src/__tests__/routes/debug-trust.test.ts | 56 +- src/__tests__/routes/notifications.test.ts | 70 + src/__tests__/routes/trust-chain.test.ts | 50 + src/__tests__/routes/trust.test.ts | 2679 ++++++++++++++++-- src/lib/notification-store.ts | 79 +- src/lib/notification.ts | 147 +- src/lib/trust-store.ts | 122 +- src/lib/trust.ts | 111 +- src/routes/debug-trust.ts | 16 +- src/routes/notifications.ts | 2 +- src/routes/trust-chain.ts | 41 +- src/routes/trust.ts | 373 ++- 26 files changed, 4115 insertions(+), 640 deletions(-) diff --git a/CONCEPT.md b/CONCEPT.md index 0896ae5b2..c3d819c06 100644 --- a/CONCEPT.md +++ b/CONCEPT.md @@ -150,21 +150,28 @@ A **moderator** is proposed by an existing moderator and confirmed by a **different** staff member, or appointed directly by a founder. Those grants persist as trust edges (`POST /trust/verify`, `POST /trust/propose-moderator`, -`POST /trust/confirm-moderator`, `POST /trust/appoint-moderator`). +`POST /trust/confirm-moderator`, `POST /trust/reject-moderator`, +`POST /trust/appoint-moderator`). `GET /trust-chain` requires a member Bearer session (any role) and returns founder seeds; `?around=` returns one hop of stored public edges with -one incoming kind per subject (no inferred links). +at most one incoming edge per subject (no inferred links). Operator `PATCH /debug/accounts/:id` can still set `role` and does not write trust edges; `POST /debug/trust-edges` backfills stored edges and -`DELETE /debug/trust-edges` removes one `(subjectId, kind)` row, both -without changing `role`. - -| Role | Capabilities | -| --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Basis | Log in, maintain a profile, receive gifts (default). No forum tag. Pays 1 sat to 21.gifts before posting or replying. | -| Verified | Everything Basis can, plus a forum tag: a moderator physically met this person. Not Lightning-Address proof-of-control. May post and reply without a Bitcoin payment. | -| Moderator | Everything Verified can, plus content moderation, the staff inbox, the closed Moderators group and the staff trust routes (verify a member, propose or confirm a moderator). Forum tag. | -| Founder | Everything Moderator can, plus appointing moderators directly. Forum tag. | +`DELETE /debug/trust-edges` removes the latest stored `(subjectId, kind)` +row (`createdAt` desc, then `id` desc), both without changing `role`. + +| Role | Capabilities | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Basis | Log in, maintain a profile, receive gifts (default). No forum tag. Pays 1 sat to 21.gifts before posting or replying. | +| Verified | Everything Basis can, plus a forum tag: a moderator physically met this person. Not Lightning-Address proof-of-control. May post and reply without a Bitcoin payment. | +| Moderator | Everything Verified can, plus content moderation, the staff inbox, the closed Moderators group and the staff trust routes (verify a member, propose, confirm, or reject a moderator). Forum tag. | +| Founder | Everything Moderator can, plus appointing moderators directly. Forum tag. | +| Role | Capabilities | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Basis | Log in, maintain a profile, receive gifts (default). No forum tag. | +| Verified | Everything Basis can, plus a forum tag: a moderator physically met this person. Not Lightning-Address proof-of-control. May reply without a Bitcoin payment. | +| Moderator | Everything Verified can, plus content moderation, the staff inbox, the closed Moderators group and the staff trust routes (verify a member, propose, confirm, or reject a moderator). Forum tag. | +| Founder | Everything Moderator can, plus appointing moderators directly. Forum tag. | Becoming a **donor** is an upgrade available to every account, not a role of its own (see below). The forum shows a tag only for Verified, Moderator, and @@ -516,8 +523,10 @@ Encryption: AES-GCM 256, with two key-derivation paths: - Basic anti-abuse: rate-limit per account, malformed-input rejection - Moderation: hide/unhide content endpoints (Moderator role); staff POST /trust/verify, confirm-moderator, appoint-moderator write role + edge; - POST /trust/propose-moderator writes the propose edge only; PATCH - /debug/accounts/:id may still set role and does not write edges + POST /trust/propose-moderator writes the propose edge only; POST + /trust/reject-moderator writes append-only `moderator_reject` (role + unchanged); PATCH /debug/accounts/:id may still set role and does not + write edges - USD → sats conversion for recurring-gift amounts via an exchange-rate source (fail-closed on a missing or implausible rate; paying stays in the spend worker) @@ -781,88 +790,175 @@ repository — they're intentionally not part of this project's scope. ## Decisions Log -| Date | Decision | -| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 2026-05-25 | Domain `21.gifts` registered (premium .gifts TLD on Identity Digital) | -| 2026-05-25 | GitHub organization `21gifts` created | -| 2026-05-25 | Docker Hub organization `21gifts` created | -| 2026-05-25 | Tech stack: Next.js 15 + TS strict + Tailwind + Zustand, mirroring the zkCoins-app pattern | -| 2026-05-25 | Passkey + PRF + NIP-06 derivation chosen as the key model (over `PRF → HKDF → nsec` direct path) | -| 2026-05-25 | No external WebAuthn library — `navigator.credentials.*` directly | -| 2026-05-25 | Lightning Address (LUD-16) mandatory for receivers; platform never custodies funds | -| 2026-05-25 | English-only product (no i18n in v1) | -| 2026-05-25 | Hard-fail on PRF-unsupported authenticators (no silent fallback) | -| 2026-05-25 | Multi-repo architecture: `api`, `app`, `docs` (later), `landing-page` (later), `marketing` (private, later) | -| 2026-05-25 | Thin-client / thick-server: app holds only keys+signing+UI; everything else (relay I/O, indexing, discovery, LN-Address resolution, anti-abuse) lives in api | -| 2026-05-25 | Backend service is named `api` and is built from day one — not deferred | -| 2026-05-25 | Canonical project documentation (CONCEPT, ROADMAP, SPEC) lives in `21gifts/api`; the app repo carries only frontend-specific docs | -| 2026-05-25 | Backend stack: **TypeScript + Bun + Hono + Vitest** (revised from Rust + Axum). Workload is I/O-bound, not CPU-bound; language symmetry with the app wins | -| 2026-05-25 | NOSTR relay: shared `nostr.space` infra (`wss://relay.nostr.space` / `wss://dev-relay.nostr.space`). 21.gifts is a client, not an operator. Closes OQ #3 | -| 2026-05-25 | Hard 100% coverage gate (lines + branches + functions + statements) enforced via `vitest.config.ts` thresholds; CI red until met | -| 2026-05-25 | TSDoc on every exported symbol, enforced via `eslint-plugin-tsdoc` | -| 2026-07-05 | v1 account model: Basis (login + receive, default for every account) and Moderator (content moderation); donor is an upgrade, not a role | -| 2026-07-05 | v1 login: **LNURL-auth (LUD-04) only** — no email, no password, no passkey; `linkingKey` = account identifier; lockout risk explicitly accepted; auth callback host pinned to `api.21.gifts` / `dev-api.21.gifts` | -| 2026-08-22 | Auth callback host (wallet `linkingKey` domain) moved to the public apex `21.gifts` / `dev.21.gifts`. Supersedes the 2026-07-05 pin to `api.21.gifts`. App public URL is the apex; `app.21.gifts` stays a transitional alias. In-memory accounts from the old host do not survive. | -| 2026-07-05 | v1 donor spending: custodial via deposited `lndhub://` export, restricted to `lightning.space` wallets; explicit transitional deviation from Core Principles 2/5, replaced by a non-custodial setup later | -| 2026-07-05 | Recurring daily gifts are a v1 feature: server-side scheduler in the api with fail-closed payout semantics | -| 2026-07-05 | Receiver verification: micro-payment with one-time nonce in the LUD-12 comment (WoS-compatible, min 1 sat); no LUD-21 dependency (WoS lacks it) | -| 2026-07-05 | Research recorded: WoS has no official API; WoS supports LNURL-auth (Classic since 2023, Self-Custody since app v3.2.5 / 2026-02-04) | -| 2026-07-05 | v1 NOSTR events are platform-signed (users hold no keys until the non-custodial phase) — attribution details open in OQ #9 | -| 2026-07-05 | Revised same day: v1 NOSTR is **fully custodial** — one keypair per account, generated server-side, `nsec` encrypted at rest, events signed with the account's own key. Supersedes the platform-signed row above; resolves OQ #9 (migration path to user-owned keys stays open) | -| 2026-08-15 | CORS on the api allows `DELETE` so the browser app can unlink a Lightning Address; `SPEC.md` added as the HTTP contract home | -| 2026-08-15 | Receiver address verification endpoints: `POST /me/lightning-address/verification` and `…/confirm`; api pays 1 sat (or provider `minSendable` ≤ 10 sat) with a LUD-12 comment nonce; **503** until an invoice payer is wired (process still boots) | -| 2026-08-15 | Core UI journeys sketched in `FLOWS.md` (sign-in, profile, donate, recurring gifts, message). Implemented screens cite `SPEC.md` only; donate / recurring / message remain CONCEPT sketches with no HTTP | -| 2026-08-15 | Public `GET /lightning-address` resolves LUD-16 metadata (callback, min/max sendable, optional commentAllowed) with a 5-minute in-memory cache; the process still boots with no extra env. Gift invoices stay browser-side. | -| 2026-08-23 | Spend-worker invoice HTTP: `POST /invoices` fetches a recipient BOLT11 via LNURL-pay; `POST /invoices/proof` accepts the payment preimage. Paying is the external spend worker via lightning.space LNDHub — this api does not store LNDHub credentials or pay. `SPEND_API_TOKEN` optional (503 until set). **Supersedes** the 2026-07-05 in-api scheduler/LNDHub-pay decision and the 2026-08-15 “gift invoices stay browser-side” note for the spend-worker path. | -| 2026-08-24 | v1 login is **passkey only**; LNURL-auth (LUD-04) endpoints, QR login, and `/auth/session` poll removed. `linkingKey` remains a nullable historical column. LNURL-pay (donate / invoices / address verification) is unchanged. **Supersedes** the 2026-07-05 LNURL-auth-only login decision. | -| 2026-08-24 | Matching `POST /invoices/proof` inserts an outbound `gift` row when `DATABASE_URL` is set so `GET /gifts/stats` includes spend-worker payments. Insert failure logs `gifts.record_failed` and still returns 200. Memory boots keep a no-op recorder. | -| 2026-08-24 | Public `GET /gifts?day=YYYY-MM-DD` lists each outbound gift on that UTC day (time, recipient, sats/BTC/USD at that day's close). No invoices. Empty day is 200. | -| 2026-08-28 | v1 public comments ship as custodial HTTP `GET/POST /messages` (name snapshot, text, timestamp); kind:1 relay fan-out remains unwired. | -| 2026-08-29 | Member-forum posts are **top-level kind:1** notes (not replies). GET/POST `/messages` include `sats` and `payable`. `POST /messages/:id/invoice` is a NIP-57 zap. Guest Send-a-gift is removed from the app. Worker fans out when `NOSTR_PUBLISH=1`. | -| 2026-08-29 | Worker indexes validated kind:9735 zap receipts onto `message.sats` (durable `nostr_zap_receipt`, LNURL provider pubkey + bolt11 amount). Kind:1 EVENT frames are published as JSON objects so relays can ACK. | -| 2026-08-29 | `POST /me/lightning-address` live-resolves LUD-16 and requires NIP-57 zap metadata before save (no migration of existing rows). Invoice limiter on `POST /messages/:id/invoice` runs only after auth, amount, payable, and KEK checks so early 400/404/401/503 do not burn quota. | -| 2026-08-29 | Forum display roles on exclusive `account.role`: `basis` \| `verified` \| `moderator` \| `founder`. New passkey accounts stay `basis`. `verified` = moderator physically met the person (not `lightningAddressVerified`). `GET/POST /messages` always include live author `role` (missing author → `basis`). Operator assignment via `PATCH /debug/accounts/:id` (`DEBUG_TOKEN`). | -| 2026-08-29 | Public member forum UX is a messenger-group thread (oldest top, newest bottom above the composer). `GET /messages` remains the latest-200 window newest-first; clients reverse for display. | -| 2026-08-29 | Zap ingest and invoice `relays` always include the public list (space plus Damus / Primal / nos.lol); kind:1 public write stays gated on `NOSTR_PUBLISH_PUBLIC`. | -| 2026-08-29 | Zap-receipt sats UPDATE qualifies `message.sats` so Postgres can apply it. | -| 2026-08-30 | Web Push is self-hosted VAPID in this api (no third-party push SDK). Missing `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` → process still boots; push HTTP 503. Subscriptions bind to `account.id`. Outbox worker sends. Events: forum posts notify every other subscribed account (collapse tag `forum`); a newly indexed zap notifies the note author. iOS v1 is Home Screen (A2HS). Payloads are English `{ type, title, body, url, tag }`. | -| 2026-09-09 | `forum.post` requires a non-blank Lightning Address in addition to rules + name (skip timestamps still do not satisfy). `POST /messages` 409 `missing_requirements` includes `lightning-address` when it is factually missing. `ensureProfileMessage` no-ops without LN; linking LN after a name creates the profile note. `contact.post`, `forum.read`, and `forum.pay` unchanged; existing message rows are not deleted. | -| 2026-09-11 | Forum UI/API lists only 21.gifts-author replies (`account_id IS NOT NULL`); `replyCount` and `GET /messages/:id/replies` omit unknown-npub children. The worker no longer persists inbound kind:1 replies whose pubkey is not a 21.gifts account. Existing Damus-only reply rows stay in storage but are omitted from lists/`replyCount`. Public `GET /messages/:id` of a Damus-only reply is 404; top-level Damus-only notes stay 200. No migration or soft-hide backfill. | -| 2026-09-12 | A 21.gifts-author forum reply writes a Notifications row for the parent author and enqueues one targeted Web Push (`/notifications`, tag `forum_reply:`). The booted process always has those stores (memory or Postgres). It does not copy into the member↔member inbox. Top-level notes still broadcast. Self-replies and Damus-only parents do not notify. Failure does not fail POST /messages. | -| 2026-09-12 | Operator `DEBUG_TOKEN` debug reads every persisted forum row (live, soft-hidden, and replies) via `GET /debug/messages` and `GET /debug/messages/:id`; hidden JPEG/PNG/WebP bytes via `GET /debug/messages/:id/photo`. Soft-hide remains a public-API filter only; public `GET /messages` hide behaviour is unchanged. | -| 2026-09-12 | Public gift stats/day also return historical CHF/EUR/PHP (USD × Frankfurter ECB; missing fiat is null, not 503). | -| 2026-09-13 | Three convictions are canonical (CONCEPT "Convictions"): giving is a duty of every Christian; direct giving with no middleman is the best and most beautiful way; Bitcoin is the most effective money available today. Public copy is `/about` in the app: states the convictions, quotes the verses, no inclusion slogan. Matthew 10:8 unchanged. Principle 7: visitor UI localized (`en`, `de`, `es`, `fil`). **Supersedes** the 2026-05-25 English-only decision. | -| 2026-09-14 | Spend-worker payouts are ping-triggered from a new top-level `POST /messages`; replies do not pay. Unset `SPEND_URL` or `SPEND_API_TOKEN` skips the ping; the process still boots. | -| 2026-09-14 | Living-room post, reply, and zap notify every bell subscriber (accounts with ≥1 `push_subscription`) except the actor/payer. In-app kinds `forum_post` / `forum_reply` / `zap`. Push URLs `/notifications`; tags `forum_post:`, `forum_reply:`, `zap:`. Damus-only parents still fan out. Self-reply skips only the actor. Missing `pushStore` is a no-op. Unique remains `(recipient, type, reply_id)`. **Supersedes** the 2026-09-12 parent-author-only reply notify. | -| 2026-09-15 | In-app living-room notifications go to every account except the actor/payer. Web Push still goes only to bell subscribers (`push_subscription`). Missing `pushStore` no longer drops in-app rows when `auth` is set. **Supersedes** the 2026-09-14 in-app-only-via-subscription rule. | -| 2026-09-16 | A zap that inserts a gift-reply fans out only `notifyZap` (one in-app row + one Web Push), not a second `forum_reply`. Gift-reply row still lands in the thread. **Supersedes** the 2026-09-14/15 dual fan-out for that path. | -| 2026-09-16 | Stored per-account `notificationLevel` (`all` / `active` / `mentions`). Fan-out filters in-app rows and Web Push by that level (`POST /me/notification-level`; default `all`). `GET /notifications` lists stored rows unfiltered, including `moderator_appointed`. **Supersedes** the 2026-09-15 every-account in-app notify. | -| 2026-09-20 | `GET /notifications` applies the owner's `notificationLevel` to stored rows (same `wantsNotification` rules as write-time fan-out). `unreadCount` is matching unread. `moderator_appointed` always stays. **Supersedes** the 2026-09-16 unfiltered GET list. | -| 2026-09-12 | Trust edges persist who verified whom and who proposed/confirmed/appointed a moderator. Public graph JSON is stored nodes+edges only (no synthetic links; `moderator_propose` is omitted). Staff `POST /trust/verify`, `confirm-moderator`, and `appoint-moderator` write role + edge; `POST /trust/propose-moderator` writes the propose edge only. Operator `POST /debug/trust-edges` backfills edges without changing `role`. `PATCH /debug/accounts/:id` still sets `role` only and does not write trust edges. | -| 2026-09-13 | Public `GET /trust-chain` returns founder seeds only. `GET /trust-chain?around=` returns that chain member plus one hop of stored public edges so a thousand-person chain is loaded by click, not dumped on first paint. **Supersedes** the 2026-09-12 GET form (stored graph, never a first-paint dump). | -| 2026-09-16 | `GET /trust-chain` requires a member Bearer session (any role, including basis). Missing or invalid Bearer is 401 `{ "error": "Unauthorized" }`. Neighborhood shape is unchanged: founder seeds on the bare GET; `?around=` one hop of stored public edges (`moderator_propose` omitted). **Supersedes** the 2026-09-13 public GET form. | -| 2026-09-17 | Public Trust Chain credits the proposer, not the confirmer. Projected kinds are stored `verify` / `moderator_propose` (only once the subject is a `moderator`) / `moderator_appoint`. `moderator_confirm` is omitted. A pending propose (subject still `verified`) stays private and is not a hop neighbor. Operator DELETE /debug/trust-edges removes one stored (subjectId, kind) row without changing role. **Supersedes** the 2026-09-12 public-graph kinds (`moderator_propose` omitted) and the 2026-09-16 neighborhood kinds parenthetical. | -| 2026-09-17 | Public Trust Chain picks one incoming kind per subject: the oldest eligible sibling (`createdAt` then `id`). Eligible: `verify`, `moderator_appoint`, and `moderator_propose` only when the subject is a `moderator`. First contact wins; later appoint, confirm, or propose do not replace it. A pending propose (subject still `verified`) stays private. **Supersedes** the 2026-09-17 kind-priority projection (`moderator_propose` if moderator, else `verify`, else `moderator_appoint`). | -| 2026-09-16 | confirm/appoint notify only the subject (`moderator_appointed`); Web Push `url` `/welcome`, tag `moderator_appointed:`; not a living-room fan-out; unique `(recipient, type, reply_id)` with `reply_id` = subject id; missing stores no-op; failure does not fail the trust POST. | -| 2026-09-17 | Staff Bearer `GET /trust/proposals` lists pending `moderator_propose` (verified subject, no confirm/appoint). Session `GET /trust-chain` still omits a pending propose; once the subject is a `moderator`, that propose is eligible as the public incoming edge only when it is the oldest eligible sibling. | -| 2026-09-21 | `eligibleToday` does not require a grant until UTC `2026-09-25` (`FUNDING_REQUIRED_FROM_UTC`). Until then, non-`basis` accounts stay eligible (passkey and living-room post still gate issue). From that day the 2026-09-20 grant matrix applies. **Softens** the 2026-09-20 spend/invoice grant cutover. | -| 2026-09-20 | Funding-program grants (`funding_grant`) are independent of `account.role`. `verified` remains a real-life meeting (forum badge). Moderators review posts against the three convictions. `basis` cannot apply; owner JSON `funding` is `null`. Status none → pending → trial (eligible only on that UTC day) or admitted (recurring) or rejected (may re-apply). Expired trial is effective pending (lazy persist). Spend ping and `POST /invoices` require `eligibleToday`; `GET /invoices/eligible?address=` returns `{ eligible }`. | -| 2026-09-17 | Inbox last-read is per (account, conversation). `GET /conversations` adds per-row `unread` and list `unreadCount`; `POST /conversations/:id/read` stamps last-read. Does not copy DMs into Notifications. | -| 2026-09-17 | Inbound private messages enqueue Web Push (`type: conversation`, url `/messages?c=`, tag `conversation:`) to bell subscribers only. No in-app Notification rows for DMs. Every outbox `unreadCount` (forum, zap, conversation) is notification unread plus listed inbox unread. Push failure does not fail HTTP 200 or Nostr ingest. | -| 2026-09-17 | A living-room note may carry up to 10 JPEG/PNG/WebP stills. Photo 0 stays on `message.photo` (Damus `/photo.jpg` unchanged). Extras 1–9 live in `message_extra_photo` and are served at `/messages/:id/photo/1.jpg` … `/photo/9.webp`. Public JSON includes `photoCount` (0–10). POST accepts `photos[]` (max 10) and still accepts singular `photo`. Video stays exclusive (poster = photo 0, no extras). | -| 2026-09-18 | External NOSTR identities become visible on the website only after a verified zap of at least 1 sat on a 21.gifts forum note; the protocol remains open. The zap creates an external gift-reply on an eligible top-level note and permanently entitles that pubkey's kind:1 replies. Staff hiding an external row blocks the pubkey and hides its other live rows. Public JSON marks these rows with `via: "nostr"` and never exposes the pubkey. **Supersedes** the 2026-09-11 rule that all unknown-pubkey replies are omitted. | -| 2026-09-20 | Roles are a strict hierarchy founder > moderator > verified > basis; every permission is a minimum role (roleAtLeast), and text names only that minimum role. The closed Moderators group follows the same rule. | -| 2026-09-20 | Staff `DELETE /messages/:id` still only soft-hides on 21.gifts, then best-effort publishes NIP-09 `kind: 5` (author nsec, durability relay plus Damus/Primal/nos.lol, not gated on `NOSTR_PUBLISH*`) and purges cached public photo/video URLs at Cloudflare when `CLOUDFLARE_ZONE_ID` + `CLOUDFLARE_API_TOKEN` are set. Failure still 204. Debug restore does not undelete Nostr. **Supersedes** the 2026-09-12 “soft-hide remains a public-API filter only” note. | -| 2026-09-20 | Staff GET of a soft-hidden forum note returns who hid it and when. Hide retracts in-app notifications for the note and its direct children. `GET /notifications` drops leftover hidden `forum_post` / `forum_reply` rows (zap still checks only the parent because `replyId` is a receipt UUID). | -| 2026-09-20 | Official platform account (`isPlatform`) never fans out living-room `forum_post` / `forum_reply` / `zap` (in-app or Web Push). House daily gift-replies still persist. **Supersedes** the 2026-09-14/15/16 living-room fan-out for the platform actor only. | -| 2026-09-20 | Moderators-group Web Push (`type: conversation`) opens `/moderate/group` (the staff-room thread). Member DM push stays `/messages?c=`; forum/zap stay `/notifications`. Tag remains `conversation:`. No in-app Notification rows; `GET /conversations` still omits `moderator_group`. **Supersedes** only the staff-room URL in the 2026-09-17 conversation-push row. | -| 2026-09-20 | A paid moderator stipend appears in the closed Moderators group as a house ("21.gifts") message carrying the paid sats, after the triggering group message, at payment time. Spend pings `{ address, kind: "moderator", groupMessageId }`; proof attaches that row. A missing or mismatched group reference never blocks the payout. | -| 2026-09-20 | Passkey authenticate/register finish and operator debug session mint refuse an account whose stored `sessionRefused` flag is true: HTTP 403 with the wrong-account error and no bearer. `GET /me` with an already-minted token for that row is the same 403 so the client can sign the visitor out. Other authenticated routes treat that token as missing (401). Operators set the flag with `PATCH /debug/accounts/:id`. The account row is not deleted. **Supersedes** the same-day listed-id copy of this row. | -| 2026-09-20 | A paid moderator stipend in the closed Moderators group carries a durable `gift_for_message_id` / public `giftFor` link to the group message that triggered it, so the app can render the stipend row attached under that message. Absent or null on every other conversation row. | -| 2026-09-21 | `GET /conversations` (and other public conversation list rows) include per-row `unreadMessageCount` (inbound messages strictly after last-read; `0` when none). `unread` stays `unreadMessageCount > 0`. Envelope `unreadCount` remains the number of listed unread threads (menu/PWA badge). Gift-only inbound counts; outbound does not. | -| 2026-09-21 | Optional `GET /messages?hashtag=` token filter on live top-level `text` (name without `#`; token match; combines with `mode`/`limit`/`cursor`). No new entity, table, or index. A shops page of 20 is 20 matching notes, not 20 mixed notes filtered later. | -| 2026-09-21 | Basis accounts pay 1 sat to 21.gifts before they can post or reply (`GET /messages/compose-target` then invoice the platform profile note). Unpaid `POST /messages` is 403 for anyone below verified, including the parent author. Verified, moderator, and founder stay unpaid-write exempt. Extra gifts on someone else’s note still pay that author. The worker always queries that profile note’s event id even after it ages out of `listLatest`. **Supersedes** the Roles table line that only mentioned unpaid replies for Verified. | -| 2026-09-21 | JPEG/PNG/WebP stills (max 10, photo-only send) on every private conversation kind (Direct, Contact, Damus, Moderators group). Bytes stay on authenticated GET photo routes (`Cache-Control: private, no-store`). Photo-bearing rows skip Nostr (`nostrPublishState: skipped`); text-only Direct/Contact/Damus stay `pending`. Spend ping stays `moderator_group` only. | +| Date | Decision | +| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-05-25 | Domain `21.gifts` registered (premium .gifts TLD on Identity Digital) | +| 2026-05-25 | GitHub organization `21gifts` created | +| 2026-05-25 | Docker Hub organization `21gifts` created | +| 2026-05-25 | Tech stack: Next.js 15 + TS strict + Tailwind + Zustand, mirroring the zkCoins-app pattern | +| 2026-05-25 | Passkey + PRF + NIP-06 derivation chosen as the key model (over `PRF → HKDF → nsec` direct path) | +| 2026-05-25 | No external WebAuthn library — `navigator.credentials.*` directly | +| 2026-05-25 | Lightning Address (LUD-16) mandatory for receivers; platform never custodies funds | +| 2026-05-25 | English-only product (no i18n in v1) | +| 2026-05-25 | Hard-fail on PRF-unsupported authenticators (no silent fallback) | +| 2026-05-25 | Multi-repo architecture: `api`, `app`, `docs` (later), `landing-page` (later), `marketing` (private, later) | +| 2026-05-25 | Thin-client / thick-server: app holds only keys+signing+UI; everything else (relay I/O, indexing, discovery, LN-Address resolution, anti-abuse) lives in api | +| 2026-05-25 | Backend service is named `api` and is built from day one — not deferred | +| 2026-05-25 | Canonical project documentation (CONCEPT, ROADMAP, SPEC) lives in `21gifts/api`; the app repo carries only frontend-specific docs | +| 2026-05-25 | Backend stack: **TypeScript + Bun + Hono + Vitest** (revised from Rust + Axum). Workload is I/O-bound, not CPU-bound; language symmetry with the app wins | +| 2026-05-25 | NOSTR relay: shared `nostr.space` infra (`wss://relay.nostr.space` / `wss://dev-relay.nostr.space`). 21.gifts is a client, not an operator. Closes OQ #3 | +| 2026-05-25 | Hard 100% coverage gate (lines + branches + functions + statements) enforced via `vitest.config.ts` thresholds; CI red until met | +| 2026-05-25 | TSDoc on every exported symbol, enforced via `eslint-plugin-tsdoc` | +| 2026-07-05 | v1 account model: Basis (login + receive, default for every account) and Moderator (content moderation); donor is an upgrade, not a role | +| 2026-07-05 | v1 login: **LNURL-auth (LUD-04) only** — no email, no password, no passkey; `linkingKey` = account identifier; lockout risk explicitly accepted; auth callback host pinned to `api.21.gifts` / `dev-api.21.gifts` | +| 2026-08-22 | Auth callback host (wallet `linkingKey` domain) moved to the public apex `21.gifts` / `dev.21.gifts`. Supersedes the 2026-07-05 pin to `api.21.gifts`. App public URL is the apex; `app.21.gifts` stays a transitional alias. In-memory accounts from the old host do not survive. | +| 2026-07-05 | v1 donor spending: custodial via deposited `lndhub://` export, restricted to `lightning.space` wallets; explicit transitional deviation from Core Principles 2/5, replaced by a non-custodial setup later | +| 2026-07-05 | Recurring daily gifts are a v1 feature: server-side scheduler in the api with fail-closed payout semantics | +| 2026-07-05 | Receiver verification: micro-payment with one-time nonce in the LUD-12 comment (WoS-compatible, min 1 sat); no LUD-21 dependency (WoS lacks it) | +| 2026-07-05 | Research recorded: WoS has no official API; WoS supports LNURL-auth (Classic since 2023, Self-Custody since app v3.2.5 / 2026-02-04) | +| 2026-07-05 | v1 NOSTR events are platform-signed (users hold no keys until the non-custodial phase) — attribution details open in OQ #9 | +| 2026-07-05 | Revised same day: v1 NOSTR is **fully custodial** — one keypair per account, generated server-side, `nsec` encrypted at rest, events signed with the account's own key. Supersedes the platform-signed row above; resolves OQ #9 (migration path to user-owned keys stays open) | +| 2026-08-15 | CORS on the api allows `DELETE` so the browser app can unlink a Lightning Address; `SPEC.md` added as the HTTP contract home | +| 2026-08-15 | Receiver address verification endpoints: `POST /me/lightning-address/verification` and `…/confirm`; api pays 1 sat (or provider `minSendable` ≤ 10 sat) with a LUD-12 comment nonce; **503** until an invoice payer is wired (process still boots) | +| 2026-08-15 | Core UI journeys sketched in `FLOWS.md` (sign-in, profile, donate, recurring gifts, message). Implemented screens cite `SPEC.md` only; donate / recurring / message remain CONCEPT sketches with no HTTP | +| 2026-08-15 | Public `GET /lightning-address` resolves LUD-16 metadata (callback, min/max sendable, optional commentAllowed) with a 5-minute in-memory cache; the process still boots with no extra env. Gift invoices stay browser-side. | +| 2026-08-23 | Spend-worker invoice HTTP: `POST /invoices` fetches a recipient BOLT11 via LNURL-pay; `POST /invoices/proof` accepts the payment preimage. Paying is the external spend worker via lightning.space LNDHub — this api does not store LNDHub credentials or pay. `SPEND_API_TOKEN` optional (503 until set). **Supersedes** the 2026-07-05 in-api scheduler/LNDHub-pay decision and the 2026-08-15 “gift invoices stay browser-side” note for the spend-worker path. | +| 2026-08-24 | v1 login is **passkey only**; LNURL-auth (LUD-04) endpoints, QR login, and `/auth/session` poll removed. `linkingKey` remains a nullable historical column. LNURL-pay (donate / invoices / address verification) is unchanged. **Supersedes** the 2026-07-05 LNURL-auth-only login decision. | +| 2026-08-24 | Matching `POST /invoices/proof` inserts an outbound `gift` row when `DATABASE_URL` is set so `GET /gifts/stats` includes spend-worker payments. Insert failure logs `gifts.record_failed` and still returns 200. Memory boots keep a no-op recorder. | +| 2026-08-24 | Public `GET /gifts?day=YYYY-MM-DD` lists each outbound gift on that UTC day (time, recipient, sats/BTC/USD at that day's close). No invoices. Empty day is 200. | +| 2026-08-28 | v1 public comments ship as custodial HTTP `GET/POST /messages` (name snapshot, text, timestamp); kind:1 relay fan-out remains unwired. | +| 2026-08-29 | Member-forum posts are **top-level kind:1** notes (not replies). GET/POST `/messages` include `sats` and `payable`. `POST /messages/:id/invoice` is a NIP-57 zap. Guest Send-a-gift is removed from the app. Worker fans out when `NOSTR_PUBLISH=1`. | +| 2026-08-29 | Worker indexes validated kind:9735 zap receipts onto `message.sats` (durable `nostr_zap_receipt`, LNURL provider pubkey + bolt11 amount). Kind:1 EVENT frames are published as JSON objects so relays can ACK. | +| 2026-08-29 | `POST /me/lightning-address` live-resolves LUD-16 and requires NIP-57 zap metadata before save (no migration of existing rows). Invoice limiter on `POST /messages/:id/invoice` runs only after auth, amount, payable, and KEK checks so early 400/404/401/503 do not burn quota. | +| 2026-08-29 | Forum display roles on exclusive `account.role`: `basis` \| `verified` \| `moderator` \| `founder`. New passkey accounts stay `basis`. `verified` = moderator physically met the person (not `lightningAddressVerified`). `GET/POST /messages` always include live author `role` (missing author → `basis`). Operator assignment via `PATCH /debug/accounts/:id` (`DEBUG_TOKEN`). | +| 2026-08-29 | Public member forum UX is a messenger-group thread (oldest top, newest bottom above the composer). `GET /messages` remains the latest-200 window newest-first; clients reverse for display. | +| 2026-08-29 | Zap ingest and invoice `relays` always include the public list (space plus Damus / Primal / nos.lol); kind:1 public write stays gated on `NOSTR_PUBLISH_PUBLIC`. | +| 2026-08-29 | Zap-receipt sats UPDATE qualifies `message.sats` so Postgres can apply it. | +| 2026-08-30 | Web Push is self-hosted VAPID in this api (no third-party push SDK). Missing `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` → process still boots; push HTTP 503. Subscriptions bind to `account.id`. Outbox worker sends. Events: forum posts notify every other subscribed account (collapse tag `forum`); a newly indexed zap notifies the note author. iOS v1 is Home Screen (A2HS). Payloads are English `{ type, title, body, url, tag }`. | +| 2026-09-09 | `forum.post` requires a non-blank Lightning Address in addition to rules + name (skip timestamps still do not satisfy). `POST /messages` 409 `missing_requirements` includes `lightning-address` when it is factually missing. `ensureProfileMessage` no-ops without LN; linking LN after a name creates the profile note. `contact.post`, `forum.read`, and `forum.pay` unchanged; existing message rows are not deleted. | +| 2026-09-11 | Forum UI/API lists only 21.gifts-author replies (`account_id IS NOT NULL`); `replyCount` and `GET /messages/:id/replies` omit unknown-npub children. The worker no longer persists inbound kind:1 replies whose pubkey is not a 21.gifts account. Existing Damus-only reply rows stay in storage but are omitted from lists/`replyCount`. Public `GET /messages/:id` of a Damus-only reply is 404; top-level Damus-only notes stay 200. No migration or soft-hide backfill. | +| 2026-09-12 | A 21.gifts-author forum reply writes a Notifications row for the parent author and enqueues one targeted Web Push (`/notifications`, tag `forum_reply:`). The booted process always has those stores (memory or Postgres). It does not copy into the member↔member inbox. Top-level notes still broadcast. Self-replies and Damus-only parents do not notify. Failure does not fail POST /messages. | +| 2026-09-12 | Operator `DEBUG_TOKEN` debug reads every persisted forum row (live, soft-hidden, and replies) via `GET /debug/messages` and `GET /debug/messages/:id`; hidden JPEG/PNG/WebP bytes via `GET /debug/messages/:id/photo`. Soft-hide remains a public-API filter only; public `GET /messages` hide behaviour is unchanged. | +| 2026-09-12 | Public gift stats/day also return historical CHF/EUR/PHP (USD × Frankfurter ECB; missing fiat is null, not 503). | +| 2026-09-13 | Three convictions are canonical (CONCEPT "Convictions"): giving is a duty of every Christian; direct giving with no middleman is the best and most beautiful way; Bitcoin is the most effective money available today. Public copy is `/about` in the app: states the convictions, quotes the verses, no inclusion slogan. Matthew 10:8 unchanged. Principle 7: visitor UI localized (`en`, `de`, `es`, `fil`). **Supersedes** the 2026-05-25 English-only decision. | +| 2026-09-14 | Spend-worker payouts are ping-triggered from a new top-level `POST /messages`; replies do not pay. Unset `SPEND_URL` or `SPEND_API_TOKEN` skips the ping; the process still boots. | +| 2026-09-14 | Living-room post, reply, and zap notify every bell subscriber (accounts with ≥1 `push_subscription`) except the actor/payer. In-app kinds `forum_post` / `forum_reply` / `zap`. Push URLs `/notifications`; tags `forum_post:`, `forum_reply:`, `zap:`. Damus-only parents still fan out. Self-reply skips only the actor. Missing `pushStore` is a no-op. Unique remains `(recipient, type, reply_id)`. **Supersedes** the 2026-09-12 parent-author-only reply notify. | +| 2026-09-15 | In-app living-room notifications go to every account except the actor/payer. Web Push still goes only to bell subscribers (`push_subscription`). Missing `pushStore` no longer drops in-app rows when `auth` is set. **Supersedes** the 2026-09-14 in-app-only-via-subscription rule. | +| 2026-09-16 | A zap that inserts a gift-reply fans out only `notifyZap` (one in-app row + one Web Push), not a second `forum_reply`. Gift-reply row still lands in the thread. **Supersedes** the 2026-09-14/15 dual fan-out for that path. | +| 2026-09-16 | Stored per-account `notificationLevel` (`all` / `active` / `mentions`). Fan-out filters in-app rows and Web Push by that level (`POST /me/notification-level`; default `all`). `GET /notifications` lists stored rows unfiltered, including `moderator_appointed`. **Supersedes** the 2026-09-15 every-account in-app notify. | +| 2026-09-20 | `GET /notifications` applies the owner's `notificationLevel` to stored rows (same `wantsNotification` rules as write-time fan-out). `unreadCount` is matching unread. `moderator_appointed` always stays. **Supersedes** the 2026-09-16 unfiltered GET list. | +| 2026-09-12 | Trust edges persist who verified whom and who proposed/confirmed/appointed a moderator. Public graph JSON is stored nodes+edges only (no synthetic links; `moderator_propose` is omitted). Staff `POST /trust/verify`, `confirm-moderator`, and `appoint-moderator` write role + edge; `POST /trust/propose-moderator` writes the propose edge only. Operator `POST /debug/trust-edges` backfills edges without changing `role`. `PATCH /debug/accounts/:id` still sets `role` only and does not write trust edges. | +| 2026-09-13 | Public `GET /trust-chain` returns founder seeds only. `GET /trust-chain?around=` returns that chain member plus one hop of stored public edges so a thousand-person chain is loaded by click, not dumped on first paint. **Supersedes** the 2026-09-12 GET form (stored graph, never a first-paint dump). | +| 2026-09-16 | `GET /trust-chain` requires a member Bearer session (any role, including basis). Missing or invalid Bearer is 401 `{ "error": "Unauthorized" }`. Neighborhood shape is unchanged: founder seeds on the bare GET; `?around=` one hop of stored public edges (`moderator_propose` omitted). **Supersedes** the 2026-09-13 public GET form. | +| 2026-09-17 | Public Trust Chain credits the proposer, not the confirmer. Projected kinds are stored `verify` / `moderator_propose` (only once the subject is a `moderator`) / `moderator_appoint`. `moderator_confirm` is omitted. A pending propose (subject still `verified`) stays private and is not a hop neighbor. Operator DELETE /debug/trust-edges removes one stored (subjectId, kind) row without changing role. **Supersedes** the 2026-09-12 public-graph kinds (`moderator_propose` omitted) and the 2026-09-16 neighborhood kinds parenthetical. | +| 2026-09-17 | Public Trust Chain picks one incoming kind per subject: the oldest eligible sibling (`createdAt` then `id`). Eligible: `verify`, `moderator_appoint`, and `moderator_propose` only when the subject is a `moderator`. First contact wins; later appoint, confirm, or propose do not replace it. A pending propose (subject still `verified`) stays private. **Supersedes** the 2026-09-17 kind-priority projection (`moderator_propose` if moderator, else `verify`, else `moderator_appoint`). | +| 2026-09-16 | confirm/appoint notify only the subject (`moderator_appointed`); Web Push `url` `/welcome`, tag `moderator_appointed:`; not a living-room fan-out; unique `(recipient, type, reply_id)` with `reply_id` = subject id; missing stores no-op; failure does not fail the trust POST. | +| 2026-09-17 | Staff Bearer `GET /trust/proposals` lists pending `moderator_propose` (verified subject, no confirm/appoint). Session `GET /trust-chain` still omits a pending propose; once the subject is a `moderator`, that propose is eligible as the public incoming edge only when it is the oldest eligible sibling. | +| 2026-09-21 | `eligibleToday` does not require a grant until UTC `2026-09-25` (`FUNDING_REQUIRED_FROM_UTC`). Until then, non-`basis` accounts stay eligible (passkey and living-room post still gate issue). From that day the 2026-09-20 grant matrix applies. **Softens** the 2026-09-20 spend/invoice grant cutover. | +| 2026-09-20 | Funding-program grants (`funding_grant`) are independent of `account.role`. `verified` remains a real-life meeting (forum badge). Moderators review posts against the three convictions. `basis` cannot apply; owner JSON `funding` is `null`. Status none → pending → trial (eligible only on that UTC day) or admitted (recurring) or rejected (may re-apply). Expired trial is effective pending (lazy persist). Spend ping and `POST /invoices` require `eligibleToday`; `GET /invoices/eligible?address=` returns `{ eligible }`. | +| 2026-09-17 | Inbox last-read is per (account, conversation). `GET /conversations` adds per-row `unread` and list `unreadCount`; `POST /conversations/:id/read` stamps last-read. Does not copy DMs into Notifications. | +| 2026-09-17 | Inbound private messages enqueue Web Push (`type: conversation`, url `/messages?c=`, tag `conversation:`) to bell subscribers only. No in-app Notification rows for DMs. Every outbox `unreadCount` (forum, zap, conversation) is notification unread plus listed inbox unread. Push failure does not fail HTTP 200 or Nostr ingest. | +| 2026-09-17 | A living-room note may carry up to 10 JPEG/PNG/WebP stills. Photo 0 stays on `message.photo` (Damus `/photo.jpg` unchanged). Extras 1–9 live in `message_extra_photo` and are served at `/messages/:id/photo/1.jpg` … `/photo/9.webp`. Public JSON includes `photoCount` (0–10). POST accepts `photos[]` (max 10) and still accepts singular `photo`. Video stays exclusive (poster = photo 0, no extras). | +| 2026-09-18 | External NOSTR identities become visible on the website only after a verified zap of at least 1 sat on a 21.gifts forum note; the protocol remains open. The zap creates an external gift-reply on an eligible top-level note and permanently entitles that pubkey's kind:1 replies. Staff hiding an external row blocks the pubkey and hides its other live rows. Public JSON marks these rows with `via: "nostr"` and never exposes the pubkey. **Supersedes** the 2026-09-11 rule that all unknown-pubkey replies are omitted. | +| 2026-09-20 | Roles are a strict hierarchy founder > moderator > verified > basis; every permission is a minimum role (roleAtLeast), and text names only that minimum role. The closed Moderators group follows the same rule. | +| 2026-09-20 | Staff `DELETE /messages/:id` still only soft-hides on 21.gifts, then best-effort publishes NIP-09 `kind: 5` (author nsec, durability relay plus Damus/Primal/nos.lol, not gated on `NOSTR_PUBLISH*`) and purges cached public photo/video URLs at Cloudflare when `CLOUDFLARE_ZONE_ID` + `CLOUDFLARE_API_TOKEN` are set. Failure still 204. Debug restore does not undelete Nostr. **Supersedes** the 2026-09-12 “soft-hide remains a public-API filter only” note. | +| 2026-09-20 | Staff GET of a soft-hidden forum note returns who hid it and when. Hide retracts in-app notifications for the note and its direct children. `GET /notifications` drops leftover hidden `forum_post` / `forum_reply` rows (zap still checks only the parent because `replyId` is a receipt UUID). | +| 2026-09-20 | Official platform account (`isPlatform`) never fans out living-room `forum_post` / `forum_reply` / `zap` (in-app or Web Push). House daily gift-replies still persist. **Supersedes** the 2026-09-14/15/16 living-room fan-out for the platform actor only. | +| 2026-09-20 | Moderators-group Web Push (`type: conversation`) opens `/moderate/group` (the staff-room thread). Member DM push stays `/messages?c=`; forum/zap stay `/notifications`. Tag remains `conversation:`. No in-app Notification rows; `GET /conversations` still omits `moderator_group`. **Supersedes** only the staff-room URL in the 2026-09-17 conversation-push row. | +| 2026-09-20 | A paid moderator stipend appears in the closed Moderators group as a house ("21.gifts") message carrying the paid sats, after the triggering group message, at payment time. Spend pings `{ address, kind: "moderator", groupMessageId }`; proof attaches that row. A missing or mismatched group reference never blocks the payout. | +| 2026-09-20 | Passkey authenticate/register finish and operator debug session mint refuse an account whose stored `sessionRefused` flag is true: HTTP 403 with the wrong-account error and no bearer. `GET /me` with an already-minted token for that row is the same 403 so the client can sign the visitor out. Other authenticated routes treat that token as missing (401). Operators set the flag with `PATCH /debug/accounts/:id`. The account row is not deleted. **Supersedes** the same-day listed-id copy of this row. | +| 2026-09-20 | A paid moderator stipend in the closed Moderators group carries a durable `gift_for_message_id` / public `giftFor` link to the group message that triggered it, so the app can render the stipend row attached under that message. Absent or null on every other conversation row. | +| 2026-09-21 | `GET /conversations` (and other public conversation list rows) include per-row `unreadMessageCount` (inbound messages strictly after last-read; `0` when none). `unread` stays `unreadMessageCount > 0`. Envelope `unreadCount` remains the number of listed unread threads (menu/PWA badge). Gift-only inbound counts; outbound does not. | +| 2026-09-20 | Staff may reject an open moderator proposal. Reject is append-only `moderator_reject` (role stays `verified`). Re-propose after reject inserts a new `moderator_propose` (history kept). Pending = latest propose/reject is propose, subject still verified, no confirm/appoint. Unique live kinds are verify/confirm/appoint only (propose/reject may repeat). An open proposal fans out in-app `moderator_proposal` plus Web Push to other staff until confirm/reject; mark-read does not dismiss it. **Supersedes** the 2026-09-17 pending-propose list row (pending was any propose without confirm/appoint). | +| 2026-09-21 | Optional `GET /messages?hashtag=` token filter on live top-level `text` (name without `#`; token match; combines with `mode`/`limit`/`cursor`). No new entity, table, or index. A shops page of 20 is 20 matching notes, not 20 mixed notes filtered later. | +| 2026-09-21 | Basis accounts pay 1 sat to 21.gifts before they can post or reply (`GET /messages/compose-target` then invoice the platform profile note). Unpaid `POST /messages` is 403 for anyone below verified, including the parent author. Verified, moderator, and founder stay unpaid-write exempt. Extra gifts on someone else’s note still pay that author. The worker always queries that profile note’s event id even after it ages out of `listLatest`. **Supersedes** the Roles table line that only mentioned unpaid replies for Verified. | +| 2026-09-21 | JPEG/PNG/WebP stills (max 10, photo-only send) on every private conversation kind (Direct, Contact, Damus, Moderators group). Bytes stay on authenticated GET photo routes (`Cache-Control: private, no-store`). Photo-bearing rows skip Nostr (`nostrPublishState: skipped`); text-only Direct/Contact/Damus stay `pending`. Spend ping stays `moderator_group` only. | +| 2026-09-21 | Propose/confirm/reject re-list after insert so a concurrent older propose, a concurrent reject, or a concurrent confirm/appoint cannot leave two live outcomes. The public graph projects at most one incoming edge per subject (winning id), skipping non-chain oldest siblings. | +| 2026-09-21 | Propose/confirm/reject re-list after insert so a concurrent older propose, a concurrent reject, or a concurrent confirm/appoint cannot leave two live outcomes. Confirm and reject compare the pending propose by edge id (same-actor same-ms re-propose is a different row). After propose notify, re-list and drop or refresh `moderator_proposal` rows when that insert is no longer pending. The public graph projects at most one incoming edge per subject (winning id), skipping non-chain oldest siblings. | +| 2026-09-21 | Optional `GET /messages?hashtag=` token filter on live top-level `text` (name without `#`; token match; combines with `mode`/`limit`/`cursor`). No new entity, table, or index. A shops page of 20 is 20 matching notes, not 20 mixed notes filtered later. | +| 2026-09-21 | Propose/confirm/reject re-list after insert so a concurrent older propose, a concurrent reject, or a concurrent confirm/appoint cannot leave two live outcomes. Confirm and reject compare the pending propose by edge id (same-actor same-ms re-propose is a different row). After propose notify, re-list and drop or refresh `moderator_proposal` rows when that insert is no longer pending. The public graph projects at most one incoming edge per subject (winning id), skipping non-chain oldest siblings. **Supersedes** the 2026-09-17 “one incoming kind” / first-contact-wins row. | +| Date | Decision | +| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-05-25 | Domain `21.gifts` registered (premium .gifts TLD on Identity Digital) | +| 2026-05-25 | GitHub organization `21gifts` created | +| 2026-05-25 | Docker Hub organization `21gifts` created | +| 2026-05-25 | Tech stack: Next.js 15 + TS strict + Tailwind + Zustand, mirroring the zkCoins-app pattern | +| 2026-05-25 | Passkey + PRF + NIP-06 derivation chosen as the key model (over `PRF → HKDF → nsec` direct path) | +| 2026-05-25 | No external WebAuthn library — `navigator.credentials.*` directly | +| 2026-05-25 | Lightning Address (LUD-16) mandatory for receivers; platform never custodies funds | +| 2026-05-25 | English-only product (no i18n in v1) | +| 2026-05-25 | Hard-fail on PRF-unsupported authenticators (no silent fallback) | +| 2026-05-25 | Multi-repo architecture: `api`, `app`, `docs` (later), `landing-page` (later), `marketing` (private, later) | +| 2026-05-25 | Thin-client / thick-server: app holds only keys+signing+UI; everything else (relay I/O, indexing, discovery, LN-Address resolution, anti-abuse) lives in api | +| 2026-05-25 | Backend service is named `api` and is built from day one — not deferred | +| 2026-05-25 | Canonical project documentation (CONCEPT, ROADMAP, SPEC) lives in `21gifts/api`; the app repo carries only frontend-specific docs | +| 2026-05-25 | Backend stack: **TypeScript + Bun + Hono + Vitest** (revised from Rust + Axum). Workload is I/O-bound, not CPU-bound; language symmetry with the app wins | +| 2026-05-25 | NOSTR relay: shared `nostr.space` infra (`wss://relay.nostr.space` / `wss://dev-relay.nostr.space`). 21.gifts is a client, not an operator. Closes OQ #3 | +| 2026-05-25 | Hard 100% coverage gate (lines + branches + functions + statements) enforced via `vitest.config.ts` thresholds; CI red until met | +| 2026-05-25 | TSDoc on every exported symbol, enforced via `eslint-plugin-tsdoc` | +| 2026-07-05 | v1 account model: Basis (login + receive, default for every account) and Moderator (content moderation); donor is an upgrade, not a role | +| 2026-07-05 | v1 login: **LNURL-auth (LUD-04) only** — no email, no password, no passkey; `linkingKey` = account identifier; lockout risk explicitly accepted; auth callback host pinned to `api.21.gifts` / `dev-api.21.gifts` | +| 2026-08-22 | Auth callback host (wallet `linkingKey` domain) moved to the public apex `21.gifts` / `dev.21.gifts`. Supersedes the 2026-07-05 pin to `api.21.gifts`. App public URL is the apex; `app.21.gifts` stays a transitional alias. In-memory accounts from the old host do not survive. | +| 2026-07-05 | v1 donor spending: custodial via deposited `lndhub://` export, restricted to `lightning.space` wallets; explicit transitional deviation from Core Principles 2/5, replaced by a non-custodial setup later | +| 2026-07-05 | Recurring daily gifts are a v1 feature: server-side scheduler in the api with fail-closed payout semantics | +| 2026-07-05 | Receiver verification: micro-payment with one-time nonce in the LUD-12 comment (WoS-compatible, min 1 sat); no LUD-21 dependency (WoS lacks it) | +| 2026-07-05 | Research recorded: WoS has no official API; WoS supports LNURL-auth (Classic since 2023, Self-Custody since app v3.2.5 / 2026-02-04) | +| 2026-07-05 | v1 NOSTR events are platform-signed (users hold no keys until the non-custodial phase) — attribution details open in OQ #9 | +| 2026-07-05 | Revised same day: v1 NOSTR is **fully custodial** — one keypair per account, generated server-side, `nsec` encrypted at rest, events signed with the account's own key. Supersedes the platform-signed row above; resolves OQ #9 (migration path to user-owned keys stays open) | +| 2026-08-15 | CORS on the api allows `DELETE` so the browser app can unlink a Lightning Address; `SPEC.md` added as the HTTP contract home | +| 2026-08-15 | Receiver address verification endpoints: `POST /me/lightning-address/verification` and `…/confirm`; api pays 1 sat (or provider `minSendable` ≤ 10 sat) with a LUD-12 comment nonce; **503** until an invoice payer is wired (process still boots) | +| 2026-08-15 | Core UI journeys sketched in `FLOWS.md` (sign-in, profile, donate, recurring gifts, message). Implemented screens cite `SPEC.md` only; donate / recurring / message remain CONCEPT sketches with no HTTP | +| 2026-08-15 | Public `GET /lightning-address` resolves LUD-16 metadata (callback, min/max sendable, optional commentAllowed) with a 5-minute in-memory cache; the process still boots with no extra env. Gift invoices stay browser-side. | +| 2026-08-23 | Spend-worker invoice HTTP: `POST /invoices` fetches a recipient BOLT11 via LNURL-pay; `POST /invoices/proof` accepts the payment preimage. Paying is the external spend worker via lightning.space LNDHub — this api does not store LNDHub credentials or pay. `SPEND_API_TOKEN` optional (503 until set). **Supersedes** the 2026-07-05 in-api scheduler/LNDHub-pay decision and the 2026-08-15 “gift invoices stay browser-side” note for the spend-worker path. | +| 2026-08-24 | v1 login is **passkey only**; LNURL-auth (LUD-04) endpoints, QR login, and `/auth/session` poll removed. `linkingKey` remains a nullable historical column. LNURL-pay (donate / invoices / address verification) is unchanged. **Supersedes** the 2026-07-05 LNURL-auth-only login decision. | +| 2026-08-24 | Matching `POST /invoices/proof` inserts an outbound `gift` row when `DATABASE_URL` is set so `GET /gifts/stats` includes spend-worker payments. Insert failure logs `gifts.record_failed` and still returns 200. Memory boots keep a no-op recorder. | +| 2026-08-24 | Public `GET /gifts?day=YYYY-MM-DD` lists each outbound gift on that UTC day (time, recipient, sats/BTC/USD at that day's close). No invoices. Empty day is 200. | +| 2026-08-28 | v1 public comments ship as custodial HTTP `GET/POST /messages` (name snapshot, text, timestamp); kind:1 relay fan-out remains unwired. | +| 2026-08-29 | Member-forum posts are **top-level kind:1** notes (not replies). GET/POST `/messages` include `sats` and `payable`. `POST /messages/:id/invoice` is a NIP-57 zap. Guest Send-a-gift is removed from the app. Worker fans out when `NOSTR_PUBLISH=1`. | +| 2026-08-29 | Worker indexes validated kind:9735 zap receipts onto `message.sats` (durable `nostr_zap_receipt`, LNURL provider pubkey + bolt11 amount). Kind:1 EVENT frames are published as JSON objects so relays can ACK. | +| 2026-08-29 | `POST /me/lightning-address` live-resolves LUD-16 and requires NIP-57 zap metadata before save (no migration of existing rows). Invoice limiter on `POST /messages/:id/invoice` runs only after auth, amount, payable, and KEK checks so early 400/404/401/503 do not burn quota. | +| 2026-08-29 | Forum display roles on exclusive `account.role`: `basis` \| `verified` \| `moderator` \| `founder`. New passkey accounts stay `basis`. `verified` = moderator physically met the person (not `lightningAddressVerified`). `GET/POST /messages` always include live author `role` (missing author → `basis`). Operator assignment via `PATCH /debug/accounts/:id` (`DEBUG_TOKEN`). | +| 2026-08-29 | Public member forum UX is a messenger-group thread (oldest top, newest bottom above the composer). `GET /messages` remains the latest-200 window newest-first; clients reverse for display. | +| 2026-08-29 | Zap ingest and invoice `relays` always include the public list (space plus Damus / Primal / nos.lol); kind:1 public write stays gated on `NOSTR_PUBLISH_PUBLIC`. | +| 2026-08-29 | Zap-receipt sats UPDATE qualifies `message.sats` so Postgres can apply it. | +| 2026-08-30 | Web Push is self-hosted VAPID in this api (no third-party push SDK). Missing `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` → process still boots; push HTTP 503. Subscriptions bind to `account.id`. Outbox worker sends. Events: forum posts notify every other subscribed account (collapse tag `forum`); a newly indexed zap notifies the note author. iOS v1 is Home Screen (A2HS). Payloads are English `{ type, title, body, url, tag }`. | +| 2026-09-09 | `forum.post` requires a non-blank Lightning Address in addition to rules + name (skip timestamps still do not satisfy). `POST /messages` 409 `missing_requirements` includes `lightning-address` when it is factually missing. `ensureProfileMessage` no-ops without LN; linking LN after a name creates the profile note. `contact.post`, `forum.read`, and `forum.pay` unchanged; existing message rows are not deleted. | +| 2026-09-11 | Forum UI/API lists only 21.gifts-author replies (`account_id IS NOT NULL`); `replyCount` and `GET /messages/:id/replies` omit unknown-npub children. The worker no longer persists inbound kind:1 replies whose pubkey is not a 21.gifts account. Existing Damus-only reply rows stay in storage but are omitted from lists/`replyCount`. Public `GET /messages/:id` of a Damus-only reply is 404; top-level Damus-only notes stay 200. No migration or soft-hide backfill. | +| 2026-09-12 | A 21.gifts-author forum reply writes a Notifications row for the parent author and enqueues one targeted Web Push (`/notifications`, tag `forum_reply:`). The booted process always has those stores (memory or Postgres). It does not copy into the member↔member inbox. Top-level notes still broadcast. Self-replies and Damus-only parents do not notify. Failure does not fail POST /messages. | +| 2026-09-12 | Operator `DEBUG_TOKEN` debug reads every persisted forum row (live, soft-hidden, and replies) via `GET /debug/messages` and `GET /debug/messages/:id`; hidden JPEG/PNG/WebP bytes via `GET /debug/messages/:id/photo`. Soft-hide remains a public-API filter only; public `GET /messages` hide behaviour is unchanged. | +| 2026-09-12 | Public gift stats/day also return historical CHF/EUR/PHP (USD × Frankfurter ECB; missing fiat is null, not 503). | +| 2026-09-13 | Three convictions are canonical (CONCEPT "Convictions"): giving is a duty of every Christian; direct giving with no middleman is the best and most beautiful way; Bitcoin is the most effective money available today. Public copy is `/about` in the app: states the convictions, quotes the verses, no inclusion slogan. Matthew 10:8 unchanged. Principle 7: visitor UI localized (`en`, `de`, `es`, `fil`). **Supersedes** the 2026-05-25 English-only decision. | +| 2026-09-14 | Spend-worker payouts are ping-triggered from a new top-level `POST /messages`; replies do not pay. Unset `SPEND_URL` or `SPEND_API_TOKEN` skips the ping; the process still boots. | +| 2026-09-14 | Living-room post, reply, and zap notify every bell subscriber (accounts with ≥1 `push_subscription`) except the actor/payer. In-app kinds `forum_post` / `forum_reply` / `zap`. Push URLs `/notifications`; tags `forum_post:`, `forum_reply:`, `zap:`. Damus-only parents still fan out. Self-reply skips only the actor. Missing `pushStore` is a no-op. Unique remains `(recipient, type, reply_id)`. **Supersedes** the 2026-09-12 parent-author-only reply notify. | +| 2026-09-15 | In-app living-room notifications go to every account except the actor/payer. Web Push still goes only to bell subscribers (`push_subscription`). Missing `pushStore` no longer drops in-app rows when `auth` is set. **Supersedes** the 2026-09-14 in-app-only-via-subscription rule. | +| 2026-09-16 | A zap that inserts a gift-reply fans out only `notifyZap` (one in-app row + one Web Push), not a second `forum_reply`. Gift-reply row still lands in the thread. **Supersedes** the 2026-09-14/15 dual fan-out for that path. | +| 2026-09-16 | Stored per-account `notificationLevel` (`all` / `active` / `mentions`). Fan-out filters in-app rows and Web Push by that level (`POST /me/notification-level`; default `all`). `GET /notifications` lists stored rows unfiltered, including `moderator_appointed`. **Supersedes** the 2026-09-15 every-account in-app notify. | +| 2026-09-20 | `GET /notifications` applies the owner's `notificationLevel` to stored rows (same `wantsNotification` rules as write-time fan-out). `unreadCount` is matching unread. `moderator_appointed` always stays. **Supersedes** the 2026-09-16 unfiltered GET list. | +| 2026-09-12 | Trust edges persist who verified whom and who proposed/confirmed/appointed a moderator. Public graph JSON is stored nodes+edges only (no synthetic links; `moderator_propose` is omitted). Staff `POST /trust/verify`, `confirm-moderator`, and `appoint-moderator` write role + edge; `POST /trust/propose-moderator` writes the propose edge only. Operator `POST /debug/trust-edges` backfills edges without changing `role`. `PATCH /debug/accounts/:id` still sets `role` only and does not write trust edges. | +| 2026-09-13 | Public `GET /trust-chain` returns founder seeds only. `GET /trust-chain?around=` returns that chain member plus one hop of stored public edges so a thousand-person chain is loaded by click, not dumped on first paint. **Supersedes** the 2026-09-12 GET form (stored graph, never a first-paint dump). | +| 2026-09-16 | `GET /trust-chain` requires a member Bearer session (any role, including basis). Missing or invalid Bearer is 401 `{ "error": "Unauthorized" }`. Neighborhood shape is unchanged: founder seeds on the bare GET; `?around=` one hop of stored public edges (`moderator_propose` omitted). **Supersedes** the 2026-09-13 public GET form. | +| 2026-09-17 | Public Trust Chain credits the proposer, not the confirmer. Projected kinds are stored `verify` / `moderator_propose` (only once the subject is a `moderator`) / `moderator_appoint`. `moderator_confirm` is omitted. A pending propose (subject still `verified`) stays private and is not a hop neighbor. Operator DELETE /debug/trust-edges removes one stored (subjectId, kind) row without changing role. **Supersedes** the 2026-09-12 public-graph kinds (`moderator_propose` omitted) and the 2026-09-16 neighborhood kinds parenthetical. | +| 2026-09-17 | Public Trust Chain picks one incoming kind per subject: the oldest eligible sibling (`createdAt` then `id`). Eligible: `verify`, `moderator_appoint`, and `moderator_propose` only when the subject is a `moderator`. First contact wins; later appoint, confirm, or propose do not replace it. A pending propose (subject still `verified`) stays private. **Supersedes** the 2026-09-17 kind-priority projection (`moderator_propose` if moderator, else `verify`, else `moderator_appoint`). | +| 2026-09-16 | confirm/appoint notify only the subject (`moderator_appointed`); Web Push `url` `/welcome`, tag `moderator_appointed:`; not a living-room fan-out; unique `(recipient, type, reply_id)` with `reply_id` = subject id; missing stores no-op; failure does not fail the trust POST. | +| 2026-09-17 | Staff Bearer `GET /trust/proposals` lists pending `moderator_propose` (verified subject, no confirm/appoint). Session `GET /trust-chain` still omits a pending propose; once the subject is a `moderator`, that propose is eligible as the public incoming edge only when it is the oldest eligible sibling. | +| 2026-09-21 | `eligibleToday` does not require a grant until UTC `2026-09-25` (`FUNDING_REQUIRED_FROM_UTC`). Until then, non-`basis` accounts stay eligible (passkey and living-room post still gate issue). From that day the 2026-09-20 grant matrix applies. **Softens** the 2026-09-20 spend/invoice grant cutover. | +| 2026-09-20 | Funding-program grants (`funding_grant`) are independent of `account.role`. `verified` remains a real-life meeting (forum badge). Moderators review posts against the three convictions. `basis` cannot apply; owner JSON `funding` is `null`. Status none → pending → trial (eligible only on that UTC day) or admitted (recurring) or rejected (may re-apply). Expired trial is effective pending (lazy persist). Spend ping and `POST /invoices` require `eligibleToday`; `GET /invoices/eligible?address=` returns `{ eligible }`. | +| 2026-09-17 | Inbox last-read is per (account, conversation). `GET /conversations` adds per-row `unread` and list `unreadCount`; `POST /conversations/:id/read` stamps last-read. Does not copy DMs into Notifications. | +| 2026-09-17 | Inbound private messages enqueue Web Push (`type: conversation`, url `/messages?c=`, tag `conversation:`) to bell subscribers only. No in-app Notification rows for DMs. Every outbox `unreadCount` (forum, zap, conversation) is notification unread plus listed inbox unread. Push failure does not fail HTTP 200 or Nostr ingest. | +| 2026-09-17 | A living-room note may carry up to 10 JPEG/PNG/WebP stills. Photo 0 stays on `message.photo` (Damus `/photo.jpg` unchanged). Extras 1–9 live in `message_extra_photo` and are served at `/messages/:id/photo/1.jpg` … `/photo/9.webp`. Public JSON includes `photoCount` (0–10). POST accepts `photos[]` (max 10) and still accepts singular `photo`. Video stays exclusive (poster = photo 0, no extras). | +| 2026-09-18 | External NOSTR identities become visible on the website only after a verified zap of at least 1 sat on a 21.gifts forum note; the protocol remains open. The zap creates an external gift-reply on an eligible top-level note and permanently entitles that pubkey's kind:1 replies. Staff hiding an external row blocks the pubkey and hides its other live rows. Public JSON marks these rows with `via: "nostr"` and never exposes the pubkey. **Supersedes** the 2026-09-11 rule that all unknown-pubkey replies are omitted. | +| 2026-09-20 | Roles are a strict hierarchy founder > moderator > verified > basis; every permission is a minimum role (roleAtLeast), and text names only that minimum role. The closed Moderators group follows the same rule. | +| 2026-09-20 | Staff `DELETE /messages/:id` still only soft-hides on 21.gifts, then best-effort publishes NIP-09 `kind: 5` (author nsec, durability relay plus Damus/Primal/nos.lol, not gated on `NOSTR_PUBLISH*`) and purges cached public photo/video URLs at Cloudflare when `CLOUDFLARE_ZONE_ID` + `CLOUDFLARE_API_TOKEN` are set. Failure still 204. Debug restore does not undelete Nostr. **Supersedes** the 2026-09-12 “soft-hide remains a public-API filter only” note. | +| 2026-09-20 | Staff GET of a soft-hidden forum note returns who hid it and when. Hide retracts in-app notifications for the note and its direct children. `GET /notifications` drops leftover hidden `forum_post` / `forum_reply` rows (zap still checks only the parent because `replyId` is a receipt UUID). | +| 2026-09-20 | Official platform account (`isPlatform`) never fans out living-room `forum_post` / `forum_reply` / `zap` (in-app or Web Push). House daily gift-replies still persist. **Supersedes** the 2026-09-14/15/16 living-room fan-out for the platform actor only. | +| 2026-09-20 | Moderators-group Web Push (`type: conversation`) opens `/moderate/group` (the staff-room thread). Member DM push stays `/messages?c=`; forum/zap stay `/notifications`. Tag remains `conversation:`. No in-app Notification rows; `GET /conversations` still omits `moderator_group`. **Supersedes** only the staff-room URL in the 2026-09-17 conversation-push row. | +| 2026-09-20 | A paid moderator stipend appears in the closed Moderators group as a house ("21.gifts") message carrying the paid sats, after the triggering group message, at payment time. Spend pings `{ address, kind: "moderator", groupMessageId }`; proof attaches that row. A missing or mismatched group reference never blocks the payout. | +| 2026-09-20 | Passkey authenticate/register finish and operator debug session mint refuse an account whose stored `sessionRefused` flag is true: HTTP 403 with the wrong-account error and no bearer. `GET /me` with an already-minted token for that row is the same 403 so the client can sign the visitor out. Other authenticated routes treat that token as missing (401). Operators set the flag with `PATCH /debug/accounts/:id`. The account row is not deleted. **Supersedes** the same-day listed-id copy of this row. | +| 2026-09-20 | A paid moderator stipend in the closed Moderators group carries a durable `gift_for_message_id` / public `giftFor` link to the group message that triggered it, so the app can render the stipend row attached under that message. Absent or null on every other conversation row. | +| 2026-09-21 | `GET /conversations` (and other public conversation list rows) include per-row `unreadMessageCount` (inbound messages strictly after last-read; `0` when none). `unread` stays `unreadMessageCount > 0`. Envelope `unreadCount` remains the number of listed unread threads (menu/PWA badge). Gift-only inbound counts; outbound does not. | +| 2026-09-20 | Staff may reject an open moderator proposal. Reject is append-only `moderator_reject` (role stays `verified`). Re-propose after reject inserts a new `moderator_propose` (history kept). Pending = latest propose/reject is propose, subject still verified, no confirm/appoint. Unique live kinds are verify/confirm/appoint only (propose/reject may repeat). An open proposal fans out in-app `moderator_proposal` plus Web Push to other staff until confirm, until reject when pending is then empty, or until appoint; mark-read does not dismiss it. **Supersedes** the 2026-09-17 pending-propose list row (pending was any propose without confirm/appoint). | +| 2026-09-21 | Optional `GET /messages?hashtag=` token filter on live top-level `text` (name without `#`; token match; combines with `mode`/`limit`/`cursor`). No new entity, table, or index. A shops page of 20 is 20 matching notes, not 20 mixed notes filtered later. | +| 2026-09-21 | Propose/confirm/reject re-list after insert so a concurrent older propose, a concurrent reject, or a concurrent confirm/appoint cannot leave two live outcomes. Confirm and reject compare the pending propose by edge id (same-actor same-ms re-propose is a different row). After propose notify, re-list and drop or refresh `moderator_proposal` rows when that insert is no longer pending. The public graph projects at most one incoming edge per subject (winning id), skipping non-chain oldest siblings. **Supersedes** the 2026-09-17 “one incoming kind” / first-contact-wins row. | ## Next Steps diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e15b6eba..de47c8069 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,7 +50,7 @@ api/ │ │ ├── debug-trust.ts # GET/POST/DELETE /debug/trust-edges (operator DEBUG_TOKEN; no role change) │ │ ├── debug-catalog.ts # GET /debug/dump, GET /debug/dump/:table (operator DEBUG_TOKEN) │ │ ├── trust-chain.ts # session GET /trust-chain (founder seeds; ?around= one hop) -│ │ ├── trust.ts # GET /trust/proposals; POST /trust/verify, propose-moderator, confirm-moderator, appoint-moderator +│ │ ├── trust.ts # GET /trust/proposals; POST /trust/verify, propose-moderator, confirm-moderator, reject-moderator, appoint-moderator │ │ ├── funding.ts # POST /funding/apply; GET /funding/applications; GET /funding/applications/:accountId; POST /funding/trial, admit, reject │ │ ├── push.ts # GET /push/vapid-public; POST/DELETE /me/push-subscriptions │ │ ├── stats.ts # GET /gifts/stats (public gift totals) @@ -87,7 +87,7 @@ api/ │ │ ├── request-auth.ts # Classify bearer for api_log (session/debug/spend/none) │ │ ├── conversation-store.ts # ConversationStore port, memory + Postgres │ │ ├── conversation-push.ts # notifyConversationMessage (DM Web Push; no in-app rows) -│ │ ├── notification.ts # Notification public JSON + bell fan-out (`notifyForumPost` / `notifyForumReply` / `notifyZap`) filtered by `notificationLevel` (`parseNotificationLevel` / `isStaffAccount` / `wantsNotification`); targeted `notifyModeratorAppointed` and `notifyExternalForumReply` (not fan-out; the latter reaches only the parent note's author) +│ │ ├── notification.ts # Notification public JSON + bell fan-out (`notifyForumPost` / `notifyForumReply` / `notifyZap`) filtered by `notificationLevel` (`parseNotificationLevel` / `isStaffAccount` / `wantsNotification`); staff `notifyModeratorProposed`; targeted `notifyModeratorAppointed` and `notifyExternalForumReply` (not fan-out; the latter reaches only the parent note's author) │ │ ├── notification-store.ts # NotificationStore port, memory + Postgres │ │ ├── push-config.ts # resolveVapidConfig (VAPID env; missing → null) │ │ ├── push.ts # parsePushSubscription + English forum/zap/conversation payloads diff --git a/SPEC.md b/SPEC.md index dea7c1e3f..1b18e82e2 100644 --- a/SPEC.md +++ b/SPEC.md @@ -4,7 +4,7 @@ > Product decisions live in [`CONCEPT.md`](./CONCEPT.md); this file owns > request/response contracts for routes that exist in code today. -**Status**: living document. Last revised 2026-09-21 (`eligibleToday` does not require a grant until UTC 2026-09-25; funding-program grants independent of `account.role`; spend ping and `POST /invoices` require `eligibleToday`; `GET /invoices/eligible`; `GET /conversations` list/open rows include per-row `unreadMessageCount`; envelope `unreadCount` remains unread thread count; `GET /trust-chain` requires a member Bearer session; public graph uses at most one incoming kind per subject: the oldest eligible sibling (`createdAt` then `id`); eligible `verify`, `moderator_appoint`, and `moderator_propose` only when the subject is a moderator; `moderator_confirm` never; later appoint/confirm/propose do not replace the first eligible contact; owner `notificationLevel` on GET `/me` and `POST /me/notification-level`; fan-out filters in-app and Web Push by `all` / `active` / `mentions`; GET `/notifications` applies the same filter to stored rows (`moderator_appointed` always stays; `unreadCount` is matching unread in the newest 1000, not `store.unreadCount()`, and may exceed the 200 page); a zap that inserts a gift-reply fans out only `notifyZap`, not a second `forum_reply`; gift-reply row still lands in the thread; confirm/appoint notify the subject only with `moderator_appointed` and Web Push url `/welcome`; official platform account (`isPlatform`) never fans out living-room `forum_post` / `forum_reply` / `zap`; house daily gift-replies still persist). +**Status**: living document. Last revised 2026-09-21 (`eligibleToday` does not require a grant until UTC 2026-09-25; funding-program grants independent of `account.role`; spend ping and `POST /invoices` require `eligibleToday`; `GET /invoices/eligible`; `GET /conversations` list/open rows include per-row `unreadMessageCount`; envelope `unreadCount` remains unread thread count; `GET /trust-chain` requires a member Bearer session; public graph uses at most one incoming edge per subject: the oldest eligible sibling (`createdAt` then `id`), skipping a non-chain oldest sibling so a later displayable contact can show; eligible `verify`, `moderator_appoint`, and `moderator_propose` only when the subject is a moderator; `moderator_confirm` and `moderator_reject` never; later appoint/confirm/propose do not replace the first eligible contact; staff may reject an open proposal (`POST /trust/reject-moderator`, append-only `moderator_reject`, role stays `verified`) and re-propose after reject (new `moderator_propose`; 409 while currently pending, any confirm/appoint, or a concurrent older open propose wins after insert); confirm/reject re-list after insert and undo when the other grant already closed; pending = latest propose/reject is propose, verified, no confirm/appoint; live-unique kinds are verify/confirm/appoint only; open proposal fans out in-app `moderator_proposal` plus Web Push to other staff until confirm, until reject when pending is then empty, or until appoint; GET `/notifications` keeps `moderator_appointed` and `moderator_proposal` (mark-read / read-all do not stamp the proposal); owner `notificationLevel` on GET `/me` and `POST /me/notification-level`; fan-out filters in-app and Web Push by `all` / `active` / `mentions`; GET `/notifications` applies the same filter to stored rows (`moderator_appointed` always stays; `unreadCount` is unread among kept rows after the hidden filter (before the 200 cap), not `store.unreadCount()` and not the unfiltered matching unread of the newest 1000); a zap that inserts a gift-reply fans out only `notifyZap`, not a second `forum_reply`; gift-reply row still lands in the thread; confirm/appoint notify the subject only with `moderator_appointed` and Web Push url `/welcome`; official platform account (`isPlatform`) never fans out living-room `forum_post` / `forum_reply` / `zap`; house daily gift-replies still persist). --- @@ -110,6 +110,7 @@ Public base URLs used in examples: | POST | `/trust/propose-moderator` | Bearer (moderator+) | Staff: propose a verified member as moderator | | GET | `/trust/proposals` | Bearer (moderator+) | Staff: list pending moderator proposals | | POST | `/trust/confirm-moderator` | Bearer (moderator+) | Staff: second, independent confirmation → `moderator` | +| POST | `/trust/reject-moderator` | Bearer (moderator+) | Staff: reject an open proposal (subject stays verified) | | POST | `/trust/appoint-moderator` | Bearer (founder) | Founder: appoint a moderator directly | | POST | `/funding/apply` | Bearer | Member apply (verified+; `basis` 403) | | GET | `/funding/applications` | Bearer (moderator+) | Staff pending grant queue | @@ -568,10 +569,12 @@ Missing or invalid Bearer → **Response** `401`: Bare `GET /trust-chain` returns **founder seeds only** (`edges` empty) so a large chain is not dumped on first paint. `GET /trust-chain?around=` returns that chain member plus -one hop of **stored** public edges with at most one incoming kind per -subject: the oldest eligible sibling (`createdAt` then `id`). Eligible: +one hop of **stored** public edges with at most one incoming edge per +subject: the oldest eligible sibling (`createdAt` then `id`), skipping a +non-chain oldest sibling so a later displayable contact can show. Eligible: `verify`, `moderator_appoint`, and `moderator_propose` only when the live -subject is a `moderator`; `moderator_confirm` never. Later appoint, +subject is a `moderator`; `moderator_confirm` and `moderator_reject` +never. Later appoint, confirm, or propose do not replace an earlier eligible contact. A pending propose (subject still `verified`) stays private and is not a hop neighbor. Neighborhood must consider all stored edges for each subject, not only @@ -618,12 +621,13 @@ Staff pending-moderator queue. Bearer **session** required (moderator). This is **not** a `DEBUG_TOKEN` route. No `forum.read` / rules gate — a moderator without rules agreement is still **200**. -Lists pending `moderator_propose` edges whose live subject is still -`verified` and has no `moderator_confirm` or `moderator_appoint`. Missing +Lists pending proposals: the latest `moderator_propose` / `moderator_reject` +edge is `moderator_propose`, the live subject is still `verified`, and +there is no `moderator_confirm` or `moderator_appoint`. Missing subjects are omitted. Oldest `createdAt` first, then propose-edge `id` (FIFO). JSON `{ "proposals": [ … ] }` including an empty list. Each item -is `{ subject: { id, name, role: "verified" }, proposedBy: { id, name }, -createdAt }` with ISO-8601 `createdAt`. A missing actor is +is `{ id, subject: { id, name, role: "verified" }, proposedBy: { id, name }, +createdAt }` with the propose-edge `id` and ISO-8601 `createdAt`. A missing actor is `{ id, name: null }`. `GET /trust-chain` still omits a pending `moderator_propose`. Once the subject is a `moderator`, that propose is eligible as the public incoming edge only when it is the oldest eligible @@ -653,6 +657,7 @@ Success (including an empty list) → **Response** `200`: { "proposals": [ { + "id": "", "subject": { "id": "", "name": "Ada", "role": "verified" }, "proposedBy": { "id": "", "name": "Mod" }, "createdAt": "2026-09-16T00:00:00.000Z" @@ -700,27 +705,69 @@ Otherwise insert the edge then update role, log `trust.verified` ### `POST /trust/propose-moderator` Bearer session. Body `{ "accountId": "" }`. Staff only. Subject role -must be `verified`, not self, and must not already have -`moderator_propose` / `moderator_confirm` / `moderator_appoint` or be -`moderator`/`founder`. Inserts `moderator_propose` without changing role. -Logs `trust.moderator_proposed`. Same 401/403/400/404/409/503 shapes as -`POST /trust/verify`. **200** `{ id, name, role }` (role unchanged). +must be `verified`, not self. **409** when currently pending (latest +propose/reject is propose) or any `moderator_confirm` / `moderator_appoint` +exists, when the subject is not `verified`, or when after insert this row +is not the oldest open propose (the insert is deleted; remaining pending is +then best-effort cleared and fan-out for that propose-edge id, failure stays +**409**; empty open after a concurrent reject is the same **409**). When this +insert is the oldest open propose and extras exist, delete newer extra +proposes and still **200**. **200** inserts a **new** +`moderator_propose` after a reject (history kept; old propose/reject rows +are not deleted). Role is unchanged. Logs `trust.moderator_proposed`. +After **200**, delete `moderator_proposal` rows with +`replyId === subject.id`, then wrap `notifyModeratorProposed` (in-app +`moderator_proposal` plus Web Push to other staff). Then re-list: if +pending is empty or the pending propose-edge `id` is not this insert, +delete those rows again; if a different propose is pending, fan out for +that actor only when a second re-list still shows that same id, and +delete the rows if a re-list after that fan-out no longer matches. +HTTP still **200** if notify (or the purge) fails. +Same 401/403/400/404/409/503 shapes as `POST /trust/verify`. +**200** `{ id, name, role }` (role unchanged). ### `POST /trust/confirm-moderator` Bearer session. Body `{ "accountId": "" }`. Staff only. A pending -`moderator_propose` must exist; the caller id must not equal the proposer's -actor id (independent second staff member). Subject must still be -`verified`. Inserts `moderator_confirm` then sets role to `moderator`, logs -`trust.moderator_confirmed`. If the caller already stored `moderator_confirm` -and the subject is still `verified`, completes the role write and returns -**200**; already-moderator with that caller-owned edge is idempotent **200**. -Same 401/403/400/404/409/503 JSON shapes (409 when a confirm edge belongs -to someone else). **200** `{ id, name, role }` with `role: "moderator"`. -After a 200 that leaves the subject as `moderator` (new grant and -idempotent already-moderator same-actor 200), the api notifies the -subject only (`moderator_appointed`, Web Push url `/welcome`). Notify -failure does not fail the POST. +proposal must exist (latest propose/reject is propose); the caller id must +not equal that latest proposer's actor id (independent second staff member). +Subject must still be `verified`. Inserts `moderator_confirm` then re-lists: +if the pending propose-edge `id` from `pendingModeratorProposals` +(ignoring this confirm insert) is no longer the same, or that id is not +also the oldest open propose (an older open propose is still present), delete +that confirm and **409** without promoting. Otherwise sets +role to `moderator`, logs `trust.moderator_confirmed`. If the caller +already stored `moderator_confirm` and the subject is still `verified`, +completes the role write and returns **200**; already-moderator with that +caller-owned edge is idempotent **200**. Same 401/403/400/404/409/503 JSON +shapes (409 when a confirm edge belongs to someone else). **200** +`{ id, name, role }` with `role: "moderator"`. After a 200 that leaves the +subject as `moderator` (new grant and idempotent already-moderator +same-actor 200), the api deletes `moderator_proposal` rows with +`replyId === subject.id`, then notifies the subject only +(`moderator_appointed`, Web Push url `/welcome`). Notify failure does not +fail the POST. + +### `POST /trust/reject-moderator` + +Bearer session. Same auth as `POST /trust/propose-moderator` (staff, +moderator+). Body `{ "accountId": "" }`. **409** self / not pending +(latest propose/reject is not propose, or any confirm/appoint) / +`role !== verified`. The original proposer **may** reject. Inserts +append-only `moderator_reject` then re-lists: if a concurrent confirm or +appoint already closed the grant, or the pending propose-edge `id` is still +the same (this reject lost the same-ms id tie), delete that reject and +**409**. If a newer propose already reopened the queue (including a +same-actor same-ms re-propose with a different edge `id`), **200** keeps +the reject in history and does not drop `moderator_proposal` rows. Role stays +`verified`. Logs +`trust.moderator_rejected` `{ subjectId, actorId }`. When pending is empty +after insert, re-lists once more and deletes `moderator_proposal` rows with +`replyId === subject.id` only if pending is still empty; if a re-list after +that delete shows a new pending propose, fan out for that actor and +re-list again so a concurrent close drops those rows. No notify +for the reject itself. Same 401/403/400/404/409/503 JSON shapes as +`POST /trust/verify`. **200** `{ id, name, role }` (role unchanged). ### `POST /trust/appoint-moderator` @@ -735,8 +782,10 @@ Same 401/403/400/404/409/503 shapes as `POST /trust/verify` (403 when the caller is not a founder). **200** `{ id, name, role }` with `role: "moderator"`. After a 200 that leaves the subject as `moderator` (new grant and idempotent already-moderator same-actor -200), the api notifies the subject only (`moderator_appointed`, Web -Push url `/welcome`). Notify failure does not fail the POST. +200), the api deletes `moderator_proposal` rows for the subject +(`deleteByTypeAndReplyId`) then notifies the subject only +(`moderator_appointed`, Web Push url `/welcome`). Notify failure does +not fail the POST. ### `POST /funding/apply` @@ -1589,11 +1638,13 @@ other debug routes). Does **not** change `account.role`. ``` `kind` is one of `verify`, `moderator_propose`, `moderator_confirm`, -`moderator_appoint`. +`moderator_appoint`, `moderator_reject`. Propose and reject may repeat; +**409** duplicate only for live-unique kinds (`verify`, +`moderator_confirm`, `moderator_appoint`) or `subjectId === actorId`. Bad body → **400** `{ "error": "Expected a JSON body with \"subjectId\", \"actorId\", and \"kind\" strings" }`. Missing subject or actor (or a non-UUID id) → **404** `{ "error": "Not found" }`. -Duplicate `(subjectId, kind)` or `subjectId === actorId` → **409** +Duplicate live-unique `(subjectId, kind)` or `subjectId === actorId` → **409** `{ "error": "Conflict" }`. Unexpected store throw → **503** `{ "error": "Trust chain is unavailable" }` logged as `debug.trust_edges.failed`. @@ -1617,8 +1668,9 @@ Success logs `debug.trust_edges.inserted` `{ subjectId, actorId, kind }`. Operator delete of a stored trust edge. Authenticated with `Authorization: Bearer` matching `DEBUG_TOKEN` (same 503/401 gate as the -other debug routes). Does **not** change `account.role`. Unique -`(subjectId, kind)` means one row is enough to identify. +other debug routes). Does **not** change `account.role`. Deletes the +latest stored row of that `(subjectId, kind)` (`createdAt` desc, then +`id` desc). **Request**: @@ -1630,7 +1682,7 @@ other debug routes). Does **not** change `account.role`. Unique ``` `kind` is one of `verify`, `moderator_propose`, `moderator_confirm`, -`moderator_appoint`. +`moderator_appoint`, `moderator_reject`. Bad body → **400** `{ "error": "Expected a JSON body with \"subjectId\" and \"kind\" strings" }`. Non-UUID `subjectId` or no matching row → **404** `{ "error": "Not found" }`. @@ -3896,13 +3948,15 @@ plus `unreadCount`. Fan-out already applied the owner's on the newest 1000). After the level filter, drop `forum_post` / `forum_reply` whose parent message is missing or hidden; also drop `forum_reply` when the child (`replyId`) is missing or hidden. Never drop -`moderator_appointed`. Zap only checks the parent (`replyId` is a -receipt-derived UUID, not a message id). Best-effort purge of those -message ids. Then cap the kept list at **200**. `unreadCount` is unread -among kept rows after the hidden filter (not the unfiltered matching -unread of the 1000, and not necessarily the page length). Member JSON -never includes recipient or actor account ids. Each item `type` is -`"forum_post"`, `"forum_reply"`, `"zap"`, or `"moderator_appointed"`. +`moderator_appointed` or `moderator_proposal` (do not look up a forum +message; do not add the parent id to the purge set). Zap only checks the +parent (`replyId` is a receipt-derived UUID, not a message id). +Best-effort purge of those message ids. Then cap the kept list at **200**. +`unreadCount` is unread among kept rows after the hidden filter (not the +unfiltered matching unread of the 1000, and not necessarily the page +length). Member JSON never includes recipient or actor account ids. Each +item `type` is `"forum_post"`, `"forum_reply"`, `"zap"`, +`"moderator_appointed"`, or `"moderator_proposal"`. Missing/invalid/expired bearer → **Response** `401`: @@ -3936,14 +3990,15 @@ Success → **Response** `200`: } ``` -`unreadCount` is matching unread in the newest 1000 after -`notificationsMatchingLevel`, not `store.unreadCount()`. It is not the -page length and may exceed the 200 list cap. +`unreadCount` is unread among kept rows after the hidden filter (before +the 200 cap), not `store.unreadCount()` and not the unfiltered matching +unread of the newest 1000. It is not necessarily the page length. ### `POST /notifications/read-all` Bearer session required. Marks every unread notification for the session -account read. +account read except `moderator_proposal` (mark-read does not stamp them; +rows drop on confirm, on reject when pending is then empty, or on appoint). Missing/invalid/expired bearer → **401** `{ "error": "Unauthorized" }`. Store failure → **503** `{ "error": "Notifications are unavailable" }`. @@ -3957,11 +4012,13 @@ Success → **Response** `200`: ### `POST /notifications/:id/read` Bearer session required. `:id` is a UUID. Marks one notification read and -returns that `PublicNotification` with `readAt` set. Unknown id, another -account's notification, or a non-uuid `:id` → **404** +returns that `PublicNotification` with `readAt` set. A `moderator_proposal` +row is **200** with `readAt` still `null` (mark-read does not dismiss it). +Unknown id, another account's notification, or a non-uuid `:id` → **404** `{ "error": "Not found" }`. Same **401** / **503** as list. -Success → **Response** `200` (one public notification with `readAt` set). +Success → **Response** `200` (one public notification with `readAt` set, +or still `null` for `moderator_proposal`). --- diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 8af8a7205..74131088c 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -492,21 +492,21 @@ ## Endpoint: GET /notifications -- **Purpose:** Bearer required. List `{ notifications, unreadCount }`. Apply the owner's `notificationLevel` filter (`notificationsMatchingLevel` on the newest 1000 stored rows), then drop rows whose parent **message** is missing or `deletedAt !== null` (`forum_post` / `zap` / `forum_reply`); `forum_reply` also drops when the child `replyId` message is missing or hidden. A `zap` `replyId` is a receipt-derived UUID, not a message id — it is not looked up and is never added to the purge set. Then cap the kept list at 200 newest-first. Then best-effort `deleteByMessageIds` of those **message** ids (`notifications.hidden.purged`; throw logs `notifications.hidden.purge_failed` and is not 503). Never drops `moderator_appointed` in the hidden filter (those rows stay in `{ notifications }`). Appointed `parentId`/`replyId` are account ids in prod, so a purge of hidden **message** ids does not remove them. `unreadCount` is unread among kept rows after the hidden filter (before the 200 cap), not the unfiltered store count (if purge throws, still count kept unread; do not 503 the list). Each item `type` is `'forum_post' | 'forum_reply' | 'zap' | 'moderator_appointed'`. No account ids. `moderator_appointed` always stays through the level filter. +- **Purpose:** Bearer required. List `{ notifications, unreadCount }`. Apply the owner's `notificationLevel` filter (`notificationsMatchingLevel` on the newest 1000 stored rows), then drop rows whose parent **message** is missing or `deletedAt !== null` (`forum_post` / `zap` / `forum_reply`); `forum_reply` also drops when the child `replyId` message is missing or hidden. A `zap` `replyId` is a receipt-derived UUID, not a message id — it is not looked up and is never added to the purge set. Then cap the kept list at 200 newest-first. Then best-effort `deleteByMessageIds` of those **message** ids (`notifications.hidden.purged`; throw logs `notifications.hidden.purge_failed` and is not 503). Never drops `moderator_appointed` or `moderator_proposal` in the hidden filter (those rows stay in `{ notifications }`; do not look up a forum message; do not add the parent id to the purge set). Appointed/proposal `parentId`/`replyId` are account ids in prod, so a purge of hidden **message** ids does not remove them. `unreadCount` is unread among kept rows after the hidden filter (before the 200 cap), not the unfiltered store count (if purge throws, still count kept unread; do not 503 the list). Each item `type` is `'forum_post' | 'forum_reply' | 'zap' | 'moderator_appointed' | 'moderator_proposal'`. No account ids. `moderator_appointed` and `moderator_proposal` always stay through the level filter. Mark-read / read-all do not stamp `moderator_proposal`. - **Errors:** 401 Unauthorized; 503 Notifications are unavailable (`notifications.list.failed`). - **Used by:** App in-app notification list. - **Auth:** Bearer session. ## Endpoint: POST /notifications/read-all -- **Purpose:** Bearer required. 200 `{ ok: true }`. Marks every unread notification for the session account read. +- **Purpose:** Bearer required. 200 `{ ok: true }`. Marks every unread notification for the session account read except `moderator_proposal` (mark-read does not stamp them; rows drop on confirm, on reject when pending is then empty, or on appoint). - **Errors:** 401 Unauthorized; 503 Notifications are unavailable (`notifications.read_all.failed`). - **Used by:** App mark-all-read control. - **Auth:** Bearer session. ## Endpoint: POST /notifications/:id/read -- **Purpose:** Bearer required. UUID `:id`. 200 `PublicNotification` with `readAt` set. +- **Purpose:** Bearer required. UUID `:id`. 200 `PublicNotification` with `readAt` set. A `moderator_proposal` row is 200 with `readAt` still `null` (mark-read does not dismiss it). - **Errors:** 401 Unauthorized; 404 Not found (unknown/other/non-uuid); 503 Notifications are unavailable (`notifications.read.failed`). - **Used by:** App mark-one-read control. - **Auth:** Bearer session. @@ -639,14 +639,14 @@ ## Endpoint: GET /trust-chain -- **Purpose:** Stored trust graph. Bearer session required (any role). Bare `GET` returns founder seeds only (`edges` empty) so a large chain is not dumped on first paint. `?around=` returns that chain member plus one hop of stored public edges with at most one incoming kind per subject: the oldest eligible sibling (`createdAt` then `id`). Eligible: `verify`, `moderator_appoint`, and `moderator_propose` only when the live subject is a `moderator`; `moderator_confirm` never. Later appoint, confirm, or propose do not replace an earlier eligible contact. Neighborhood uses full sibling lists per subject (`listEdgesForSubject`) so a non-touching older eligible edge still wins over a touching newer one. A pending propose stays private. Nodes are founder/moderator/verified (never basis). Never invents edges; omits lightning addresses, view keys, and linking keys. +- **Purpose:** Stored trust graph. Bearer session required (any role). Bare `GET` returns founder seeds only (`edges` empty) so a large chain is not dumped on first paint. `?around=` returns that chain member plus one hop of stored public edges with at most one incoming edge per subject: the oldest eligible sibling (`createdAt` then `id`), skipping a non-chain oldest sibling so a later displayable contact can show. Eligible: `verify`, `moderator_appoint`, and `moderator_propose` only when the live subject is a `moderator`; `moderator_confirm` and `moderator_reject` never. Later appoint, confirm, or propose do not replace an earlier eligible contact. Neighborhood uses full sibling lists per subject (`listEdgesForSubject`) so a non-touching older eligible edge still wins over a touching newer one. A pending propose stays private. Nodes are founder/moderator/verified (never basis). Never invents edges; omits lightning addresses, view keys, and linking keys. - **Errors:** 401 `{ error: 'Unauthorized' }` without a session or with an invalid Bearer. 404 `{ error: 'Not found' }` when `around` is supplied but is not a uuid, is unknown, or is not a chain member (including Postgres `22P02`). Omitting `around` (or empty) is founder seeds, not 404. Unauthenticated `around` is 401, not 404. 503 `{ error: 'Trust chain is unavailable' }` when listing accounts or edges throws (`trust.chain.failed`). - **Used by:** signed-in app `/trust-chain` via app `GET /trust/graph`. - **Auth:** `Authorization: Bearer` session. ## Endpoint: GET /trust/proposals -- **Purpose:** Bearer session required (moderator; not `DEBUG_TOKEN`). Lists pending `moderator_propose` rows via `pendingModeratorProposals`: live subject `role` is `verified` and the subject has no `moderator_confirm` / `moderator_appoint`. JSON `{ "proposals": [ { subject: { id, name, role: "verified" }, proposedBy: { id, name }, createdAt } ] }` with ISO-8601 `createdAt` (empty list is 200). Oldest `createdAt` first, then propose-edge id. Missing subjects are omitted; a missing actor is `{ id, name: null }`. No `forum.read` / rules gate — a moderator without rules agreement is still 200. Logs `trust.proposals.listed` with `{ count }` only. `GET /trust-chain` still omits a pending `moderator_propose`. Once the subject is a `moderator`, that propose is eligible as the public incoming edge only when it is the oldest eligible sibling (`createdAt` then `id`). +- **Purpose:** Bearer session required (moderator; not `DEBUG_TOKEN`). Lists pending proposals via `pendingModeratorProposals`: latest propose/reject is `moderator_propose`, live subject `role` is `verified`, and the subject has no `moderator_confirm` / `moderator_appoint`. JSON `{ "proposals": [ { id, subject: { id, name, role: "verified" }, proposedBy: { id, name }, createdAt } ] }` with the propose-edge `id` and ISO-8601 `createdAt` (empty list is 200). Oldest `createdAt` first, then propose-edge id. Missing subjects are omitted; a missing actor is `{ id, name: null }`. No `forum.read` / rules gate — a moderator without rules agreement is still 200. Logs `trust.proposals.listed` with `{ count }` only. `GET /trust-chain` still omits a pending `moderator_propose`. Once the subject is a `moderator`, that propose is eligible as the public incoming edge only when it is the oldest eligible sibling (`createdAt` then `id`). - **Errors:** 401 `{ error: 'Unauthorized' }` without a session; 403 `{ error: 'Forbidden' }` when the live role is not at least moderator; 503 `{ error: 'Trust chain is unavailable' }` when listing accounts/edges or projecting throws (`trust.proposals.failed`). - **Used by:** Staff moderator-proposal queue in the app. - **Auth:** `Authorization: Bearer` session (moderator). Not `DEBUG_TOKEN`. @@ -660,21 +660,28 @@ ## Endpoint: POST /trust/propose-moderator -- **Purpose:** Bearer staff. Body `{ "accountId" }`. Subject must be `verified`, not self, and must not already have `moderator_propose` / `moderator_confirm` / `moderator_appoint` (and not already moderator/founder). Inserts `moderator_propose` without changing role; logs `trust.moderator_proposed`; `200 { id, name, role }`. -- **Errors:** Same 401/403/400/404/409/503 JSON shapes as `POST /trust/verify` (409 when the subject is not verified, is self, or already has a staff-grant edge). +- **Purpose:** Bearer staff. Body `{ "accountId" }`. Subject must be `verified`, not self. 409 only when currently pending (latest propose/reject is propose), any confirm/appoint exists, the subject is self, role is not verified, or after insert this row is not the oldest open propose (the insert is deleted; remaining pending is then best-effort cleared and fan-out for that propose-edge id, failure stays 409; empty open after a concurrent reject is the same 409). After a reject, 200 inserts a **new** `moderator_propose` (history kept). When this insert is the oldest open propose and extras exist, delete newer extra proposes and still 200. Role unchanged; logs `trust.moderator_proposed`; `200 { id, name, role }`. After 200, delete existing `moderator_proposal` rows for the subject then wrap `notifyModeratorProposed` (in-app `moderator_proposal` plus Web Push to other staff). Then re-list: if pending is empty or the pending propose-edge `id` is not this insert, delete those rows again; if a different propose is pending, fan out for that actor only when a second re-list still shows that same id, and delete the rows if a re-list after that fan-out no longer matches. HTTP still 200 if notify or the purge fails. +- **Errors:** Same 401/403/400/404/409/503 JSON shapes as `POST /trust/verify` (409 when the subject is not verified, is self, is currently pending, already has confirm/appoint, or after insert this row is not the oldest open propose). - **Used by:** Staff moderator-proposal flow. - **Auth:** `Authorization: Bearer` session. Staff only. ## Endpoint: POST /trust/confirm-moderator -- **Purpose:** Bearer staff. Body `{ "accountId" }`. A pending `moderator_propose` must exist and the caller id must differ from the proposer's actor id. Subject must still be `verified`. Inserts `moderator_confirm` then sets role to `moderator`, logs `trust.moderator_confirmed`, `200 { id, name, role }`. If the caller already stored `moderator_confirm` and the subject is still `verified`, completes the role write and returns 200; already-moderator with that caller-owned edge is idempotent 200. After a 200 that leaves the subject as `moderator` (new grant and idempotent already-moderator same-actor 200), wrap `notifyModeratorAppointed` for the subject only (in-app `moderator_appointed`, Web Push url `/welcome`). Failure logs `push.enqueue.failed`; persist/HTTP still 200. -- **Errors:** Same 401/403/400/404/409/503 JSON shapes as `POST /trust/verify` (409 when there is no pending propose, the caller proposed, the subject is no longer verified, or a confirm edge belongs to someone else). +- **Purpose:** Bearer staff. Body `{ "accountId" }`. A pending proposal must exist (latest propose/reject is propose) and the caller id must differ from that latest proposer's actor id. Subject must still be `verified`. Inserts `moderator_confirm` then re-lists: if the pending propose-edge `id` from `pendingModeratorProposals` (ignoring this confirm insert) is no longer the same, or that id is not also the oldest open propose, delete that confirm and 409 without promoting. Otherwise sets role to `moderator`, logs `trust.moderator_confirmed`, `200 { id, name, role }`. If the caller already stored `moderator_confirm` and the subject is still `verified`, completes the role write and returns 200; already-moderator with that caller-owned edge is idempotent 200. After a 200 that leaves the subject as `moderator` (new grant and idempotent already-moderator same-actor 200), delete `moderator_proposal` rows (`replyId === subject.id`) then wrap `notifyModeratorAppointed` for the subject only (in-app `moderator_appointed`, Web Push url `/welcome`). Failure logs `push.enqueue.failed`; persist/HTTP still 200. +- **Errors:** Same 401/403/400/404/409/503 JSON shapes as `POST /trust/verify` (409 when there is no pending propose, the caller proposed, the subject is no longer verified, a confirm edge belongs to someone else, the pending propose-edge id changed after insert, or that id is not the oldest open propose). - **Used by:** Independent second staff confirmation. - **Auth:** `Authorization: Bearer` session. Staff only. +## Endpoint: POST /trust/reject-moderator + +- **Purpose:** Bearer staff (same auth as propose-moderator). Body `{ "accountId" }`. Reject an open moderator proposal: pending means the latest propose/reject is propose, the subject is still `verified`, and there is no confirm/appoint. Inserts append-only `moderator_reject` then re-lists: if a concurrent confirm or appoint already closed the grant, or the pending propose-edge `id` is still the same (this reject lost the same-ms id tie), delete that reject and 409. If a newer propose already reopened the queue (including a same-actor same-ms re-propose with a different edge `id`), 200 keeps the reject in history and does not drop `moderator_proposal` rows. Role stays `verified`. The original proposer may reject. Logs `trust.moderator_rejected` `{ subjectId, actorId }`. When pending is empty after insert, re-lists once more and deletes `moderator_proposal` rows with `replyId === subject.id` only if pending is still empty; if a re-list after that delete shows a new pending propose, fan out for that actor and re-list again so a concurrent close drops those rows. No notify for the reject itself. `200 { id, name, role }` with role unchanged. +- **Errors:** 401 `{ error: 'Unauthorized' }` without a bearer session; 403 `{ error: 'Forbidden' }` when the caller is not staff; 400 `{ error: 'Expected a JSON body with an "accountId" string' }`; 404 `{ error: 'Not found' }` for a non-UUID or missing subject; 409 `{ error: 'Conflict' }` when the subject is self, not pending, `role !== verified`, a concurrent confirm/appoint closed the grant, or the pending propose-edge id is still the same after insert; 503 `{ error: 'Trust chain is unavailable' }` on unexpected store throw (`trust.write.failed`). +- **Used by:** Staff moderator-proposal queue in the app (reject an open proposal so another staff member can re-propose). +- **Auth:** `Authorization: Bearer` session. Staff only (moderator+). Not `DEBUG_TOKEN`. + ## Endpoint: POST /trust/appoint-moderator -- **Purpose:** Bearer founder (moderators → 403). Body `{ "accountId" }`. Subject must not be self, not founder, and not already moderator; may be `basis` or `verified`. Inserts `moderator_appoint` then sets role to `moderator`, logs `trust.moderator_appointed`, `200 { id, name, role }`. If the caller already stored `moderator_appoint` and the subject is not yet `moderator`, completes the role write and returns 200; already-moderator with that caller-owned edge is idempotent 200. After a 200 that leaves the subject as `moderator` (new grant and idempotent already-moderator same-actor 200), wrap `notifyModeratorAppointed` for the subject only (in-app `moderator_appointed`, Web Push url `/welcome`). Failure logs `push.enqueue.failed`; persist/HTTP still 200. +- **Purpose:** Bearer founder (moderators → 403). Body `{ "accountId" }`. Subject must not be self, not founder, and not already moderator; may be `basis` or `verified`. Inserts `moderator_appoint` then sets role to `moderator`, logs `trust.moderator_appointed`, `200 { id, name, role }`. If the caller already stored `moderator_appoint` and the subject is not yet `moderator`, completes the role write and returns 200; already-moderator with that caller-owned edge is idempotent 200. After a 200 that leaves the subject as `moderator` (new grant and idempotent already-moderator same-actor 200), delete `moderator_proposal` rows with `replyId === subject.id`, then wrap `notifyModeratorAppointed` for the subject only (in-app `moderator_appointed`, Web Push url `/welcome`). Failure logs `push.enqueue.failed`; persist/HTTP still 200. - **Errors:** 401 without session; 403 when the caller is not `founder`; 400/404/409/503 same JSON shapes as `POST /trust/verify`. - **Used by:** Founder appointment of a moderator. - **Auth:** `Authorization: Bearer` session. Founder only. @@ -730,14 +737,14 @@ ## Endpoint: POST /debug/trust-edges -- **Purpose:** Operator backfill of a stored trust edge. Body `{ "subjectId", "actorId", "kind" }` with `kind` one of `verify` / `moderator_propose` / `moderator_confirm` / `moderator_appoint`. Inserts the edge, logs `debug.trust_edges.inserted` `{ subjectId, actorId, kind }`, and returns `{ id, subjectId, actorId, kind, createdAt }` (`createdAt` ISO-8601). Does **not** change `account.role`. `PATCH /debug/accounts/:id` remains role-only. -- **Errors:** 503 `{ error: 'Debug is not configured' }` when `DEBUG_TOKEN` is unset or blank; 401 `{ error: 'Unauthorized' }` when the Bearer token does not match; 400 `{ error: 'Expected a JSON body with "subjectId", "actorId", and "kind" strings' }`; 404 `{ error: 'Not found' }` when subject or actor is missing or not a UUID; 409 `{ error: 'Conflict' }` on duplicate `(subjectId, kind)` or `subjectId === actorId`; 503 `{ error: 'Trust chain is unavailable' }` on unexpected store throw (`debug.trust_edges.failed`). +- **Purpose:** Operator backfill of a stored trust edge. Body `{ "subjectId", "actorId", "kind" }` with `kind` one of `verify` / `moderator_propose` / `moderator_confirm` / `moderator_appoint` / `moderator_reject`. Inserts the edge, logs `debug.trust_edges.inserted` `{ subjectId, actorId, kind }`, and returns `{ id, subjectId, actorId, kind, createdAt }` (`createdAt` ISO-8601). Does **not** change `account.role`. `PATCH /debug/accounts/:id` remains role-only. POST 409 duplicate only for live-unique kinds (`verify` / `moderator_confirm` / `moderator_appoint`); propose and reject may repeat. +- **Errors:** 503 `{ error: 'Debug is not configured' }` when `DEBUG_TOKEN` is unset or blank; 401 `{ error: 'Unauthorized' }` when the Bearer token does not match; 400 `{ error: 'Expected a JSON body with "subjectId", "actorId", and "kind" strings' }`; 404 `{ error: 'Not found' }` when subject or actor is missing or not a UUID; 409 `{ error: 'Conflict' }` on duplicate live-unique `(subjectId, kind)` or `subjectId === actorId`; 503 `{ error: 'Trust chain is unavailable' }` on unexpected store throw (`debug.trust_edges.failed`). - **Used by:** Operator `gifts-debug trust-edge` CLI. - **Auth:** `Authorization: Bearer` with `DEBUG_TOKEN`. Not an end-user session. ## Endpoint: DELETE /debug/trust-edges -- **Purpose:** Operator delete of a stored trust edge. Body `{ "subjectId", "kind" }` with `kind` one of `verify` / `moderator_propose` / `moderator_confirm` / `moderator_appoint`. Removes the unique `(subjectId, kind)` row, logs `debug.trust_edges.deleted` `{ subjectId, kind }`, and returns the deleted `{ id, subjectId, actorId, kind, createdAt }` (`createdAt` ISO-8601). Does **not** change `account.role`. +- **Purpose:** Operator delete of a stored trust edge. Body `{ "subjectId", "kind" }` with `kind` one of `verify` / `moderator_propose` / `moderator_confirm` / `moderator_appoint` / `moderator_reject`. Removes the latest `(subjectId, kind)` row (`createdAt` desc, then `id` desc), logs `debug.trust_edges.deleted` `{ subjectId, kind }`, and returns the deleted `{ id, subjectId, actorId, kind, createdAt }` (`createdAt` ISO-8601). Does **not** change `account.role`. - **Errors:** 503 `{ error: 'Debug is not configured' }` when `DEBUG_TOKEN` is unset or blank; 401 `{ error: 'Unauthorized' }` when the Bearer token does not match; 400 `{ error: 'Expected a JSON body with "subjectId" and "kind" strings' }`; 404 `{ error: 'Not found' }` when `subjectId` is not a UUID or no row matches; 503 `{ error: 'Trust chain is unavailable' }` on unexpected store throw (`debug.trust_edges.delete_failed`). - **Used by:** Operator `gifts-debug trust-edge-delete` CLI. - **Auth:** `Authorization: Bearer` with `DEBUG_TOKEN`. Not an end-user session. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index b5d0c087a..e070c8640 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -428,8 +428,8 @@ ## Function: InMemoryNotificationStore -- **Purpose:** Process-local `NotificationStore` for in-app forum post, reply, zap, and moderator appointment notifications. Default empty so the process boots without a database. `deleteByMessageIds` removes rows whose `parentId` or `replyId` is in the id list (any type). -- **Inputs:** Optional seed `NotificationRow[]` (copied). `create` is unique on `(recipientAccountId, type, replyId)` and returns the existing row on duplicate. `listByRecipient(accountId, limit)` is newest `createdAt` then `id` DESC. `unreadCount` is total unread (`readAt === null`), not page length. `markRead` / `markAllRead` stamp unread rows only. `deleteByMessageIds(ids)` is a no-op for empty `ids`. Operator dump: `listAll(limit)` newest-first (cap 200). +- **Purpose:** Process-local `NotificationStore` for in-app forum post, reply, zap, moderator appointment, and open moderator-proposal notifications. Default empty so the process boots without a database. `deleteByMessageIds` removes rows whose `parentId` or `replyId` is in the id list (any type). `deleteByTypeAndReplyId` removes rows whose `type` and `replyId` both match. Mark-read skips `moderator_proposal`. +- **Inputs:** Optional seed `NotificationRow[]` (copied). `create` is unique on `(recipientAccountId, type, replyId)` and returns the existing row on duplicate. `listByRecipient(accountId, limit)` is newest `createdAt` then `id` DESC. `unreadCount` is total unread (`readAt === null`), not page length. `markRead` / `markAllRead` stamp unread rows only except `moderator_proposal` (left unread). `deleteByMessageIds(ids)` is a no-op for empty `ids`. `deleteByTypeAndReplyId(type, replyId)` returns the removed count. Operator dump: `listAll(limit)` newest-first (cap 200). - **Returns / side effects:** Promise of row copies; mutating results does not change the store. No I/O. - **Used by:** `createApp` default `notificationStore`; memory `openBootStores` omits it. @@ -442,7 +442,7 @@ ## Function: PostgresNotificationStore -- **Purpose:** Durable `NotificationStore` over Postgres (`notification`). Same port as the in-memory adapter: unique create, newest-first list, total unread count, get/mark-one/mark-all for the recipient only, and `deleteByMessageIds` (`parent_id` or `reply_id` in the id list; the ids are bound as one `uuid[]` array-literal string built from well-formed UUIDs only, because the driver does not encode a JavaScript array for `$1::uuid[]`). +- **Purpose:** Durable `NotificationStore` over Postgres (`notification`). Same port as the in-memory adapter: unique create, newest-first list, total unread count, get/mark-one/mark-all for the recipient only (mark-read / mark-all skip `type = 'moderator_proposal'`), `deleteByMessageIds` (`parent_id` or `reply_id` in the id list; the ids are bound as one `uuid[]` array-literal string built from well-formed UUIDs only, because the driver does not encode a JavaScript array for `$1::uuid[]`), and `deleteByTypeAndReplyId` (`DELETE FROM notification WHERE type = $1 AND reply_id = $2 RETURNING id`). - **Inputs:** Constructor takes a shared boot `SqlClient` (already migrated via `migrateNotificationSchema`). Operator dump: `listAll(limit)` newest-first (cap 200). - **Returns / side effects:** Parameter-bound SQL; maps snake_case rows to `NotificationRow`. Unique violation re-selects the existing row. Errors propagate to the route (503). - **Used by:** `openBootStores` when `DATABASE_URL` is set. @@ -794,7 +794,7 @@ ## Function: createApp -- **Purpose:** Wires CORS, requestLog, brand, health, info, auth, me, `/view`, lightning-address, `/debug/accounts`, `/debug/contacts`, `/debug/api-log`, `/debug/db`, `/debug/external-pubkeys`, `/debug/messages`, `/debug/invoices`, `/debug/invoices/settle`, `/debug/zap-ingests`, `/debug/push-ping`, `/debug/trust-edges`, `/debug/dump`, `/trust-chain`, `/trust` (verify / propose-moderator / confirm-moderator / appoint-moderator), `/funding` (apply / applications / trial / admit / reject), Web Push subscription routes, `/gifts`, `/gifts/stats`, `/messages` (incl. invoice), `/members/:accountId`, `/.well-known` NIP-05 `nostr.json` (CORS `*`), `/contact`, `/conversations`, `/notifications`, and invoices. +- **Purpose:** Wires CORS, requestLog, brand, health, info, auth, me, `/view`, lightning-address, `/debug/accounts`, `/debug/contacts`, `/debug/api-log`, `/debug/db`, `/debug/external-pubkeys`, `/debug/messages`, `/debug/invoices`, `/debug/invoices/settle`, `/debug/zap-ingests`, `/debug/push-ping`, `/debug/trust-edges`, `/debug/dump`, `/trust-chain`, `/trust` (verify / propose-moderator / confirm-moderator / reject-moderator / appoint-moderator), `/funding` (apply / applications / trial / admit / reject), Web Push subscription routes, `/gifts`, `/gifts/stats`, `/messages` (incl. invoice), `/members/:accountId`, `/.well-known` NIP-05 `nostr.json` (CORS `*`), `/contact`, `/conversations`, `/notifications`, and invoices. - **Inputs:** Optional `AppDeps` (store, clock, payer, fetch, cache, readBrand, origins, `debugToken`, giftStore, `giftRecorder`, `btcUsdRates`, `fiatRates`, `messageStore`, `contactStore`, optional `conversationStore` (default `InMemoryConversationStore`), optional `notificationStore` (default `InMemoryNotificationStore`), optional `apiLogStore` (default `InMemoryApiLogStore`), optional `debugDbStore` (omitted on a memory boot; `GET /debug/db` then 503 after the token matches), `pushStore`, `trustStore`, optional `fundingStore` (default `InMemoryFundingStore`; also forwarded to `debugPaymentsRoutes`), optional `listDbChange`, `vapidPublicKey`, `nostrKek`, optional `nostrPublisher` (without `nostrKek` staff hide skips NIP-09), optional `env` (default `process.env`; relays / `PUBLIC_BASE_URL` / Cloudflare on `DELETE /messages/:id`), spendApiToken, `spendPing` (default `resolveSpendPing(process.env, fetchImpl)`; unset/blank `SPEND_URL` or `SPEND_API_TOKEN` omits it; `POST /messages` still 200; daily/omitted kind body `{ address, messageId }`; `conversationRoutes` gets the same `spendPing`; moderator-group POST body `{ address, kind: "moderator", groupMessageId }` without `messageId`; forum `POST /messages` still two-arg daily ping), optional `postLimiter` (default a new `PostRateLimiter`; passed to `messagesRoutes`; boot shares one instance with the Nostr worker), invoiceStore, `webAuthnRpId`, `webAuthnRpName`, `passkeyCeremony`). `debugPaymentsRoutes` receives the same optional `spendPing`. Omitted `giftRecorder` → `invoiceRoutes` uses `NoopGiftRecorder`; omitted `messageStore` → `InMemoryMessageStore`; omitted `contactStore` → `InMemoryContactStore`; omitted `conversationStore` → `InMemoryConversationStore`; omitted `notificationStore` → `InMemoryNotificationStore`; omitted `pushStore` → `InMemoryPushStore`; omitted `trustStore` → `InMemoryTrustStore`; omitted `fundingStore` → `InMemoryFundingStore`; omitted `apiLogStore` → `InMemoryApiLogStore`; omitted/blank `vapidPublicKey` → push HTTP 503 after session; omitted `nostrKek` → unsigned forum + invoice 503; SQL boot injects `SqlGiftRecorder`, `PostgresMessageStore`, `PostgresContactStore`, `PostgresConversationStore`, `PostgresNotificationStore`, `PostgresPushStore`, `PostgresTrustStore`, `PostgresFundingStore`, `PostgresApiLogStore`, `PostgresDebugDbStore`, and parsed KEK. `messagesRoutes`, `meRoutes`, `invoiceRoutes`, and `trustRoutes` receive `conversationStore`. `fundingRoutes`, `invoiceRoutes`, `messagesRoutes`, `conversationRoutes`, `meRoutes`, `membersRoutes`, and auth finish receive `fundingStore`. `contactRoutes` and `conversationRoutes` receive `pushStore` plus `notificationStore`. Mounts `notificationRoutes` at `/notifications`. Does not take a push sender (worker owns delivery). - **Returns / side effects:** Hono app. Default `btcUsdRates` is an empty `InMemoryBtcUsdStore`. Default `fiatRates` is an empty `InMemoryFiatStore`. `createApp` passes the same `fiatRates` object into `/gifts`, `/gifts/stats`, `/me`, `/members`, and `/view`. Used by Bun.serve in `index.ts` and by tests via `app.request()`. - **Used by:** Boot path and every HTTP test. @@ -873,7 +873,7 @@ ## Function: notificationRoutes -- **Purpose:** Hono sub-app for signed-in in-app notifications: `GET /` lists `{ notifications, unreadCount }` (scan newest 1000, `notificationsMatchingLevel` for the owner's `notificationLevel`, then drop rows whose parent **message** is missing or `deletedAt !== null` (`forum_reply` also checks the child `replyId` message; `zap` `replyId` is a receipt UUID and is not looked up), then cap 200; `unreadCount` is matching unread among kept rows; each item `type` is `'forum_post' | 'forum_reply' | 'zap' | 'moderator_appointed'`; `moderator_appointed` always stays through the level filter and the hidden filter), `POST /read-all` marks all read, `POST /:id/read` marks one UUID. Mount `read-all` before `/:id/read`. Never exposes recipient or actor account ids. `DEBUG_TOKEN` cannot read this list. `createApp` always passes `messages` (`getById`). Hidden/missing forum rows are then best-effort `deleteByMessageIds` (`notifications.hidden.purged`). Appointed `parentId`/`replyId` are account ids in prod, so a purge of hidden **message** ids does not remove them. If purge throws, count kept unread rows (do not 503 the list). +- **Purpose:** Hono sub-app for signed-in in-app notifications: `GET /` lists `{ notifications, unreadCount }` (scan newest 1000, `notificationsMatchingLevel` for the owner's `notificationLevel`, then drop rows whose parent **message** is missing or `deletedAt !== null` (`forum_reply` also checks the child `replyId` message; `zap` `replyId` is a receipt UUID and is not looked up), then cap 200; `unreadCount` is matching unread among kept rows; each item `type` is `'forum_post' | 'forum_reply' | 'zap' | 'moderator_appointed' | 'moderator_proposal'`; `moderator_appointed` and `moderator_proposal` always stay through the level filter and the hidden filter), `POST /read-all` marks all read except `moderator_proposal`, `POST /:id/read` marks one UUID (`moderator_proposal` stays unread). Mount `read-all` before `/:id/read`. Never exposes recipient or actor account ids. `DEBUG_TOKEN` cannot read this list. `createApp` always passes `messages` (`getById`). Hidden/missing forum rows are then best-effort `deleteByMessageIds` (`notifications.hidden.purged`). Appointed/proposal `parentId`/`replyId` are account ids in prod, so a purge of hidden **message** ids does not remove them. If purge throws, count kept unread rows (do not 503 the list). - **Inputs:** `NotificationRouteDeps`: notification `store`, shared `authStore`, `messages` (`getById`), `now`. - **Returns / side effects:** Hono app mounted at `/notifications`. 401 without session; 404 `{ error: 'Not found' }` for unknown / other-account / non-uuid `:id`; 503 `{ error: 'Notifications are unavailable' }` (`notifications.list.failed` / `notifications.read_all.failed` / `notifications.read.failed`). Hidden-row purge failure is not 503. - **Used by:** `createApp`. @@ -1013,7 +1013,7 @@ ## Function: serializeNotification -- **Purpose:** Project a stored notification row to its public JSON shape. `type` is `'forum_post' | 'forum_reply' | 'zap' | 'moderator_appointed'`. +- **Purpose:** Project a stored notification row to its public JSON shape. `type` is `'forum_post' | 'forum_reply' | 'zap' | 'moderator_appointed' | 'moderator_proposal'`. - **Inputs:** `NotificationRow` (includes recipient/actor account ids). - **Returns / side effects:** `{ id, type, parentId, replyId, name, text, createdAt, readAt }` with ISO-8601 dates; `readAt` null stays null. Omits recipient and actor account ids. No I/O. - **Used by:** `notificationRoutes`. @@ -1053,6 +1053,13 @@ - **Returns / side effects:** Void. Writes one in-app row for the subject when `notifications` is set. When `pushStore` is set, enqueues one outbox row with payload from `buildModeratorAppointedPushPayload(subject.id)` (url `/welcome`, tag `moderator_appointed:`). Outbox JSON `unreadCount` is notification unread + listed inbox unread when either source is passed. - **Used by:** `trustRoutes` `POST /trust/confirm-moderator` and `POST /trust/appoint-moderator` after every 200 that leaves/keeps the subject as `moderator` (new grant **and** idempotent already-moderator same-actor 200). Failure logs `push.enqueue.failed`; HTTP still 200. +## Function: notifyModeratorProposed + +- **Purpose:** Notify other staff of an open moderator proposal (not a living-room fan-out). Recipients are staff from the live account list except the proposing actor and anyone with `isPlatform === true`; founder is included; basis and verified are skipped. Persist a `moderator_proposal` row when `notifications` is set (`parentId` and `replyId` = `subject.id`, `name` is `actor.name ?? 'Someone'`, `text` is `subject.name ?? ''`, `readAt` null) and enqueue a Web Push (`type: 'forum'`, url `/moderate/proposals`, tag `moderator_proposal:`) when `pushStore` is set. Missing both stores is a no-op. Unique duplicate create is fine. Mark-read does not dismiss these rows. May throw (`push.fanout.failed`); callers wrap so persist still succeeds. +- **Inputs:** `{ notifications?, pushStore?, inboxUnreadCount?, recipients, subject, actor, nowMs }`. Recipients are filtered here to other staff. +- **Returns / side effects:** Void. Writes one in-app row per other staff member when `notifications` is set. When `pushStore` is set, enqueues one outbox row per recipient (`type: 'forum'`, `messageId: subject.id`). Outbox JSON `unreadCount` is notification unread + listed inbox unread when either source is passed. +- **Used by:** `trustRoutes` `POST /trust/propose-moderator` after every 200, and `POST /trust/reject-moderator` when a re-list after dropping `moderator_proposal` rows shows a new pending propose (not for the reject itself). Failure logs `push.enqueue.failed`; HTTP still 200. + ## Function: parseNotificationLevel - **Purpose:** Map a stored or request value to the owner fan-out enum so omitted and unknown strings keep current every-account behaviour. Accepts only the strings `all`, `active`, and `mentions`; any other input (number, null, undefined, object, unknown string) becomes `all`. @@ -1076,7 +1083,7 @@ ## Function: notificationsMatchingLevel -- **Purpose:** Keep stored in-app rows the owner's current `notificationLevel` would still accept, same rules as `wantsNotification`. `all` returns the rows unchanged. `moderator_appointed` always stays. `forum_post` is never personal (`mentionedAccountId` null). `forum_reply` / `zap` use the parent note's `accountId` and `sats` from `parentById`; a missing parent is unpaid and not personal. Zap `text` is the amount string and still counts as active when `> 0`. Zap actor staff is the stored actor via `isStaffAccount` only when that actor is not the parent note author (missing payer is not staff). +- **Purpose:** Keep stored in-app rows the owner's current `notificationLevel` would still accept, same rules as `wantsNotification`. `all` returns the rows unchanged. `moderator_appointed` and `moderator_proposal` always stay. `forum_post` is never personal (`mentionedAccountId` null). `forum_reply` / `zap` use the parent note's `accountId` and `sats` from `parentById`; a missing parent is unpaid and not personal. Zap `text` is the amount string and still counts as active when `> 0`. Zap actor staff is the stored actor via `isStaffAccount` only when that actor is not the parent note author (missing payer is not staff). - **Inputs:** `{ rows, level, recipientAccountId, accounts, parentById }`. - **Returns / side effects:** Matching rows in the same order. No I/O. - **Used by:** `notificationRoutes` `GET /notifications` after scanning the newest `NOTIFICATION_FILTER_SCAN_LIMIT` rows. @@ -1093,7 +1100,7 @@ - **Purpose:** Build the fan-out `inboxUnreadCount` callback: listed GET `/conversations` unread for one account. Staff comes from `getAccount` + `roleAtLeast(role, 'moderator')`, the `moderator` flag from `isModeratorGroupMember` (at least moderator and not the platform account). GET `/conversations` never lists `moderator_group` (fifth argument always false); this helper still pins that thread in the badge unread count for every group member. Platform id from `listAccounts` / `isPlatform`. Lookup failure yields staff false, moderator false, and `platformId` null. - **Inputs:** `ConversationStore`, `Pick`. - **Returns / side effects:** `(accountId) => Promise` calling `conversations.unreadCount`. -- **Used by:** `notifyConversationMessage`; `notifyForumPost` / `notifyForumReply` / `notifyZap` / `notifyModeratorAppointed` callers that have a conversation store (`messagesRoutes`, `meRoutes`, `ensureProfileMessage`, `indexOpenZapReceipts`, `runNostrWorkerTick`, `trustRoutes`). +- **Used by:** `notifyConversationMessage`; `notifyForumPost` / `notifyForumReply` / `notifyZap` / `notifyModeratorAppointed` / `notifyModeratorProposed` callers that have a conversation store (`messagesRoutes`, `meRoutes`, `ensureProfileMessage`, `indexOpenZapReceipts`, `runNostrWorkerTick`, `trustRoutes`). ## Function: notifyConversationMessage @@ -2092,7 +2099,7 @@ ## Function: isStaffRole -- **Purpose:** True when `account.role` may run staff trust routes. Delegates to `roleAtLeast(role, 'moderator')`. `basis` and `verified` return false. Used before `GET /trust/proposals`, `POST /trust/verify`, `POST /trust/propose-moderator`, and `POST /trust/confirm-moderator` (appoint requires founder separately via `roleAtLeast(..., 'founder')`). +- **Purpose:** True when `account.role` may run staff trust routes. Delegates to `roleAtLeast(role, 'moderator')`. `basis` and `verified` return false. Used before `GET /trust/proposals`, `POST /trust/verify`, `POST /trust/propose-moderator`, `POST /trust/confirm-moderator`, and `POST /trust/reject-moderator` (appoint requires founder separately via `roleAtLeast(..., 'founder')`). - **Inputs:** `AccountRole` (`basis` \| `verified` \| `moderator` \| `founder`). - **Returns / side effects:** boolean. No I/O. - **Used by:** `trustRoutes`. @@ -2106,14 +2113,14 @@ ## Function: isProjectedTrustEdge -- **Purpose:** Whether a stored edge appears on the public Trust Chain. True iff `edge.kind` equals the oldest eligible sibling among `subjectEdges` (`createdAt` then `id`). Eligible: `verify`, `moderator_appoint`, and `moderator_propose` only when the live subject is a `moderator`. `moderator_confirm` never. Later appoint, confirm, or propose do not replace an earlier eligible contact. -- **Inputs:** `edge` (`TrustEdge`), `subject` (`Account | undefined`), `subjectEdges` (`readonly TrustEdge[]`, default `[edge]`). +- **Purpose:** Whether a stored edge appears on the public Trust Chain. True iff `edge` is the oldest eligible sibling among `subjectEdges` (`createdAt` then `id`). When `chainActorIds` is set, skip siblings whose actor is not in that set and take the oldest remaining eligible edge. Eligible: `verify`, `moderator_appoint`, and `moderator_propose` only when the live subject is a `moderator`. `moderator_confirm` and `moderator_reject` never. Later appoint, confirm, or propose do not replace an earlier eligible contact. At most one public incoming edge per subject, even when several rows share the winning kind. +- **Inputs:** `edge` (`TrustEdge`), `subject` (`Account | undefined`), `subjectEdges` (`readonly TrustEdge[]`, default `[edge]`), optional `chainActorIds` (`ReadonlySet`) so a non-chain oldest sibling does not hide a later displayable contact. - **Returns / side effects:** boolean. No I/O. - **Used by:** `buildTrustChain`, `trustChainRoutes` (`GET /trust-chain?around=`). ## Function: buildTrustChain -- **Purpose:** Project live accounts and stored trust edges to the public graph. Nodes are founder/moderator/verified only (never `basis`), sorted founder then moderator then verified, then oldest `createdAt`, then `id`. Groups stored edges by `subjectId` and projects at most one incoming kind per subject: the oldest eligible sibling (`createdAt` then `id`). Eligible: `verify`, `moderator_appoint`, and `moderator_propose` only when the live subject is a `moderator`. Never invents edges; `moderator_confirm` is omitted; a pending propose (subject still `verified`) stays private; later appoint, confirm, or propose do not replace an earlier eligible contact; omits lightning addresses, view keys, and linking keys. A node with no stored incoming edge stays disconnected. +- **Purpose:** Project live accounts and stored trust edges to the public graph. Nodes are founder/moderator/verified only (never `basis`), sorted founder then moderator then verified, then oldest `createdAt`, then `id`. Groups stored edges by `subjectId` and projects at most one incoming edge per subject: the oldest eligible sibling (`createdAt` then `id`), skipping a non-chain oldest sibling so a later displayable contact can show. Eligible: `verify`, `moderator_appoint`, and `moderator_propose` only when the live subject is a `moderator`. Never invents edges; `moderator_confirm` and `moderator_reject` are omitted; a pending propose (subject still `verified`) stays private; later appoint, confirm, or propose do not replace an earlier eligible contact; omits lightning addresses, view keys, and linking keys. A node with no stored incoming edge stays disconnected. - **Inputs:** `accounts` (`readonly Account[]`), `edges` (`readonly TrustEdge[]`). - **Returns / side effects:** `{ nodes, edges }` (`TrustChain`). No I/O. - **Used by:** `trustChainRoutes` (`GET /trust-chain`). @@ -2127,10 +2134,10 @@ ## Function: pendingModeratorProposals -- **Purpose:** Pure helper for the staff moderator-proposal queue. A row is pending when a `moderator_propose` edge exists, the live subject is `verified`, and that subject has no `moderator_confirm` and no `moderator_appoint`. Missing subject accounts are omitted. Several proposes for one subject keep the latest by `createdAt` then `id` (same tie-break as `accountTrust`). `proposedBy` uses live actor names; a missing actor is `{ id, name: null }`. Sorted oldest `createdAt` first, then propose-edge `id` (FIFO). Never includes `basis` / `moderator` / `founder` subjects. +- **Purpose:** Pure helper for the staff moderator-proposal queue. A row is pending when the latest `moderator_propose` / `moderator_reject` edge is `moderator_propose`, the live subject is `verified`, and that subject has no `moderator_confirm` and no `moderator_appoint`. A later reject closes the queue; a later propose re-opens it; confirm/appoint close forever. Missing subject accounts are omitted. Several propose/reject edges for one subject keep the latest by `createdAt` then `id` (same tie-break as `accountTrust`). `id` is that latest propose-edge id. `proposedBy` uses live actor names; a missing actor is `{ id, name: null }`. Sorted oldest `createdAt` first, then propose-edge `id` (FIFO). Never includes `basis` / `moderator` / `founder` subjects. - **Inputs:** `accounts` (`readonly Account[]`), `edges` (`readonly TrustEdge[]`). -- **Returns / side effects:** `ModeratorProposal[]` (epoch-ms `createdAt`; subject `role` is always `"verified"`). No I/O. -- **Used by:** `trustRoutes` (`GET /trust/proposals`). +- **Returns / side effects:** `ModeratorProposal[]` (`id` plus epoch-ms `createdAt`; subject `role` is always `"verified"`). No I/O. +- **Used by:** `trustRoutes` (`GET /trust/proposals`, `POST /trust/propose-moderator`, `POST /trust/confirm-moderator`, `POST /trust/reject-moderator`). ## Function: serializeTrustEdge @@ -2141,7 +2148,7 @@ ## Function: migrateTrustSchema -- **Purpose:** Applies `TRUST_SCHEMA_SQL` in order (`CREATE TABLE IF NOT EXISTS trust_edge` with FKs to `account`, kind CHECK, `subject_id <> actor_id`, unique `(subject_id, kind)` index, actor index). Idempotent. Runs after auth/`account` exists and before `migrateDbChangeSchema` so `trg_db_change` attaches to `trust_edge`. +- **Purpose:** Applies `TRUST_SCHEMA_SQL` in order (six statements: `CREATE TABLE IF NOT EXISTS trust_edge` with FKs to `account`, kind CHECK including `moderator_reject`, `subject_id <> actor_id`; `DROP CONSTRAINT IF EXISTS trust_edge_kind_check`; `ADD CONSTRAINT` kind check including `moderator_reject`; `DROP INDEX IF EXISTS trust_edge_subject_kind_uidx`; live unique index `trust_edge_subject_kind_live_uidx` on `(subject_id, kind) WHERE kind IN ('verify', 'moderator_confirm', 'moderator_appoint')`; actor index). Idempotent. Runs after auth/`account` exists and before `migrateDbChangeSchema` so `trg_db_change` attaches to `trust_edge`. - **Inputs:** `SqlClient`. - **Returns / side effects:** Void; idempotent DDL execute matching `docs/schema/trust_edge.sql` (comment header allowed in the `.sql` file only). - **Used by:** `openBootStores` when SQL opens. @@ -2149,28 +2156,28 @@ ## Function: InMemoryTrustStore - **Purpose:** Process-local `TrustStore` for who granted which staff status. Default empty so the process boots without a database. `createApp` uses this when boot leaves `trustStore` undefined (memory `DATABASE_URL`). -- **Inputs:** Optional seed `TrustEdge[]` (copied). `listEdges` / `listEdgesForSubject` / `listEdgesTouching` sort oldest `createdAt` then `id` ASC. `insertEdge` copies on write and throws `Error('duplicate trust edge')` when `(subjectId, kind)` exists. `deleteEdge(subjectId, kind)` removes that unique row or returns `undefined`. +- **Inputs:** Optional seed `TrustEdge[]` (copied). `listEdges` / `listEdgesForSubject` / `listEdgesTouching` sort oldest `createdAt` then `id` ASC. `insertEdge` copies on write and throws `Error('duplicate trust edge')` only for live-unique kinds (`verify` / `moderator_confirm` / `moderator_appoint`); propose and reject may repeat. `deleteEdge(subjectId, kind)` removes the latest matching row (`createdAt` desc, then `id` desc) or returns `undefined`. `deleteEdgeById(id)` removes that row or returns `undefined`. - **Returns / side effects:** Promise of edge copies; mutating results does not change the store. No I/O. - **Used by:** `createApp` default `trustStore`. ## Function: PostgresTrustStore -- **Purpose:** Durable `TrustStore` over Postgres (`trust_edge` table). `listEdges` / `listEdgesForSubject` / `listEdgesTouching` are oldest-first; `insertEdge` binds columns without `ON CONFLICT` and maps unique violation `23505` to `Error('duplicate trust edge')`. `deleteEdge` is `DELETE … RETURNING` on `(subject_id, kind)` and returns `undefined` when no row matches. +- **Purpose:** Durable `TrustStore` over Postgres (`trust_edge` table). `listEdges` / `listEdgesForSubject` / `listEdgesTouching` are oldest-first; `insertEdge` binds columns without `ON CONFLICT` and maps unique violation `23505` to `Error('duplicate trust edge')` (live-unique kinds only at the index). `deleteEdge` selects the latest `(subject_id, kind)` (`ORDER BY created_at DESC, id DESC LIMIT 1`) then `DELETE FROM trust_edge WHERE id = $1 RETURNING …`; empty SELECT returns `undefined` with no delete. `deleteEdgeById` is `DELETE FROM trust_edge WHERE id = $1 RETURNING …`; empty RETURNING is `undefined`. - **Inputs:** Constructor takes a shared boot `SqlClient` (already migrated). Maps `subject_id` / `actor_id` / `created_at` (Date or ISO string) onto `TrustEdge`. - **Returns / side effects:** Parameter-bound SQL; copies on return. Non-unique errors propagate to the route (409/503). - **Used by:** `openBootStores` when `DATABASE_URL` is set. ## Function: trustChainRoutes -- **Purpose:** Hono sub-app for `GET /trust-chain`. Bearer session required (any role). Missing or invalid Bearer → 401 `{ error: 'Unauthorized' }`. Bare GET (no `around`, or empty) returns founder seeds (no edges). `?around=` loads all edges for each subject in the touching set (`listEdgesForSubject`) then `isProjectedTrustEdge` with that sibling list so a non-touching older eligible edge still wins over a touching newer one. One hop of the oldest eligible public kind (`createdAt` then `id`); pending-propose verified neighbors are not nodes; confirm never. Empty arrays when none. Invalid uuid (Postgres `22P02`), unknown, or basis `around` → 404 after a valid session. Other store throw → 503 `{ error: 'Trust chain is unavailable' }` and log `trust.chain.failed`. +- **Purpose:** Hono sub-app for `GET /trust-chain`. Bearer session required (any role). Missing or invalid Bearer → 401 `{ error: 'Unauthorized' }`. Bare GET (no `around`, or empty) returns founder seeds (no edges). `?around=` loads all edges for each subject in the touching set (`listEdgesForSubject`) then `isProjectedTrustEdge` with that sibling list and chain actor ids so a non-touching older eligible edge still wins over a touching newer one, and a non-chain oldest sibling does not hide a later displayable contact. One hop of the oldest eligible public edge (`createdAt` then `id`); pending-propose verified neighbors are not nodes; confirm and reject never. Empty arrays when none. Invalid uuid (Postgres `22P02`), unknown, or basis `around` → 404 after a valid session. Other store throw → 503 `{ error: 'Trust chain is unavailable' }` and log `trust.chain.failed`. - **Inputs:** `TrustChainRouteDeps`: `authStore`, `trustStore`, `now`. - **Returns / side effects:** Hono app mounted at `/trust-chain` (`GET /`). - **Used by:** `createApp`. ## Function: trustRoutes -- **Purpose:** Hono sub-app for staff Bearer `GET /proposals` (pending `moderator_propose` via `pendingModeratorProposals`; ISO `createdAt`; empty list is 200; logs `trust.proposals.listed` `{ count }` only) and four POSTs: `/verify` (role `verified` + `verify` edge; idempotent when the caller already verified), `/propose-moderator` (pending propose, role unchanged), `/confirm-moderator` (independent second staff member; role `moderator` + confirm edge), `/appoint-moderator` (founder only; role `moderator` + appoint edge). UUID check reuses `MESSAGE_ID_RE`. Logs `trust.verified` / `trust.moderator_proposed` / `trust.moderator_confirmed` / `trust.moderator_appointed`. After every confirm/appoint 200 that leaves/keeps the subject as `moderator` (new grant and idempotent already-moderator same-actor 200), wraps `notifyModeratorAppointed` for the subject only. -- **Inputs:** `TrustRouteDeps`: `authStore`, `trustStore`, `now`, optional `notificationStore`, `pushStore`, and `conversationStore` (appointed push `unreadCount` includes listed inbox unread). +- **Purpose:** Hono sub-app for staff Bearer `GET /proposals` (pending via `pendingModeratorProposals`: latest propose/reject is propose, verified, no confirm/appoint; JSON includes propose-edge `id` plus ISO `createdAt`; empty list is 200; logs `trust.proposals.listed` `{ count }` only) and five POSTs: `/verify` (role `verified` + `verify` edge; idempotent when the caller already verified), `/propose-moderator` (new propose after reject; 409 while currently pending, any confirm/appoint, or after insert this row is not the oldest open propose (the insert is deleted; remaining pending is then best-effort cleared and fan-out for that propose-edge id, failure stays 409); extras deleted when this insert is oldest; role unchanged; wrap `notifyModeratorProposed`; after notify, re-list and drop or refresh `moderator_proposal` rows when this insert is no longer pending; a refresh fan-out re-lists immediately before and after so a concurrent reject cannot leave stale rows; a pending-id change during that extra round clears `moderator_proposal` first so unique create cannot keep the previous actor, and a second pending-id change at that depth drops the rows), `/confirm-moderator` (independent second staff member vs the latest pending propose; after insert, undo 409 unless `pendingModeratorProposals` ignoring this confirm still has that propose-edge `id` and it is the oldest open propose; else role `moderator` + confirm edge), `/reject-moderator` (append-only `moderator_reject`; after insert, a concurrent confirm/appoint undoes the reject with 409; same pending propose-edge `id` after insert is also 409; a different pending id including same-actor same-ms re-propose keeps the reject; empty pending re-lists once more before dropping `moderator_proposal` rows and fans out if a re-list after that delete shows a new pending propose; role stays `verified`; proposer may reject), `/appoint-moderator` (founder only; role `moderator` + appoint edge). UUID check reuses `MESSAGE_ID_RE`. Logs `trust.verified` / `trust.moderator_proposed` / `trust.moderator_confirmed` / `trust.moderator_rejected` / `trust.moderator_appointed`. Propose/confirm/appoint delete `moderator_proposal` rows (`replyId === subject.id`). Reject deletes those rows only when pending is empty after insert; if a newer propose already reopened the queue, the reject stays in history and those rows stay. Propose then wraps `notifyModeratorProposed`; confirm/appoint then wrap `notifyModeratorAppointed`; reject does not notify for the reject itself. After every confirm/appoint 200 that leaves/keeps the subject as `moderator` (new grant and idempotent already-moderator same-actor 200), wraps `notifyModeratorAppointed` for the subject only. +- **Inputs:** `TrustRouteDeps`: `authStore`, `trustStore`, `now`, optional `notificationStore`, `pushStore`, and `conversationStore` (appointed and propose push `unreadCount` include listed inbox unread). - **Returns / side effects:** Hono app mounted at `/trust`. 401/403/400/404/409/503 with the documented `{ error }` strings; GET `/proposals` 200 `{ proposals }` (empty list included); POST 200 `{ id, name, role }`. - **Used by:** `createApp`. @@ -2183,7 +2190,7 @@ ## Function: debugTrustRoutes -- **Purpose:** Operator list `GET /debug/trust-edges`, backfill `POST /debug/trust-edges`, and undo `DELETE /debug/trust-edges`. Same 503/401 `DEBUG_TOKEN` gate as other debug routes. GET returns `{ edges }` newest-first. POST body `{ subjectId, actorId, kind }` inserts; DELETE body `{ subjectId, kind }` removes the unique `(subjectId, kind)` row. POST/DELETE return `serializeTrustEdge` (ISO `createdAt`) and do **not** change `account.role`. `PATCH /debug/accounts/:id` remains role-only. +- **Purpose:** Operator list `GET /debug/trust-edges`, backfill `POST /debug/trust-edges`, and undo `DELETE /debug/trust-edges`. Same 503/401 `DEBUG_TOKEN` gate as other debug routes. GET returns `{ edges }` newest-first. POST body `{ subjectId, actorId, kind }` with kind `verify` / `moderator_propose` / `moderator_confirm` / `moderator_appoint` / `moderator_reject` inserts (409 duplicate only for live-unique kinds; propose/reject may repeat); DELETE body `{ subjectId, kind }` removes the latest `(subjectId, kind)` row (`createdAt` desc, then `id` desc). POST/DELETE return `serializeTrustEdge` (ISO `createdAt`) and do **not** change `account.role`. `PATCH /debug/accounts/:id` remains role-only. - **Inputs:** `DebugTrustRouteDeps`: auth `store`, `trustStore`, optional `debugToken`, optional `now` (default `Date.now`; unused by DELETE). - **Returns / side effects:** Hono app mounted at `/debug/trust-edges`. POST success logs `debug.trust_edges.inserted` `{ subjectId, actorId, kind }`. DELETE success logs `debug.trust_edges.deleted` `{ subjectId, kind }`. POST 400/404/409/503 as before. DELETE 400 bad body; 404 missing UUID or missing row; 503 on unexpected store throw (`debug.trust_edges.delete_failed`). GET 503 `{ error: 'Trust chain is unavailable' }` on unexpected `listEdges` throw (`debug.trust_edges.failed`). - **Used by:** `createApp`; operator `gifts-debug trust-edges` / `gifts-debug trust-edge` / `gifts-debug trust-edge-delete`. diff --git a/docs/schema/notification.sql b/docs/schema/notification.sql index bab693c3c..b949e0d0e 100644 --- a/docs/schema/notification.sql +++ b/docs/schema/notification.sql @@ -1,5 +1,6 @@ --- In-app notifications for forum posts, replies, zaps, and appointed moderators --- (GET /notifications; mark-read POST). Kinds: forum_post, forum_reply, zap, moderator_appointed. +-- In-app notifications for forum posts, replies, zaps, appointed moderators, +-- and open moderator proposals (GET /notifications; mark-read POST). Kinds: +-- forum_post, forum_reply, zap, moderator_appointed, moderator_proposal. -- Actor display name and event text are snapshotted at event time. Unique -- still on (recipient, type, reply_id) so a duplicate persist is -- idempotent. Indexed newest-first for listByRecipient. diff --git a/docs/schema/trust_edge.sql b/docs/schema/trust_edge.sql index 62bc02e62..1e8cc3523 100644 --- a/docs/schema/trust_edge.sql +++ b/docs/schema/trust_edge.sql @@ -1,15 +1,21 @@ -- Trust edges: who granted which staff status to whom. --- Session GET /trust-chain projects stored verify / moderator_confirm / --- moderator_appoint edges only. moderator_propose is stored but not shown. +-- Session GET /trust-chain projects at most one incoming edge per subject: +-- verify, moderator_appoint, and moderator_propose only when the live +-- subject is a moderator. moderator_confirm and moderator_reject never. +-- Propose and reject may repeat (append-only history). Live unique kinds +-- stay one-per-subject: verify, moderator_confirm, moderator_appoint. -- Operator PATCH /debug/accounts/:id does not write this table. CREATE TABLE IF NOT EXISTS trust_edge ( id uuid PRIMARY KEY, subject_id uuid NOT NULL REFERENCES account (id), actor_id uuid NOT NULL REFERENCES account (id), - kind text NOT NULL CHECK (kind IN ('verify', 'moderator_propose', 'moderator_confirm', 'moderator_appoint')), + kind text NOT NULL CHECK (kind IN ('verify', 'moderator_propose', 'moderator_confirm', 'moderator_appoint', 'moderator_reject')), created_at timestamptz NOT NULL, CHECK (subject_id <> actor_id) ); -CREATE UNIQUE INDEX IF NOT EXISTS trust_edge_subject_kind_uidx ON trust_edge (subject_id, kind); +ALTER TABLE trust_edge DROP CONSTRAINT IF EXISTS trust_edge_kind_check; +ALTER TABLE trust_edge ADD CONSTRAINT trust_edge_kind_check CHECK (kind IN ('verify', 'moderator_propose', 'moderator_confirm', 'moderator_appoint', 'moderator_reject')); +DROP INDEX IF EXISTS trust_edge_subject_kind_uidx; +CREATE UNIQUE INDEX IF NOT EXISTS trust_edge_subject_kind_live_uidx ON trust_edge (subject_id, kind) WHERE kind IN ('verify', 'moderator_confirm', 'moderator_appoint'); CREATE INDEX IF NOT EXISTS trust_edge_actor_idx ON trust_edge (actor_id); diff --git a/e2e/functions.spec.ts b/e2e/functions.spec.ts index a7882fe53..cc08886d7 100644 --- a/e2e/functions.spec.ts +++ b/e2e/functions.spec.ts @@ -1541,6 +1541,13 @@ test('Function: notifyModeratorAppointed — POST /trust/confirm-moderator witho (await request.post('/trust/confirm-moderator', { data: { accountId: 'x' } })).status(), ).toBe(401); }); +test('Function: notifyModeratorProposed — POST /trust/propose-moderator without bearer is 401', async ({ + request, +}) => { + expect( + (await request.post('/trust/propose-moderator', { data: { accountId: 'x' } })).status(), + ).toBe(401); +}); test('Function: notifyZap — POST /messages without bearer is 401', async ({ request }) => { expect((await request.post('/messages')).status()).toBe(401); }); diff --git a/e2e/http.spec.ts b/e2e/http.spec.ts index c4b0a880b..9ad8bd242 100644 --- a/e2e/http.spec.ts +++ b/e2e/http.spec.ts @@ -740,6 +740,11 @@ test('POST /trust/confirm-moderator without bearer is 401', async ({ request }) expect(res.status()).toBe(401); }); +test('POST /trust/reject-moderator without bearer is 401', async ({ request }) => { + const res = await request.post('/trust/reject-moderator', { data: { accountId: 'x' } }); + expect(res.status()).toBe(401); +}); + test('POST /trust/appoint-moderator without bearer is 401', async ({ request }) => { const res = await request.post('/trust/appoint-moderator', { data: { accountId: 'x' } }); expect(res.status()).toBe(401); diff --git a/scripts/gifts-debug.sh b/scripts/gifts-debug.sh index 36e0810b8..c9716ef3f 100755 --- a/scripts/gifts-debug.sh +++ b/scripts/gifts-debug.sh @@ -45,7 +45,7 @@ # gifts-debug trust-edge # # POST a stored trust edge; print edge JSON # # kind: verify | moderator_propose | -# # moderator_confirm | moderator_appoint +# # moderator_confirm | moderator_appoint | moderator_reject # gifts-debug trust-edge-delete # # DELETE a stored trust edge; print edge JSON # # Does not change account.role diff --git a/src/__tests__/lib/notification-store.test.ts b/src/__tests__/lib/notification-store.test.ts index dfd7988c6..e5309c8d4 100644 --- a/src/__tests__/lib/notification-store.test.ts +++ b/src/__tests__/lib/notification-store.test.ts @@ -182,6 +182,15 @@ describe('InMemoryNotificationStore', () => { expect(marked?.readAt?.toISOString()).toBe(original.toISOString()); }); + it('markRead on a moderator_proposal leaves readAt null', async () => { + const store = new InMemoryNotificationStore([ + row({ type: 'moderator_proposal', replyId: 'subject' }), + ]); + const marked = await store.markRead('n-1', 'parent', READ_AT); + expect(marked?.readAt).toBeNull(); + expect((await store.getByIdForRecipient('n-1', 'parent'))?.readAt).toBeNull(); + }); + it('markAllRead stamps unread rows only', async () => { const original = new Date('2026-08-29T18:00:00.000Z'); const store = new InMemoryNotificationStore([ @@ -199,6 +208,20 @@ describe('InMemoryNotificationStore', () => { expect((await store.getByIdForRecipient('other', 'other'))?.readAt).toBeNull(); }); + it('markAllRead skips moderator_proposal and stamps other unread types', async () => { + const store = new InMemoryNotificationStore([ + row({ id: 'reply', type: 'forum_reply', replyId: 'r-unread' }), + row({ id: 'proposal', type: 'moderator_proposal', replyId: 'subject' }), + row({ id: 'other', recipientAccountId: 'other', replyId: 'r-other' }), + ]); + await store.markAllRead('parent', READ_AT); + expect((await store.getByIdForRecipient('reply', 'parent'))?.readAt?.toISOString()).toBe( + READ_AT.toISOString(), + ); + expect((await store.getByIdForRecipient('proposal', 'parent'))?.readAt).toBeNull(); + expect((await store.getByIdForRecipient('other', 'other'))?.readAt).toBeNull(); + }); + it('unique create returns the existing id', async () => { const store = new InMemoryNotificationStore(); const first = await store.create(row({ id: 'first' })); @@ -237,6 +260,19 @@ describe('InMemoryNotificationStore', () => { expect(await store.deleteByMessageIds(['hit'])).toBe(3); expect((await store.listByRecipient('parent', 10)).map((item) => item.id)).toEqual(['neither']); }); + + it('deleteByTypeAndReplyId removes matching type and replyId and returns the count', async () => { + const store = new InMemoryNotificationStore([ + row({ id: 'hit', type: 'moderator_proposal', replyId: 'subject' }), + row({ id: 'wrong-type', type: 'moderator_appointed', replyId: 'subject' }), + row({ id: 'wrong-reply', type: 'moderator_proposal', replyId: 'other' }), + ]); + expect(await store.deleteByTypeAndReplyId('moderator_proposal', 'subject')).toBe(1); + expect((await store.listByRecipient('parent', 10)).map((item) => item.id).sort()).toEqual([ + 'wrong-reply', + 'wrong-type', + ]); + }); }); describe('PostgresNotificationStore', () => { @@ -393,6 +429,7 @@ describe('PostgresNotificationStore', () => { const marked = await new PostgresNotificationStore(sql).markRead('n-1', 'parent', READ_AT); expect(sql.executes).toHaveLength(0); expect(sql.queries[0]?.text).toMatch(/UPDATE notification SET read_at/); + expect(sql.queries[0]?.text).toMatch(/type <> 'moderator_proposal'/); expect(sql.queries[0]?.text).toMatch(/RETURNING/); expect(sql.queries[0]?.params).toEqual(['n-1', 'parent', READ_AT]); expect(marked?.readAt).toEqual(READ_AT); @@ -429,6 +466,7 @@ describe('PostgresNotificationStore', () => { await new PostgresNotificationStore(sql).markAllRead('parent', READ_AT); expect(sql.executes).toHaveLength(1); expect(sql.executes[0]?.text).toMatch(/UPDATE notification SET read_at = \$2/); + expect(sql.executes[0]?.text).toMatch(/type <> 'moderator_proposal'/); expect(sql.executes[0]?.params).toEqual(['parent', READ_AT]); }); @@ -464,4 +502,19 @@ describe('PostgresNotificationStore', () => { ]); expect(sql.executes).toEqual([]); }); + + it('deleteByTypeAndReplyId deletes by type and reply_id', async () => { + const sql = new MockSql(); + sql.nextRows = [{ id: 'n-1' }, { id: 'n-2' }]; + const removed = await new PostgresNotificationStore(sql).deleteByTypeAndReplyId( + 'moderator_proposal', + 'r-1', + ); + expect(removed).toBe(2); + expect(sql.queries[0]?.text).toBe( + `DELETE FROM notification WHERE type = $1 AND reply_id = $2 RETURNING id`, + ); + expect(sql.queries[0]?.params).toEqual(['moderator_proposal', 'r-1']); + expect(sql.executes).toEqual([]); + }); }); diff --git a/src/__tests__/lib/notification.test.ts b/src/__tests__/lib/notification.test.ts index 97df49a0d..a13470d1c 100644 --- a/src/__tests__/lib/notification.test.ts +++ b/src/__tests__/lib/notification.test.ts @@ -9,6 +9,7 @@ import { notifyForumPost, notifyForumReply, notifyModeratorAppointed, + notifyModeratorProposed, notificationsMatchingLevel, notifyZap, parseNotificationLevel, @@ -1447,6 +1448,122 @@ describe('notifyModeratorAppointed', () => { }); }); +describe('notifyModeratorProposed', () => { + it('is a no-op when both stores are omitted', async () => { + await expect( + notifyModeratorProposed({ + recipients: [{ id: 'founder', role: 'founder' }], + subject: { id: 'subject', name: 'Sub' }, + actor: { id: 'actor', name: 'Mod' }, + nowMs: NOW.getTime(), + }), + ).resolves.toBeUndefined(); + }); + + it('creates in-app rows for staff except the actor and isPlatform', async () => { + const notifications = new InMemoryNotificationStore(); + const founder = { id: 'founder', role: 'founder' as const }; + const actor = { id: 'actor', role: 'moderator' as const }; + const platform = { id: 'platform', role: 'moderator' as const, isPlatform: true }; + const basis = { id: 'basis', role: 'basis' as const }; + const subject = { id: 'subject', role: 'verified' as const }; + await notifyModeratorProposed({ + notifications, + recipients: [founder, actor, platform, basis, subject], + subject: { id: subject.id, name: 'Sub' }, + actor: { id: actor.id, name: 'Mod' }, + nowMs: NOW.getTime(), + }); + const listed = await notifications.listByRecipient(founder.id, 10); + expect(listed).toHaveLength(1); + expect(listed[0]?.type).toBe('moderator_proposal'); + expect(listed[0]?.parentId).toBe(subject.id); + expect(listed[0]?.replyId).toBe(subject.id); + expect(listed[0]?.name).toBe('Mod'); + expect(listed[0]?.text).toBe('Sub'); + expect(listed[0]?.readAt).toBeNull(); + expect(await notifications.listByRecipient(actor.id, 10)).toEqual([]); + expect(await notifications.listByRecipient(platform.id, 10)).toEqual([]); + expect(await notifications.listByRecipient(basis.id, 10)).toEqual([]); + expect(await notifications.listByRecipient(subject.id, 10)).toEqual([]); + }); + + it('defaults missing actor and subject names', async () => { + const notifications = new InMemoryNotificationStore(); + await notifyModeratorProposed({ + notifications, + recipients: [{ id: 'founder', role: 'founder' }], + subject: { id: 'subject', name: null }, + actor: { id: 'actor', name: null }, + nowMs: NOW.getTime(), + }); + const listed = await notifications.listByRecipient('founder', 10); + expect(listed[0]?.name).toBe('Someone'); + expect(listed[0]?.text).toBe(''); + }); + + it('throws push.fanout.failed when create rejects', async () => { + const notifications = new InMemoryNotificationStore(); + notifications.create = async () => { + throw new Error('boom'); + }; + await expect( + notifyModeratorProposed({ + notifications, + recipients: [{ id: 'founder', role: 'founder' }], + subject: { id: 'subject', name: 'Sub' }, + actor: { id: 'actor', name: 'Mod' }, + nowMs: NOW.getTime(), + }), + ).rejects.toThrow('push.fanout.failed'); + }); + + it('enqueues without unreadCount when only pushStore is set', async () => { + const pushStore = new InMemoryPushStore(); + await notifyModeratorProposed({ + pushStore, + recipients: [{ id: 'founder', role: 'founder' }], + subject: { id: 'subject', name: 'Sub' }, + actor: { id: 'actor', name: 'Mod' }, + nowMs: NOW.getTime(), + }); + const claimed = await pushStore.claimPending(10, NOW.getTime(), 60_000); + expect(payloadObject(claimed[0]?.payload ?? '{}')['unreadCount']).toBeUndefined(); + }); + + it('includes inbox unread in the push payload', async () => { + const pushStore = new InMemoryPushStore(); + await notifyModeratorProposed({ + pushStore, + inboxUnreadCount: async () => 3, + recipients: [{ id: 'founder', role: 'founder' }], + subject: { id: 'subject', name: 'Sub' }, + actor: { id: 'actor', name: 'Mod' }, + nowMs: NOW.getTime(), + }); + const claimed = await pushStore.claimPending(10, NOW.getTime(), 60_000); + expect(payloadObject(claimed[0]?.payload ?? '{}')['unreadCount']).toBe(3); + }); + + it('throws push.fanout.failed when enqueue rejects', async () => { + const notifications = new InMemoryNotificationStore(); + const pushStore = new InMemoryPushStore(); + pushStore.enqueue = async () => { + throw new Error('boom'); + }; + await expect( + notifyModeratorProposed({ + notifications, + pushStore, + recipients: [{ id: 'founder', role: 'founder' }], + subject: { id: 'subject', name: 'Sub' }, + actor: { id: 'actor', name: 'Mod' }, + nowMs: NOW.getTime(), + }), + ).rejects.toThrow('push.fanout.failed'); + }); +}); + describe('parseNotificationLevel', () => { it('round-trips known levels and defaults unknown values to all', () => { expect(parseNotificationLevel('all')).toBe('all'); @@ -1611,6 +1728,27 @@ describe('notificationsMatchingLevel', () => { ).toEqual(['n-appoint']); }); + it('keeps moderator_proposal at mentions', () => { + const rows: NotificationRow[] = [ + notification({ + id: 'n-propose', + recipientAccountId: 'me', + type: 'moderator_proposal', + parentId: 'subject', + replyId: 'subject', + }), + ]; + expect( + notificationsMatchingLevel({ + rows, + level: 'mentions', + recipientAccountId: 'me', + accounts: [], + parentById: new Map(), + }).map((row) => row.id), + ).toEqual(['n-propose']); + }); + it('returns every row at all including an unpaid non-staff forum_post', () => { const rows: NotificationRow[] = [ notification({ diff --git a/src/__tests__/lib/trust-store.test.ts b/src/__tests__/lib/trust-store.test.ts index e87414096..1e28a5359 100644 --- a/src/__tests__/lib/trust-store.test.ts +++ b/src/__tests__/lib/trust-store.test.ts @@ -14,12 +14,17 @@ class MockSql implements SqlClient { nextRows: unknown[] = []; queryError: unknown | undefined; executeError: unknown | undefined; + queryImpl: ((text: string) => unknown[] | undefined) | undefined; async query(text: string, params: readonly unknown[] = []): Promise { this.queries.push({ text, params }); if (this.queryError !== undefined) { throw this.queryError; } + const override = this.queryImpl?.(text); + if (override !== undefined) { + return override as T[]; + } return this.nextRows as T[]; } @@ -64,15 +69,23 @@ const TIE_LOW: TrustEdge = { }; describe('TRUST_SCHEMA_SQL', () => { - it('creates trust_edge, the subject-kind unique index, and the actor index', () => { - expect(TRUST_SCHEMA_SQL).toHaveLength(3); + it('creates trust_edge, live unique index, kind check, and the actor index', () => { + expect(TRUST_SCHEMA_SQL).toHaveLength(6); expect(TRUST_SCHEMA_SQL[0]).toMatch(/CREATE TABLE IF NOT EXISTS trust_edge/i); expect(TRUST_SCHEMA_SQL[0]).toMatch(/subject_id uuid NOT NULL REFERENCES account/i); expect(TRUST_SCHEMA_SQL[0]).toMatch(/CHECK \(subject_id <> actor_id\)/); - expect(TRUST_SCHEMA_SQL[1]).toMatch( - /CREATE UNIQUE INDEX IF NOT EXISTS trust_edge_subject_kind_uidx/, + expect(TRUST_SCHEMA_SQL[0]).toMatch(/moderator_reject/); + expect(TRUST_SCHEMA_SQL[1]).toMatch(/DROP CONSTRAINT IF EXISTS trust_edge_kind_check/); + expect(TRUST_SCHEMA_SQL[2]).toMatch(/ADD CONSTRAINT trust_edge_kind_check/); + expect(TRUST_SCHEMA_SQL[2]).toMatch(/moderator_reject/); + expect(TRUST_SCHEMA_SQL[3]).toMatch(/DROP INDEX IF EXISTS trust_edge_subject_kind_uidx/); + expect(TRUST_SCHEMA_SQL[4]).toMatch( + /CREATE UNIQUE INDEX IF NOT EXISTS trust_edge_subject_kind_live_uidx/, + ); + expect(TRUST_SCHEMA_SQL[4]).toMatch( + /WHERE kind IN \('verify', 'moderator_confirm', 'moderator_appoint'\)/, ); - expect(TRUST_SCHEMA_SQL[2]).toMatch(/CREATE INDEX IF NOT EXISTS trust_edge_actor_idx/); + expect(TRUST_SCHEMA_SQL[5]).toMatch(/CREATE INDEX IF NOT EXISTS trust_edge_actor_idx/); }); }); @@ -150,6 +163,30 @@ describe('InMemoryTrustStore', () => { ).rejects.toThrow('duplicate trust edge'); }); + it('inserts multiple moderator_propose and moderator_reject rows for one subject', async () => { + const store = new InMemoryTrustStore(); + await store.insertEdge({ ...LATE, id: 'p1', createdAt: 1 }); + await store.insertEdge({ ...LATE, id: 'p2', actorId: 'act-3', createdAt: 2 }); + await store.insertEdge({ + ...LATE, + id: 'r1', + kind: 'moderator_reject', + createdAt: 3, + }); + await store.insertEdge({ + ...LATE, + id: 'r2', + actorId: 'act-3', + kind: 'moderator_reject', + createdAt: 4, + }); + expect((await store.listEdges()).map((row) => row.id)).toEqual(['p1', 'p2', 'r1', 'r2']); + await store.insertEdge({ ...EARLY, id: 'v1' }); + await expect(store.insertEdge({ ...EARLY, id: 'v2', actorId: 'someone-else' })).rejects.toThrow( + 'duplicate trust edge', + ); + }); + it('deleteEdge removes the matching row and leaves others', async () => { const store = new InMemoryTrustStore([EARLY, LATE]); const removed = await store.deleteEdge('sub', 'verify'); @@ -158,11 +195,61 @@ describe('InMemoryTrustStore', () => { expect((await store.listEdges()).map((row) => row.id)).toEqual(['b']); }); + it('deleteEdgeById removes only that row', async () => { + const store = new InMemoryTrustStore([EARLY, LATE]); + const removed = await store.deleteEdgeById('b'); + expect(removed).toEqual(LATE); + expect((await store.listEdges()).map((row) => row.id)).toEqual(['a']); + expect(await store.deleteEdgeById('missing')).toBeUndefined(); + }); + it('deleteEdge returns undefined when no row matches', async () => { const store = new InMemoryTrustStore([EARLY]); expect(await store.deleteEdge('sub', 'moderator_confirm')).toBeUndefined(); expect((await store.listEdges()).map((row) => row.id)).toEqual(['a']); }); + + it('deleteEdge removes the latest moderator_propose and leaves the older', async () => { + const older: TrustEdge = { + id: 'p-old', + subjectId: 'sub', + actorId: 'act', + kind: 'moderator_propose', + createdAt: 1, + }; + const newer: TrustEdge = { + id: 'p-new', + subjectId: 'sub', + actorId: 'act-2', + kind: 'moderator_propose', + createdAt: 2, + }; + const store = new InMemoryTrustStore([older, newer]); + const removed = await store.deleteEdge('sub', 'moderator_propose'); + expect(removed).toEqual(newer); + expect((await store.listEdges()).map((row) => row.id)).toEqual(['p-old']); + }); + + it('deleteEdge prefers the higher id when createdAt ties', async () => { + const low: TrustEdge = { + id: 'p-a', + subjectId: 'sub', + actorId: 'act', + kind: 'moderator_propose', + createdAt: 5, + }; + const high: TrustEdge = { + id: 'p-z', + subjectId: 'sub', + actorId: 'act-2', + kind: 'moderator_propose', + createdAt: 5, + }; + const store = new InMemoryTrustStore([low, high]); + const removed = await store.deleteEdge('sub', 'moderator_propose'); + expect(removed).toEqual(high); + expect((await store.listEdges()).map((row) => row.id)).toEqual(['p-a']); + }); }); describe('PostgresTrustStore', () => { @@ -272,22 +359,30 @@ describe('PostgresTrustStore', () => { await expect(new PostgresTrustStore(sql).insertEdge(EARLY)).rejects.toBeNull(); }); - it('deleteEdge uses DELETE RETURNING and maps the row', async () => { + it('deleteEdge selects the latest row then DELETE WHERE id', async () => { const sql = new MockSql(); - sql.nextRows = [ - { - id: 'e1', - subject_id: 'sub', - actor_id: 'act', - kind: 'moderator_confirm', - created_at: new Date('2026-08-28T12:00:00.000Z'), - }, - ]; + const mapped = { + id: 'e1', + subject_id: 'sub', + actor_id: 'act', + kind: 'moderator_confirm', + created_at: new Date('2026-08-28T12:00:00.000Z'), + }; + sql.queryImpl = (text) => { + if (text.includes('DELETE')) { + return [mapped]; + } + return [mapped]; + }; const removed = await new PostgresTrustStore(sql).deleteEdge('sub', 'moderator_confirm'); expect(sql.queries[0]?.text).toMatch( - /DELETE FROM trust_edge WHERE subject_id = \$1 AND kind = \$2 RETURNING id, subject_id, actor_id, kind, created_at/, + /SELECT id, subject_id, actor_id, kind, created_at FROM trust_edge WHERE subject_id = \$1 AND kind = \$2 ORDER BY created_at DESC, id DESC LIMIT 1/, ); expect(sql.queries[0]?.params).toEqual(['sub', 'moderator_confirm']); + expect(sql.queries[1]?.text).toMatch( + /DELETE FROM trust_edge WHERE id = \$1 RETURNING id, subject_id, actor_id, kind, created_at/, + ); + expect(sql.queries[1]?.params).toEqual(['e1']); expect(removed).toEqual({ id: 'e1', subjectId: 'sub', @@ -297,10 +392,55 @@ describe('PostgresTrustStore', () => { }); }); - it('deleteEdge returns undefined when RETURNING is empty', async () => { + it('deleteEdge returns undefined when SELECT is empty', async () => { const sql = new MockSql(); sql.nextRows = []; expect(await new PostgresTrustStore(sql).deleteEdge('sub', 'verify')).toBeUndefined(); + expect(sql.queries).toHaveLength(1); + expect(sql.queries[0]?.text).toMatch(/ORDER BY created_at DESC, id DESC LIMIT 1/); + }); + + it('deleteEdge returns undefined when DELETE RETURNING is empty', async () => { + const sql = new MockSql(); + sql.queryImpl = (text) => { + if (text.includes('DELETE')) { + return []; + } + return [ + { + id: 'e1', + subject_id: 'sub', + actor_id: 'act', + kind: 'verify', + created_at: new Date('2026-08-28T12:00:00.000Z'), + }, + ]; + }; + expect(await new PostgresTrustStore(sql).deleteEdge('sub', 'verify')).toBeUndefined(); + expect(sql.queries[1]?.params).toEqual(['e1']); + }); + + it('deleteEdgeById DELETEs WHERE id', async () => { + const sql = new MockSql(); + sql.nextRows = [ + { + id: 'e1', + subject_id: 'sub', + actor_id: 'act', + kind: 'moderator_propose', + created_at: new Date('2026-08-28T12:00:00.000Z'), + }, + ]; + const removed = await new PostgresTrustStore(sql).deleteEdgeById('e1'); + expect(sql.queries[0]?.text).toMatch(/DELETE FROM trust_edge WHERE id = \$1/); + expect(sql.queries[0]?.params).toEqual(['e1']); + expect(removed?.id).toBe('e1'); + }); + + it('deleteEdgeById returns undefined when DELETE RETURNING is empty', async () => { + const sql = new MockSql(); + sql.nextRows = []; + expect(await new PostgresTrustStore(sql).deleteEdgeById('e1')).toBeUndefined(); }); it('propagates list query errors', async () => { diff --git a/src/__tests__/lib/trust.test.ts b/src/__tests__/lib/trust.test.ts index 35101e77b..acd5f4cb1 100644 --- a/src/__tests__/lib/trust.test.ts +++ b/src/__tests__/lib/trust.test.ts @@ -105,6 +105,15 @@ describe('isProjectedTrustEdge', () => { ).toBe(false); }); + it('omits moderator_reject even when the subject is a moderator', () => { + expect( + isProjectedTrustEdge( + edge({ id: 'e', subjectId: 'm', actorId: 'f', kind: 'moderator_reject' }), + account({ id: 'm', role: 'moderator' }), + ), + ).toBe(false); + }); + it('omits appoint when an older verify sibling exists for a moderator', () => { const subject = account({ id: 'm', role: 'moderator' }); const verify = edge({ @@ -210,6 +219,32 @@ describe('isProjectedTrustEdge', () => { const appoint = edge({ id: 'e-a', subjectId: 'm', actorId: 'f', kind: 'moderator_appoint' }); expect(isProjectedTrustEdge(appoint, subject, [appoint])).toBe(true); }); + + it('projects only the oldest propose when several propose rows exist', () => { + const subject = account({ id: 'm', role: 'moderator' }); + const first = edge({ + id: 'e-p1', + subjectId: 'm', + actorId: 'a', + kind: 'moderator_propose', + createdAt: 1, + }); + const second = edge({ + id: 'e-p2', + subjectId: 'm', + actorId: 'b', + kind: 'moderator_propose', + createdAt: 2, + }); + const siblings = [first, second]; + expect(isProjectedTrustEdge(first, subject, siblings)).toBe(true); + expect(isProjectedTrustEdge(second, subject, siblings)).toBe(false); + const chainActors = new Set(['b']); + expect(isProjectedTrustEdge(first, subject, siblings, chainActors)).toBe(false); + expect(isProjectedTrustEdge(second, subject, siblings, chainActors)).toBe(true); + expect(isProjectedTrustEdge(first, subject, siblings, new Set())).toBe(false); + expect(isProjectedTrustEdge(second, subject, siblings, new Set())).toBe(false); + }); }); describe('buildTrustChain', () => { @@ -387,6 +422,7 @@ describe('pendingModeratorProposals', () => { ]; expect(pendingModeratorProposals([subject, actor], edges)).toEqual([ { + id: 'p1', subject: { id: 's', name: 'Ada', role: 'verified' }, proposedBy: { id: 'm', name: 'Mod' }, createdAt: 10, @@ -406,6 +442,7 @@ describe('pendingModeratorProposals', () => { }); expect(pendingModeratorProposals([subject, actor], [propose])).toEqual([ { + id: 'p1', subject: { id: 's', name: null, role: 'verified' }, proposedBy: { id: 'm', name: 'Mod' }, createdAt: 10, @@ -457,6 +494,7 @@ describe('pendingModeratorProposals', () => { }); expect(pendingModeratorProposals([subject], [propose])).toEqual([ { + id: 'p1', subject: { id: 's', name: 'Ada', role: 'verified' }, proposedBy: { id: 'missing', name: null }, createdAt: 4, @@ -536,6 +574,7 @@ describe('pendingModeratorProposals', () => { ]; expect(pendingModeratorProposals([subject, ada, bob], edges)).toEqual([ { + id: 'p-new', subject: { id: 's', name: 'Ada', role: 'verified' }, proposedBy: { id: 'bob', name: 'Bob' }, createdAt: 2, @@ -570,6 +609,55 @@ describe('pendingModeratorProposals', () => { pendingModeratorProposals([one, two, actor], edges).map((row) => row.subject.id), ).toEqual(['s1', 's2']); }); + + it('is empty after propose t1 then reject t2, then pending again on propose t3', () => { + const subject = account({ id: 's', role: 'verified', name: 'Ada' }); + const first = account({ id: 'm', role: 'moderator', name: 'Mod' }); + const second = account({ id: 'f', role: 'founder', name: 'Founder' }); + const propose1 = edge({ + id: 'p1', + subjectId: 's', + actorId: 'm', + kind: 'moderator_propose', + createdAt: 1, + }); + const reject = edge({ + id: 'r1', + subjectId: 's', + actorId: 'f', + kind: 'moderator_reject', + createdAt: 2, + }); + const propose3 = edge({ + id: 'p3', + subjectId: 's', + actorId: 'f', + kind: 'moderator_propose', + createdAt: 3, + }); + expect(pendingModeratorProposals([subject, first, second], [propose1, reject])).toEqual([]); + expect( + pendingModeratorProposals([subject, first, second], [propose1, reject, propose3]), + ).toEqual([ + { + id: 'p3', + subject: { id: 's', name: 'Ada', role: 'verified' }, + proposedBy: { id: 'f', name: 'Founder' }, + createdAt: 3, + }, + ]); + }); + + it('stays closed after confirm even when a later propose exists', () => { + const subject = account({ id: 's', role: 'verified', name: 'Ada' }); + const actor = account({ id: 'm', role: 'moderator', name: 'Mod' }); + const edges: TrustEdge[] = [ + edge({ id: 'p1', subjectId: 's', actorId: 'm', kind: 'moderator_propose', createdAt: 1 }), + edge({ id: 'c1', subjectId: 's', actorId: 'f', kind: 'moderator_confirm', createdAt: 2 }), + edge({ id: 'p2', subjectId: 's', actorId: 'm', kind: 'moderator_propose', createdAt: 3 }), + ]; + expect(pendingModeratorProposals([subject, actor], edges)).toEqual([]); + }); }); describe('serializeTrustEdge', () => { diff --git a/src/__tests__/routes/debug-trust.test.ts b/src/__tests__/routes/debug-trust.test.ts index c5eb09852..cc436c621 100644 --- a/src/__tests__/routes/debug-trust.test.ts +++ b/src/__tests__/routes/debug-trust.test.ts @@ -127,6 +127,7 @@ describe('GET /debug/trust-edges', () => { listEdgesTouching: async () => [], insertEdge: async (row) => row, deleteEdge: async () => undefined, + deleteEdgeById: async () => undefined, }; const res = await mount(new InMemoryAuthStore(), throwing).request('/debug/trust-edges', { headers: { authorization: 'Bearer secret' }, @@ -278,7 +279,29 @@ describe('POST /debug/trust-edges', () => { ).toBe(true); }); - it('returns 409 on a duplicate (subjectId, kind)', async () => { + it('returns 200 on a repeated moderator_reject and leaves role unchanged', async () => { + const store = await seeded(); + const trustStore = new InMemoryTrustStore(); + const app = mount(store, trustStore); + const first = await post(app, 'secret', { + subjectId: SUBJECT, + actorId: ACTOR, + kind: 'moderator_reject', + }); + expect(first.status).toBe(200); + const second = await post(app, 'secret', { + subjectId: SUBJECT, + actorId: ACTOR, + kind: 'moderator_reject', + }); + expect(second.status).toBe(200); + expect( + (await trustStore.listEdges()).filter((row) => row.kind === 'moderator_reject'), + ).toHaveLength(2); + expect((await store.getAccount(SUBJECT))?.role).toBe('basis'); + }); + + it('returns 200 on a repeated moderator_propose and leaves role unchanged', async () => { const store = await seeded(); const trustStore = new InMemoryTrustStore(); const app = mount(store, trustStore); @@ -293,10 +316,35 @@ describe('POST /debug/trust-edges', () => { actorId: ACTOR, kind: 'moderator_propose', }); - expect(second.status).toBe(409); - expect(await second.json()).toEqual({ error: 'Conflict' }); + expect(second.status).toBe(200); + expect( + (await trustStore.listEdges()).filter((row) => row.kind === 'moderator_propose'), + ).toHaveLength(2); + expect((await store.getAccount(SUBJECT))?.role).toBe('basis'); }); + it.each(['verify', 'moderator_confirm', 'moderator_appoint'] as const)( + 'returns 409 on a duplicate (subjectId, %s)', + async (kind) => { + const store = await seeded(); + const trustStore = new InMemoryTrustStore(); + const app = mount(store, trustStore); + const first = await post(app, 'secret', { + subjectId: SUBJECT, + actorId: ACTOR, + kind, + }); + expect(first.status).toBe(200); + const second = await post(app, 'secret', { + subjectId: SUBJECT, + actorId: ACTOR, + kind, + }); + expect(second.status).toBe(409); + expect(await second.json()).toEqual({ error: 'Conflict' }); + }, + ); + it('returns 503 when getAccount throws', async () => { const store = await seeded(); const inner = store.getAccount.bind(store); @@ -327,6 +375,7 @@ describe('POST /debug/trust-edges', () => { throw new Error('boom'); }, deleteEdge: async () => undefined, + deleteEdgeById: async () => undefined, }; const res = await post(mount(await seeded(), throwing), 'secret', { subjectId: SUBJECT, @@ -439,6 +488,7 @@ describe('DELETE /debug/trust-edges', () => { deleteEdge: async () => { throw new Error('boom'); }, + deleteEdgeById: async () => undefined, }; const res = await del(mount(await seeded(), throwing), 'secret', { subjectId: SUBJECT, diff --git a/src/__tests__/routes/notifications.test.ts b/src/__tests__/routes/notifications.test.ts index 8f218a870..b88e11a7a 100644 --- a/src/__tests__/routes/notifications.test.ts +++ b/src/__tests__/routes/notifications.test.ts @@ -329,6 +329,31 @@ describe('GET /notifications', () => { expect(body.notifications[0]?.['type']).toBe('moderator_appointed'); }); + it('keeps moderator_proposal when parent and reply are missing', async () => { + const proposalId = '10101010-1010-4101-8101-101010101010'; + const accountId = '88888888-8888-4888-8888-888888888888'; + const store = new InMemoryNotificationStore([ + note({ + id: proposalId, + type: 'moderator_proposal', + parentId: accountId, + replyId: accountId, + text: 'Sub', + }), + ]); + const res = await mount(await seeded(), store, new InMemoryMessageStore()).request( + '/notifications', + { headers: AUTH }, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + notifications: Array>; + }; + expect(body.notifications).toHaveLength(1); + expect(body.notifications[0]?.['id']).toBe(proposalId); + expect(body.notifications[0]?.['type']).toBe('moderator_proposal'); + }); + it('reuses the message lookup when two live replies share a parent', async () => { const parentLive = '11111111-1111-4111-8111-111111111111'; const replyA = '22222222-2222-4222-8222-222222222222'; @@ -597,6 +622,29 @@ describe('POST /notifications/read-all', () => { expect(await store.unreadCount('acc')).toBe(0); }); + it('leaves moderator_proposal unread when marking all read', async () => { + const proposalId = '10101010-1010-4101-8101-101010101010'; + const accountId = '88888888-8888-4888-8888-888888888888'; + const store = new InMemoryNotificationStore([ + note({ id: ID_A }), + note({ + id: proposalId, + type: 'moderator_proposal', + parentId: accountId, + replyId: accountId, + text: 'Sub', + }), + ]); + const res = await mount(await seeded(), store).request('/notifications/read-all', { + method: 'POST', + headers: AUTH, + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + expect((await store.getByIdForRecipient(ID_A, 'acc'))?.readAt?.toISOString()).toBe(READ_ISO); + expect((await store.getByIdForRecipient(proposalId, 'acc'))?.readAt).toBeNull(); + }); + it('is 200 not 404 (mount order)', async () => { const res = await mount(await seeded()).request('/notifications/read-all', { method: 'POST', @@ -649,6 +697,28 @@ describe('POST /notifications/:id/read', () => { expect((await second.json()) as { readAt: string }).toEqual({ ...body }); }); + it('returns 200 with readAt still null for a moderator_proposal', async () => { + const proposalId = '10101010-1010-4101-8101-101010101010'; + const accountId = '88888888-8888-4888-8888-888888888888'; + const store = new InMemoryNotificationStore([ + note({ + id: proposalId, + type: 'moderator_proposal', + parentId: accountId, + replyId: accountId, + text: 'Sub', + }), + ]); + const res = await mount(await seeded(), store).request(`/notifications/${proposalId}/read`, { + method: 'POST', + headers: AUTH, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { readAt: string | null }; + expect(body.readAt).toBeNull(); + expect((await store.getByIdForRecipient(proposalId, 'acc'))?.readAt).toBeNull(); + }); + it('returns 404 for unknown, other-account, and non-uuid ids', async () => { const store = new InMemoryNotificationStore([ note({ id: ID_A }), diff --git a/src/__tests__/routes/trust-chain.test.ts b/src/__tests__/routes/trust-chain.test.ts index 95cf48f06..4c43d5149 100644 --- a/src/__tests__/routes/trust-chain.test.ts +++ b/src/__tests__/routes/trust-chain.test.ts @@ -162,6 +162,55 @@ describe('GET /trust-chain', () => { }); }); + it('skips a missing oldest sibling actor so a later chain contact can show', async () => { + const authStore = new InMemoryAuthStore(); + await authStore.createAccount(account({ id: 'f', role: 'founder', name: 'F', createdAt: 1 })); + await authStore.createAccount(account({ id: 'm', role: 'moderator', name: 'M', createdAt: 2 })); + await authStore.createAccount(account({ id: 'v', role: 'verified', name: 'V', createdAt: 3 })); + await signIn(authStore, 'f'); + const edges: TrustEdge[] = [ + { id: 'e-ghost', subjectId: 'v', actorId: 'ghost', kind: 'verify', createdAt: 10 }, + { id: 'e-mod', subjectId: 'v', actorId: 'm', kind: 'verify', createdAt: 11 }, + ]; + const res = await mount(authStore, new InMemoryTrustStore(edges)).request( + '/trust-chain?around=m', + { headers: AUTH }, + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + nodes: [ + { id: 'm', name: 'M', role: 'moderator' }, + { id: 'v', name: 'V', role: 'verified' }, + ], + edges: [{ from: 'm', to: 'v', kind: 'verify' }], + }); + }); + + it('skips a non-chain oldest verify so a later chain contact can show', async () => { + const authStore = new InMemoryAuthStore(); + await authStore.createAccount(account({ id: 'f', role: 'founder', name: 'F', createdAt: 1 })); + await authStore.createAccount(account({ id: 'm', role: 'moderator', name: 'M', createdAt: 2 })); + await authStore.createAccount(account({ id: 'v', role: 'verified', name: 'V', createdAt: 3 })); + await authStore.createAccount(account({ id: 'b', role: 'basis', name: 'B', createdAt: 0 })); + await signIn(authStore, 'f'); + const edges: TrustEdge[] = [ + { id: 'e-basis', subjectId: 'v', actorId: 'b', kind: 'verify', createdAt: 10 }, + { id: 'e-mod', subjectId: 'v', actorId: 'm', kind: 'verify', createdAt: 11 }, + ]; + const res = await mount(authStore, new InMemoryTrustStore(edges)).request( + '/trust-chain?around=v', + { headers: AUTH }, + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + nodes: [ + { id: 'm', name: 'M', role: 'moderator' }, + { id: 'v', name: 'V', role: 'verified' }, + ], + edges: [{ from: 'm', to: 'v', kind: 'verify' }], + }); + }); + it('omits founder appoint when the subject was verified by someone else', async () => { const authStore = new InMemoryAuthStore(); await authStore.createAccount(account({ id: 'f', role: 'founder', name: 'F', createdAt: 1 })); @@ -278,6 +327,7 @@ describe('GET /trust-chain', () => { }, insertEdge: async (row) => row, deleteEdge: async () => undefined, + deleteEdgeById: async () => undefined, }; const res = await mount(authStore, throwing).request('/trust-chain?around=f', { headers: AUTH, diff --git a/src/__tests__/routes/trust.test.ts b/src/__tests__/routes/trust.test.ts index 78ca8429c..293d181b6 100644 --- a/src/__tests__/routes/trust.test.ts +++ b/src/__tests__/routes/trust.test.ts @@ -54,14 +54,18 @@ async function staffed( function mount( authStore: InMemoryAuthStore, trustStore: TrustStore, - extras: { notificationStore?: NotificationStore; pushStore?: PushStore } = {}, + extras: { + notificationStore?: NotificationStore; + pushStore?: PushStore; + now?: () => number; + } = {}, ): Hono { return new Hono().route( '/trust', trustRoutes({ authStore, trustStore, - now, + now: extras.now ?? now, ...(extras.notificationStore === undefined ? {} : { notificationStore: extras.notificationStore }), @@ -122,6 +126,7 @@ const throwingList: TrustStore = { }, insertEdge: async (row) => row, deleteEdge: async () => undefined, + deleteEdgeById: async () => undefined, }; const throwingListEdges: TrustStore = { @@ -132,6 +137,7 @@ const throwingListEdges: TrustStore = { listEdgesForSubject: async () => [], insertEdge: async (row) => row, deleteEdge: async () => undefined, + deleteEdgeById: async () => undefined, }; const duplicateInsert: TrustStore = { @@ -142,6 +148,7 @@ const duplicateInsert: TrustStore = { throw new Error('duplicate trust edge'); }, deleteEdge: async () => undefined, + deleteEdgeById: async () => undefined, }; const boomInsert: TrustStore = { @@ -152,6 +159,7 @@ const boomInsert: TrustStore = { throw new Error('insert boom'); }, deleteEdge: async () => undefined, + deleteEdgeById: async () => undefined, }; describe('POST /trust/*', () => { @@ -549,58 +557,1728 @@ describe('POST /trust/*', () => { ).toBe(true); }); - it('does not create a notification on propose 200', async () => { + it('returns 409 when a concurrent older propose already exists after insert', async () => { const { authStore, trustStore } = await staffed([ account({ id: SUBJECT, role: 'verified', name: 'Sub' }), ]); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 2) { + return rows; + } + return [ + { + id: 'other-p', + subjectId: OTHER, + actorId: MOD, + kind: 'moderator_propose', + createdAt: 1, + }, + { + id: 'r-new', + subjectId: SUBJECT, + actorId: FOUNDER, + kind: 'moderator_reject', + createdAt: 2, + }, + { + id: 'r-old', + subjectId: SUBJECT, + actorId: FOUNDER, + kind: 'moderator_reject', + createdAt: 2, + }, + { + id: 'a-propose', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose', + createdAt: 2, + }, + { + id: 'ffffffff-ffff-4fff-8fff-ffffffffffff', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose', + createdAt: 2_000_000_000_000, + }, + { + id: '88888888-8888-4888-8888-888888888888', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose', + createdAt: 1_700_000_000_000, + }, + { + id: '00000000-0000-4000-8000-000000000001', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose', + createdAt: 1_700_000_000_000, + }, + { + id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose', + createdAt: 1_700_000_000_000, + }, + ...rows, + ]; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post(mount(authStore, store), '/trust/propose-moderator', 'founder', { + accountId: SUBJECT, + }); + expect(res.status).toBe(409); + }); + + it('returns 409 when a same-timestamp older propose wins on id', async () => { + const { authStore, trustStore } = await staffed([ + account({ id: SUBJECT, role: 'verified', name: 'Sub' }), + ]); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 2) { + return rows; + } + return [ + { + id: 'r-new', + subjectId: SUBJECT, + actorId: FOUNDER, + kind: 'moderator_reject', + createdAt: 2, + }, + { + id: 'z-propose', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose', + createdAt: 2, + }, + ...rows, + ]; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post(mount(authStore, store), '/trust/propose-moderator', 'founder', { + accountId: SUBJECT, + }); + expect(res.status).toBe(409); + }); + + it('returns 503 when listing after propose insert throws', async () => { + const { authStore, trustStore } = await staffed([ + account({ id: SUBJECT, role: 'verified', name: 'Sub' }), + ]); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + if (lists >= 2) { + throw new Error('list boom'); + } + return trustStore.listEdgesForSubject(id); + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post(mount(authStore, store), '/trust/propose-moderator', 'founder', { + accountId: SUBJECT, + }); + expect(res.status).toBe(503); + expect((await trustStore.listEdges()).some((row) => row.kind === 'moderator_propose')).toBe( + false, + ); + }); + + it('keeps the oldest concurrent propose and drops a newer extra', async () => { + const { authStore, trustStore } = await staffed([ + account({ id: SUBJECT, role: 'verified', name: 'Sub' }), + ]); + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: (id) => trustStore.listEdgesForSubject(id), + insertEdge: async (row) => { + const stored = await trustStore.insertEdge(row); + await trustStore.insertEdge({ + id: 'newer', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose', + createdAt: 9_000_000_000_000, + }); + return stored; + }, + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post(mount(authStore, store), '/trust/propose-moderator', 'founder', { + accountId: SUBJECT, + }); + expect(res.status).toBe(200); + expect((await trustStore.listEdges()).map((row) => row.id)).not.toContain('newer'); + }); + + it('returns 409 when a concurrent confirm closed the grant after insert', async () => { + const { authStore, trustStore } = await staffed([ + account({ id: SUBJECT, role: 'verified', name: 'Sub' }), + ]); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 2) { + return rows; + } + return [ + ...rows, + { + id: 'confirm-race', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_confirm', + createdAt: 9_000_000_000_000, + }, + ]; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post(mount(authStore, store), '/trust/propose-moderator', 'founder', { + accountId: SUBJECT, + }); + expect(res.status).toBe(409); + expect((await trustStore.listEdges()).some((row) => row.kind === 'moderator_propose')).toBe( + false, + ); + }); + + it('returns 409 when the post-insert list has no remaining open propose', async () => { + const { authStore, trustStore } = await staffed([ + account({ id: SUBJECT, role: 'verified', name: 'Sub' }), + ]); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + if (lists < 2) { + return trustStore.listEdgesForSubject(id); + } + return [ + { + id: 'r-only', + subjectId: SUBJECT, + actorId: FOUNDER, + kind: 'moderator_reject', + createdAt: 9_000_000_000_000, + }, + ]; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post(mount(authStore, store), '/trust/propose-moderator', 'founder', { + accountId: SUBJECT, + }); + expect(res.status).toBe(409); + }); + + it('returns 409 when a concurrent older propose remains after extras are dropped', async () => { + const { authStore, trustStore } = await staffed([ + account({ id: SUBJECT, role: 'verified', name: 'Sub' }), + ]); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 2) { + return rows; + } + return [ + { + id: 'older', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose', + createdAt: 1, + }, + ...rows, + { + id: 'newer-extra', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose', + createdAt: 9_000_000_000_000, + }, + ]; + }, + insertEdge: async (row) => { + const stored = await trustStore.insertEdge(row); + await trustStore.insertEdge({ + id: 'newer-extra', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose', + createdAt: 9_000_000_000_000, + }); + return stored; + }, + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post(mount(authStore, store), '/trust/propose-moderator', 'founder', { + accountId: SUBJECT, + }); + expect(res.status).toBe(409); + }); + + it('refreshes proposal rows onto the oldest open propose after a lost concurrent propose', async () => { + const { authStore, trustStore } = await staffed([ + account({ id: SUBJECT, role: 'verified', name: 'Sub' }), + ]); + const notifications = new InMemoryNotificationStore(); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 2) { + return rows; + } + return [ + { + id: 'older', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose', + createdAt: 1, + }, + ...rows, + ]; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post( + mount(authStore, store, { notificationStore: notifications }), + '/trust/propose-moderator', + 'founder', + { accountId: SUBJECT }, + ); + expect(res.status).toBe(409); + expect(await notifications.listByRecipient(MOD, 10)).toEqual([]); + const founderRows = await notifications.listByRecipient(FOUNDER, 10); + expect(founderRows).toHaveLength(1); + expect(founderRows[0]?.actorAccountId).toBe(MOD); + expect(founderRows[0]?.name).toBe('Mod'); + }); + + it('still 409 when listing after a lost concurrent propose throws', async () => { + const { authStore, trustStore } = await staffed([ + account({ id: SUBJECT, role: 'verified', name: 'Sub' }), + ]); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + if (lists >= 3) { + throw new Error('list boom'); + } + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 2) { + return rows; + } + return [ + { + id: 'older', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose', + createdAt: 1, + }, + ...rows, + ]; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post(mount(authStore, store), '/trust/propose-moderator', 'founder', { + accountId: SUBJECT, + }); + expect(res.status).toBe(409); + expect(parsedEvents(warn).some((event) => event['event'] === 'push.enqueue.failed')).toBe( + true, + ); + }); + + it('drops proposal rows when a lost concurrent propose leaves no pending', async () => { + const { authStore, trustStore } = await staffed([ + account({ id: SUBJECT, role: 'verified', name: 'Sub' }), + ]); + const notifications = new InMemoryNotificationStore(); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 2) { + return rows; + } + if (lists === 2) { + return [ + { + id: 'older', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose', + createdAt: 1, + }, + ...rows, + ]; + } + return []; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post( + mount(authStore, store, { notificationStore: notifications }), + '/trust/propose-moderator', + 'founder', + { accountId: SUBJECT }, + ); + expect(res.status).toBe(409); + expect(await notifications.listByRecipient(FOUNDER, 10)).toEqual([]); + expect(await notifications.listByRecipient(MOD, 10)).toEqual([]); + }); + + it('still 200 when deleting a concurrent extra propose throws', async () => { + const { authStore, trustStore } = await staffed([ + account({ id: SUBJECT, role: 'verified', name: 'Sub' }), + ]); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 2) { + return rows; + } + return [ + ...rows, + { + id: 'newer-extra', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose', + createdAt: 9_000_000_000_000, + }, + ]; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: async (id) => { + if (id === 'newer-extra') { + throw new Error('delete boom'); + } + return trustStore.deleteEdgeById(id); + }, + }; + const res = await post(mount(authStore, store), '/trust/propose-moderator', 'founder', { + accountId: SUBJECT, + }); + expect(res.status).toBe(200); + expect((await trustStore.listEdges()).some((row) => row.kind === 'moderator_propose')).toBe( + true, + ); + expect(parsedEvents(warn).some((event) => event['event'] === 'trust.write.failed')).toBe( + true, + ); + }); + + it('fans out moderator_proposal to other staff on propose 200', async () => { + const { authStore, trustStore } = await staffed([ + account({ id: SUBJECT, role: 'verified', name: 'Sub' }), + ]); + const notifications = new InMemoryNotificationStore(); + const pushStore = new InMemoryPushStore(); + await subscribeSubjectActorAndOther(pushStore); + const res = await post( + mount(authStore, trustStore, { notificationStore: notifications, pushStore }), + '/trust/propose-moderator', + 'founder', + { accountId: SUBJECT }, + ); + expect(res.status).toBe(200); + expect((await authStore.getAccount(SUBJECT))?.role).toBe('verified'); + const listed = await notifications.listByRecipient(MOD, 10); + expect(listed).toHaveLength(1); + expect(listed[0]?.type).toBe('moderator_proposal'); + expect(listed[0]?.parentId).toBe(SUBJECT); + expect(listed[0]?.replyId).toBe(SUBJECT); + expect(listed[0]?.actorAccountId).toBe(FOUNDER); + expect(listed[0]?.name).toBe('Founder'); + expect(listed[0]?.text).toBe('Sub'); + expect(listed[0]?.readAt).toBeNull(); + expect(await notifications.listByRecipient(FOUNDER, 10)).toEqual([]); + expect(await notifications.listByRecipient(SUBJECT, 10)).toEqual([]); + expect(await notifications.listByRecipient(OTHER, 10)).toEqual([]); + const claimed = await pushStore.claimPending(10, now(), 60_000); + expect(claimed.map((row) => row.accountId)).toEqual([MOD]); + expect((JSON.parse(claimed[0]?.payload ?? '{}') as { url: string }).url).toBe( + '/moderate/proposals', + ); + }); + + it('clears proposal notifications when a concurrent reject lands during notify', async () => { + const { authStore, trustStore } = await staffed([ + account({ id: SUBJECT, role: 'verified', name: 'Sub' }), + ]); + const notifications = new InMemoryNotificationStore(); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 3) { + return rows; + } + return [ + ...rows, + { + id: 'reject-during-notify', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_reject', + createdAt: 9_000_000_000_000, + }, + ]; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post( + mount(authStore, store, { notificationStore: notifications }), + '/trust/propose-moderator', + 'founder', + { accountId: SUBJECT }, + ); + expect(res.status).toBe(200); + expect(await notifications.listByRecipient(MOD, 10)).toEqual([]); + }); + + it('refreshes proposal notifications when a newer propose reopened during notify', async () => { + const { authStore, trustStore } = await staffed([ + account({ id: SUBJECT, role: 'verified', name: 'Sub' }), + ]); + const notifications = new InMemoryNotificationStore(); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 3) { + return rows; + } + return [ + ...rows, + { + id: 'reject-during-notify', + subjectId: SUBJECT, + actorId: FOUNDER, + kind: 'moderator_reject', + createdAt: 9_000_000_000_000, + }, + { + id: 'p-reopen', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose', + createdAt: 9_000_000_000_001, + }, + ]; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post( + mount(authStore, store, { notificationStore: notifications }), + '/trust/propose-moderator', + 'founder', + { accountId: SUBJECT }, + ); + expect(res.status).toBe(200); + const listed = await notifications.listByRecipient(FOUNDER, 10); + expect(listed).toHaveLength(1); + expect(listed[0]?.actorAccountId).toBe(MOD); + expect(listed[0]?.name).toBe('Mod'); + expect(await notifications.listByRecipient(MOD, 10)).toEqual([]); + }); + + it('refreshes proposal notifications when the newer proposer account is missing', async () => { + const missing = '99999999-9999-4999-8999-999999999999'; + const { authStore, trustStore } = await staffed([ + account({ id: SUBJECT, role: 'verified', name: 'Sub' }), + ]); + const notifications = new InMemoryNotificationStore(); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 3) { + return rows; + } + return [ + ...rows, + { + id: 'reject-during-notify', + subjectId: SUBJECT, + actorId: FOUNDER, + kind: 'moderator_reject', + createdAt: 9_000_000_000_000, + }, + { + id: 'p-reopen-missing', + subjectId: SUBJECT, + actorId: missing, + kind: 'moderator_propose', + createdAt: 9_000_000_000_001, + }, + ]; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post( + mount(authStore, store, { notificationStore: notifications }), + '/trust/propose-moderator', + 'founder', + { accountId: SUBJECT }, + ); + expect(res.status).toBe(200); + const listed = await notifications.listByRecipient(MOD, 10); + expect(listed).toHaveLength(1); + expect(listed[0]?.actorAccountId).toBe(missing); + expect(listed[0]?.name).toBe('Someone'); + }); + + it('does not fan out when pending empties before the refresh notify', async () => { + const { authStore, trustStore } = await staffed([ + account({ id: SUBJECT, role: 'verified', name: 'Sub' }), + ]); + const notifications = new InMemoryNotificationStore(); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 3) { + return rows; + } + const closed = [ + ...rows, + { + id: 'reject-during-notify', + subjectId: SUBJECT, + actorId: FOUNDER, + kind: 'moderator_reject' as const, + createdAt: 9_000_000_000_000, + }, + ]; + if (lists === 3) { + return [ + ...closed, + { + id: 'p-reopen', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose' as const, + createdAt: 9_000_000_000_001, + }, + ]; + } + return closed; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post( + mount(authStore, store, { notificationStore: notifications }), + '/trust/propose-moderator', + 'founder', + { accountId: SUBJECT }, + ); + expect(res.status).toBe(200); + expect(await notifications.listByRecipient(FOUNDER, 10)).toEqual([]); + expect(await notifications.listByRecipient(MOD, 10)).toEqual([]); + }); + + it('drops refresh rows when pending empties after the refresh notify', async () => { + const { authStore, trustStore } = await staffed([ + account({ id: SUBJECT, role: 'verified', name: 'Sub' }), + ]); + const notifications = new InMemoryNotificationStore(); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 3) { + return rows; + } + const closed = [ + ...rows, + { + id: 'reject-during-notify', + subjectId: SUBJECT, + actorId: FOUNDER, + kind: 'moderator_reject' as const, + createdAt: 9_000_000_000_000, + }, + ]; + if (lists < 5) { + return [ + ...closed, + { + id: 'p-reopen', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose' as const, + createdAt: 9_000_000_000_001, + }, + ]; + } + return closed; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post( + mount(authStore, store, { notificationStore: notifications }), + '/trust/propose-moderator', + 'founder', + { accountId: SUBJECT }, + ); + expect(res.status).toBe(200); + expect(await notifications.listByRecipient(FOUNDER, 10)).toEqual([]); + }); + + it('refreshes again when pending id changes after fan-out notify', async () => { + const { authStore, trustStore } = await staffed([ + account({ id: SUBJECT, role: 'verified', name: 'Sub' }), + ]); + const notifications = new InMemoryNotificationStore(); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 3) { + return rows; + } + const closed = [ + ...rows, + { + id: 'reject-during-notify', + subjectId: SUBJECT, + actorId: FOUNDER, + kind: 'moderator_reject' as const, + createdAt: 9_000_000_000_000, + }, + ]; + if (lists < 5) { + return [ + ...closed, + { + id: 'p-reopen', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose' as const, + createdAt: 9_000_000_000_001, + }, + ]; + } + if (lists < 7) { + return [ + ...closed, + { + id: 'p-reopen-2', + subjectId: SUBJECT, + actorId: FOUNDER, + kind: 'moderator_propose' as const, + createdAt: 9_000_000_000_002, + }, + ]; + } + return [ + ...closed, + { + id: 'p-reopen-3', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose' as const, + createdAt: 9_000_000_000_003, + }, + ]; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post( + mount(authStore, store, { notificationStore: notifications }), + '/trust/propose-moderator', + 'mod', + { accountId: SUBJECT }, + ); + expect(res.status).toBe(200); + expect(await notifications.listByRecipient(MOD, 10)).toEqual([]); + expect(await notifications.listByRecipient(FOUNDER, 10)).toEqual([]); + }); + + it('fans out the current actor when fan-out opens on a different pending id', async () => { + const { authStore, trustStore } = await staffed([ + account({ id: SUBJECT, role: 'verified', name: 'Sub' }), + ]); + const notifications = new InMemoryNotificationStore(); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 3) { + return rows; + } + const closed = [ + ...rows, + { + id: 'reject-during-notify', + subjectId: SUBJECT, + actorId: FOUNDER, + kind: 'moderator_reject' as const, + createdAt: 9_000_000_000_000, + }, + ]; + if (lists === 3) { + return [ + ...closed, + { + id: 'p-reopen', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose' as const, + createdAt: 9_000_000_000_001, + }, + ]; + } + return [ + ...closed, + { + id: 'p-reopen-2', + subjectId: SUBJECT, + actorId: FOUNDER, + kind: 'moderator_propose' as const, + createdAt: 9_000_000_000_002, + }, + ]; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post( + mount(authStore, store, { notificationStore: notifications }), + '/trust/propose-moderator', + 'founder', + { accountId: SUBJECT }, + ); + expect(res.status).toBe(200); + expect(await notifications.listByRecipient(FOUNDER, 10)).toEqual([]); + const modRows = await notifications.listByRecipient(MOD, 10); + expect(modRows).toHaveLength(1); + expect(modRows[0]?.actorAccountId).toBe(FOUNDER); + expect(modRows[0]?.name).toBe('Founder'); + }); + + it('rewrites proposal rows when the extra fan-out round keeps a new actor', async () => { + const { authStore, trustStore } = await staffed([ + account({ id: SUBJECT, role: 'verified', name: 'Sub' }), + ]); + const notifications = new InMemoryNotificationStore(); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 3) { + return rows; + } + return [ + ...rows, + { + id: 'reject-during-notify', + subjectId: SUBJECT, + actorId: FOUNDER, + kind: 'moderator_reject' as const, + createdAt: 9_000_000_000_000, + }, + { + id: 'p-reopen', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose' as const, + createdAt: 9_000_000_000_001, + }, + ]; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post( + mount(authStore, store, { notificationStore: notifications }), + '/trust/propose-moderator', + 'founder', + { accountId: SUBJECT }, + ); + expect(res.status).toBe(200); + expect(await notifications.listByRecipient(MOD, 10)).toEqual([]); + const founderRows = await notifications.listByRecipient(FOUNDER, 10); + expect(founderRows).toHaveLength(1); + expect(founderRows[0]?.type).toBe('moderator_proposal'); + expect(founderRows[0]?.actorAccountId).toBe(MOD); + expect(founderRows[0]?.name).toBe('Mod'); + }); + + it('drops rows when the extra fan-out round opens on a closed pending', async () => { + const { authStore, trustStore } = await staffed([ + account({ id: SUBJECT, role: 'verified', name: 'Sub' }), + ]); + const notifications = new InMemoryNotificationStore(); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 3) { + return rows; + } + const closed = [ + ...rows, + { + id: 'reject-during-notify', + subjectId: SUBJECT, + actorId: FOUNDER, + kind: 'moderator_reject' as const, + createdAt: 9_000_000_000_000, + }, + ]; + if (lists < 5) { + return [ + ...closed, + { + id: 'p-reopen', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose' as const, + createdAt: 9_000_000_000_001, + }, + ]; + } + if (lists === 5) { + return [ + ...closed, + { + id: 'p-reopen-2', + subjectId: SUBJECT, + actorId: FOUNDER, + kind: 'moderator_propose' as const, + createdAt: 9_000_000_000_002, + }, + ]; + } + return closed; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post( + mount(authStore, store, { notificationStore: notifications }), + '/trust/propose-moderator', + 'founder', + { accountId: SUBJECT }, + ); + expect(res.status).toBe(200); + expect(await notifications.listByRecipient(MOD, 10)).toEqual([]); + expect(await notifications.listByRecipient(FOUNDER, 10)).toEqual([]); + }); + + it('still 200 when pending fan-out throws', async () => { + const { authStore, trustStore } = await staffed([ + account({ id: SUBJECT, role: 'verified', name: 'Sub' }), + ]); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + if (lists >= 4) { + throw new Error('list boom'); + } + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 3) { + return rows; + } + return [ + ...rows, + { + id: 'reject-during-notify', + subjectId: SUBJECT, + actorId: FOUNDER, + kind: 'moderator_reject', + createdAt: 9_000_000_000_000, + }, + { + id: 'p-reopen', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose', + createdAt: 9_000_000_000_001, + }, + ]; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post(mount(authStore, store), '/trust/propose-moderator', 'founder', { + accountId: SUBJECT, + }); + expect(res.status).toBe(200); + expect(parsedEvents(warn).some((event) => event['event'] === 'push.enqueue.failed')).toBe( + true, + ); + }); + + it('still 200 when reconciling proposal notifications throws', async () => { + const { authStore, trustStore } = await staffed([ + account({ id: SUBJECT, role: 'verified', name: 'Sub' }), + ]); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + if (lists >= 3) { + throw new Error('list boom'); + } + return trustStore.listEdgesForSubject(id); + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post(mount(authStore, store), '/trust/propose-moderator', 'founder', { + accountId: SUBJECT, + }); + expect(res.status).toBe(200); + expect(parsedEvents(warn).some((event) => event['event'] === 'push.enqueue.failed')).toBe( + true, + ); + }); + + it('still 200 when staff proposal notify throws', async () => { + const { authStore, trustStore } = await staffed([ + account({ id: SUBJECT, role: 'verified', name: 'Sub' }), + ]); + authStore.listAccounts = async () => { + throw new Error('boom'); + }; + const res = await post(mount(authStore, trustStore), '/trust/propose-moderator', 'founder', { + accountId: SUBJECT, + }); + expect(res.status).toBe(200); + expect(parsedEvents(warn).some((event) => event['event'] === 'push.enqueue.failed')).toBe( + true, + ); + }); + + it('returns 503 when listing throws and 409/503 on insert failure', async () => { + const extras = [account({ id: SUBJECT, role: 'verified', name: 'Sub' })]; + const listed = await staffed(extras); + expect( + ( + await post(mount(listed.authStore, throwingList), '/trust/propose-moderator', 'founder', { + accountId: SUBJECT, + }) + ).status, + ).toBe(503); + const dup = await staffed(extras); + expect( + ( + await post(mount(dup.authStore, duplicateInsert), '/trust/propose-moderator', 'founder', { + accountId: SUBJECT, + }) + ).status, + ).toBe(409); + const boom = await staffed(extras); + expect( + ( + await post(mount(boom.authStore, boomInsert), '/trust/propose-moderator', 'founder', { + accountId: SUBJECT, + }) + ).status, + ).toBe(503); + }); + }); + + describe('POST /trust/confirm-moderator', () => { + async function pending(): Promise<{ + authStore: InMemoryAuthStore; + trustStore: InMemoryTrustStore; + }> { + const seeded = await staffed([account({ id: SUBJECT, role: 'verified', name: 'Sub' })]); + await seeded.trustStore.insertEdge({ + id: 'propose', + subjectId: SUBJECT, + actorId: FOUNDER, + kind: 'moderator_propose', + createdAt: 1, + }); + return seeded; + } + + it('returns 401, 403, 400, and 404 like the other staff POSTs', async () => { + const { authStore, trustStore } = await staffed(); + expect( + ( + await post(mount(authStore, trustStore), '/trust/confirm-moderator', undefined, { + accountId: SUBJECT, + }) + ).status, + ).toBe(401); + expect( + ( + await post(mount(authStore, trustStore), '/trust/confirm-moderator', 'other', { + accountId: SUBJECT, + }) + ).status, + ).toBe(403); + expect( + ( + await post(mount(authStore, trustStore), '/trust/confirm-moderator', 'founder', { + nope: true, + }) + ).status, + ).toBe(400); + expect( + ( + await post(mount(authStore, trustStore), '/trust/confirm-moderator', 'founder', { + accountId: 'bad', + }) + ).status, + ).toBe(404); + expect( + ( + await post(mount(authStore, trustStore), '/trust/confirm-moderator', 'founder', { + accountId: SUBJECT, + }) + ).status, + ).toBe(404); + }); + + it('returns 409 when confirming self, when not verified, when no propose exists, or when the caller proposed', async () => { + const self = await pending(); + expect( + ( + await post( + mount(self.authStore, self.trustStore), + '/trust/confirm-moderator', + 'founder', + { + accountId: FOUNDER, + }, + ) + ).status, + ).toBe(409); + const notVerified = await staffed(); + expect( + ( + await post( + mount(notVerified.authStore, notVerified.trustStore), + '/trust/confirm-moderator', + 'mod', + { accountId: OTHER }, + ) + ).status, + ).toBe(409); + const noPropose = await staffed([account({ id: SUBJECT, role: 'verified', name: 'Sub' })]); + expect( + ( + await post( + mount(noPropose.authStore, noPropose.trustStore), + '/trust/confirm-moderator', + 'mod', + { accountId: SUBJECT }, + ) + ).status, + ).toBe(409); + const same = await pending(); + expect( + ( + await post( + mount(same.authStore, same.trustStore), + '/trust/confirm-moderator', + 'founder', + { + accountId: SUBJECT, + }, + ) + ).status, + ).toBe(409); + }); + + it('promotes the subject when a different staff member confirms', async () => { + const { authStore, trustStore } = await pending(); + const res = await post(mount(authStore, trustStore), '/trust/confirm-moderator', 'mod', { + accountId: SUBJECT, + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ id: SUBJECT, name: 'Sub', role: 'moderator' }); + expect((await authStore.getAccount(SUBJECT))?.role).toBe('moderator'); + expect((await trustStore.listEdges()).some((row) => row.kind === 'moderator_confirm')).toBe( + true, + ); + expect( + parsedEvents(warn).some((event) => event['event'] === 'trust.moderator_confirmed'), + ).toBe(true); + }); + + it('returns 503 when listing throws and 409/503 on insert failure', async () => { + const extras = [account({ id: SUBJECT, role: 'verified', name: 'Sub' })]; + const listed = await staffed(extras); + expect( + ( + await post(mount(listed.authStore, throwingList), '/trust/confirm-moderator', 'mod', { + accountId: SUBJECT, + }) + ).status, + ).toBe(503); + const withPropose: TrustEdge[] = [ + { + id: 'propose', + subjectId: SUBJECT, + actorId: FOUNDER, + kind: 'moderator_propose', + createdAt: 1, + }, + ]; + const dupStore: TrustStore = { + listEdges: async () => withPropose, + listEdgesForSubject: async () => withPropose, + listEdgesTouching: async () => withPropose, + insertEdge: async () => { + throw new Error('duplicate trust edge'); + }, + deleteEdge: async () => undefined, + deleteEdgeById: async () => undefined, + }; + const dup = await staffed(extras); + expect( + ( + await post(mount(dup.authStore, dupStore), '/trust/confirm-moderator', 'mod', { + accountId: SUBJECT, + }) + ).status, + ).toBe(409); + expect((await dup.authStore.getAccount(SUBJECT))?.role).toBe('verified'); + const boomStore: TrustStore = { + listEdges: async () => withPropose, + listEdgesForSubject: async () => withPropose, + listEdgesTouching: async () => withPropose, + insertEdge: async () => { + throw new Error('insert boom'); + }, + deleteEdge: async () => undefined, + deleteEdgeById: async () => undefined, + }; + const boom = await staffed(extras); + expect( + ( + await post(mount(boom.authStore, boomStore), '/trust/confirm-moderator', 'mod', { + accountId: SUBJECT, + }) + ).status, + ).toBe(503); + expect((await boom.authStore.getAccount(SUBJECT))?.role).toBe('verified'); + }); + + it('returns 409 and drops the confirm when a newer propose replaced the pending one', async () => { + const { authStore, trustStore } = await pending(); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 2) { + return rows; + } + return [ + ...rows, + { + id: 'r1', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_reject', + createdAt: 2, + }, + { + id: 'p2', + subjectId: SUBJECT, + actorId: FOUNDER, + kind: 'moderator_propose', + createdAt: 9_000_000_000_000, + }, + ]; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post(mount(authStore, store), '/trust/confirm-moderator', 'mod', { + accountId: SUBJECT, + }); + expect(res.status).toBe(409); + expect((await authStore.getAccount(SUBJECT))?.role).toBe('verified'); + expect((await trustStore.listEdges()).some((row) => row.kind === 'moderator_confirm')).toBe( + false, + ); + }); + + it('returns 409 when a newer extra propose replaced the pending row', async () => { + const { authStore, trustStore } = await pending(); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 2) { + return rows; + } + return [ + ...rows, + { + id: 'propose-z', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose', + createdAt: 2, + }, + ]; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post(mount(authStore, store), '/trust/confirm-moderator', 'mod', { + accountId: SUBJECT, + }); + expect(res.status).toBe(409); + expect((await authStore.getAccount(SUBJECT))?.role).toBe('verified'); + expect((await trustStore.listEdges()).some((row) => row.kind === 'moderator_confirm')).toBe( + false, + ); + }); + + it('returns 409 when a same-actor same-timestamp re-propose replaced the pending row', async () => { + const { authStore, trustStore } = await pending(); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 2) { + return rows; + } + return [ + ...rows, + { + id: 'propose0', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_reject', + createdAt: 1, + }, + { + id: 'propose1', + subjectId: SUBJECT, + actorId: FOUNDER, + kind: 'moderator_propose', + createdAt: 1, + }, + ]; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post(mount(authStore, store), '/trust/confirm-moderator', 'mod', { + accountId: SUBJECT, + }); + expect(res.status).toBe(409); + expect((await authStore.getAccount(SUBJECT))?.role).toBe('verified'); + expect((await trustStore.listEdges()).some((row) => row.kind === 'moderator_confirm')).toBe( + false, + ); + }); + + it('returns 409 and drops the confirm when a concurrent reject closed the proposal', async () => { + const { authStore, trustStore } = await pending(); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 2) { + return rows; + } + return [ + ...rows, + { + id: 'reject-race', + subjectId: SUBJECT, + actorId: FOUNDER, + kind: 'moderator_reject', + createdAt: 9_000_000_000_000, + }, + ]; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post(mount(authStore, store), '/trust/confirm-moderator', 'mod', { + accountId: SUBJECT, + }); + expect(res.status).toBe(409); + expect((await authStore.getAccount(SUBJECT))?.role).toBe('verified'); + expect((await trustStore.listEdges()).some((row) => row.kind === 'moderator_confirm')).toBe( + false, + ); + }); + + it('returns 503 when listing after confirm insert throws', async () => { + const { authStore, trustStore } = await pending(); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + if (lists >= 2) { + throw new Error('list boom'); + } + return trustStore.listEdgesForSubject(id); + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post(mount(authStore, store), '/trust/confirm-moderator', 'mod', { + accountId: SUBJECT, + }); + expect(res.status).toBe(503); + expect((await authStore.getAccount(SUBJECT))?.role).toBe('verified'); + expect((await trustStore.listEdges()).some((row) => row.kind === 'moderator_confirm')).toBe( + false, + ); + }); + + it('completes the role write when the caller already stored a confirm edge', async () => { + const { authStore, trustStore } = await pending(); + await trustStore.insertEdge({ + id: 'confirm', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_confirm', + createdAt: 2, + }); + const res = await post(mount(authStore, trustStore), '/trust/confirm-moderator', 'mod', { + accountId: SUBJECT, + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ id: SUBJECT, name: 'Sub', role: 'moderator' }); + expect((await authStore.getAccount(SUBJECT))?.role).toBe('moderator'); + expect( + (await trustStore.listEdges()).filter((row) => row.kind === 'moderator_confirm'), + ).toHaveLength(1); + }); + + it('returns 409 when the caller owns a confirm edge but the subject is not verified', async () => { + const { authStore, trustStore } = await staffed([ + account({ id: SUBJECT, role: 'basis', name: 'Sub' }), + ]); + await trustStore.insertEdge({ + id: 'confirm', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_confirm', + createdAt: 2, + }); + const res = await post(mount(authStore, trustStore), '/trust/confirm-moderator', 'mod', { + accountId: SUBJECT, + }); + expect(res.status).toBe(409); + expect((await authStore.getAccount(SUBJECT))?.role).toBe('basis'); + }); + + it('returns 503 when updateAccount throws on a caller-owned confirm retry', async () => { + const { authStore, trustStore } = await pending(); + await trustStore.insertEdge({ + id: 'confirm', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_confirm', + createdAt: 2, + }); + const spy = vi + .spyOn(authStore, 'updateAccount') + .mockRejectedValueOnce(new Error('role boom')); + const first = await post(mount(authStore, trustStore), '/trust/confirm-moderator', 'mod', { + accountId: SUBJECT, + }); + expect(first.status).toBe(503); + expect(await first.json()).toEqual({ error: 'Trust chain is unavailable' }); + expect((await authStore.getAccount(SUBJECT))?.role).toBe('verified'); + const retry = await post(mount(authStore, trustStore), '/trust/confirm-moderator', 'mod', { + accountId: SUBJECT, + }); + expect(retry.status).toBe(200); + expect((await authStore.getAccount(SUBJECT))?.role).toBe('moderator'); + spy.mockRestore(); + }); + + it('returns 409 when a confirm edge from a different actor already exists', async () => { + const { authStore, trustStore } = await pending(); + await trustStore.insertEdge({ + id: 'confirm', + subjectId: SUBJECT, + actorId: FOUNDER, + kind: 'moderator_confirm', + createdAt: 2, + }); + const res = await post(mount(authStore, trustStore), '/trust/confirm-moderator', 'mod', { + accountId: SUBJECT, + }); + expect(res.status).toBe(409); + expect((await authStore.getAccount(SUBJECT))?.role).toBe('verified'); + }); + + it('returns 200 idempotently when the caller already confirmed the subject', async () => { + const { authStore, trustStore } = await staffed([ + account({ id: SUBJECT, role: 'moderator', name: 'Sub' }), + ]); + await trustStore.insertEdge({ + id: 'confirm', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_confirm', + createdAt: 1, + }); + const res = await post(mount(authStore, trustStore), '/trust/confirm-moderator', 'mod', { + accountId: SUBJECT, + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ id: SUBJECT, name: 'Sub', role: 'moderator' }); + expect((await authStore.getAccount(SUBJECT))?.role).toBe('moderator'); + expect((await trustStore.listEdges()).map((row) => row.id)).toEqual(['confirm']); + }); + + it('returns 503 when updateAccount throws after insert and retries the role write', async () => { + const { authStore, trustStore } = await pending(); + const spy = vi + .spyOn(authStore, 'updateAccount') + .mockRejectedValueOnce(new Error('role boom')); + const first = await post(mount(authStore, trustStore), '/trust/confirm-moderator', 'mod', { + accountId: SUBJECT, + }); + expect(first.status).toBe(503); + expect(await first.json()).toEqual({ error: 'Trust chain is unavailable' }); + expect((await authStore.getAccount(SUBJECT))?.role).toBe('verified'); + expect( + (await trustStore.listEdges()).some( + (row) => row.kind === 'moderator_confirm' && row.actorId === MOD, + ), + ).toBe(true); + const retry = await post(mount(authStore, trustStore), '/trust/confirm-moderator', 'mod', { + accountId: SUBJECT, + }); + expect(retry.status).toBe(200); + expect(await retry.json()).toEqual({ id: SUBJECT, name: 'Sub', role: 'moderator' }); + expect((await authStore.getAccount(SUBJECT))?.role).toBe('moderator'); + expect( + (await trustStore.listEdges()).filter((row) => row.kind === 'moderator_confirm'), + ).toHaveLength(1); + spy.mockRestore(); + }); + + it('notifies only the subject on a successful confirm', async () => { + const { authStore, trustStore } = await pending(); const notifications = new InMemoryNotificationStore(); const pushStore = new InMemoryPushStore(); await subscribeSubjectActorAndOther(pushStore); const res = await post( mount(authStore, trustStore, { notificationStore: notifications, pushStore }), - '/trust/propose-moderator', - 'founder', + '/trust/confirm-moderator', + 'mod', { accountId: SUBJECT }, ); expect(res.status).toBe(200); - expect((await authStore.getAccount(SUBJECT))?.role).toBe('verified'); - expect(await notifications.listByRecipient(SUBJECT, 10)).toEqual([]); + expect(await res.json()).toEqual({ id: SUBJECT, name: 'Sub', role: 'moderator' }); + expect((await authStore.getAccount(SUBJECT))?.role).toBe('moderator'); + const listed = await notifications.listByRecipient(SUBJECT, 10); + expect(listed).toHaveLength(1); + expect(listed[0]?.type).toBe('moderator_appointed'); + expect(listed[0]?.actorAccountId).toBe(MOD); expect(await notifications.listByRecipient(FOUNDER, 10)).toEqual([]); expect(await notifications.listByRecipient(MOD, 10)).toEqual([]); expect(await notifications.listByRecipient(OTHER, 10)).toEqual([]); - expect(await pushStore.claimPending(10, now(), 60_000)).toEqual([]); + const claimed = await pushStore.claimPending(10, now(), 60_000); + expect(claimed.map((row) => row.accountId)).toEqual([SUBJECT]); }); - it('returns 503 when listing throws and 409/503 on insert failure', async () => { - const extras = [account({ id: SUBJECT, role: 'verified', name: 'Sub' })]; - const listed = await staffed(extras); - expect( - ( - await post(mount(listed.authStore, throwingList), '/trust/propose-moderator', 'founder', { - accountId: SUBJECT, - }) - ).status, - ).toBe(503); - const dup = await staffed(extras); - expect( - ( - await post(mount(dup.authStore, duplicateInsert), '/trust/propose-moderator', 'founder', { - accountId: SUBJECT, - }) - ).status, - ).toBe(409); - const boom = await staffed(extras); - expect( - ( - await post(mount(boom.authStore, boomInsert), '/trust/propose-moderator', 'founder', { - accountId: SUBJECT, - }) - ).status, - ).toBe(503); + it('notifies the subject on an idempotent already-moderator confirm 200', async () => { + const { authStore, trustStore } = await staffed([ + account({ id: SUBJECT, role: 'moderator', name: 'Sub' }), + ]); + await trustStore.insertEdge({ + id: 'confirm', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_confirm', + createdAt: 1, + }); + const notifications = new InMemoryNotificationStore(); + const pushStore = new InMemoryPushStore(); + await subscribeSubjectActorAndOther(pushStore); + const res = await post( + mount(authStore, trustStore, { notificationStore: notifications, pushStore }), + '/trust/confirm-moderator', + 'mod', + { accountId: SUBJECT }, + ); + expect(res.status).toBe(200); + expect((await authStore.getAccount(SUBJECT))?.role).toBe('moderator'); + expect(await notifications.listByRecipient(SUBJECT, 10)).toHaveLength(1); + expect(await notifications.listByRecipient(FOUNDER, 10)).toEqual([]); + expect(await notifications.listByRecipient(MOD, 10)).toEqual([]); + expect(await notifications.listByRecipient(OTHER, 10)).toEqual([]); + expect((await pushStore.claimPending(10, now(), 60_000)).map((row) => row.accountId)).toEqual( + [SUBJECT], + ); + }); + + it('still returns 200 when notification create throws', async () => { + const { authStore, trustStore } = await pending(); + const notifications = new InMemoryNotificationStore(); + const pushStore = new InMemoryPushStore(); + await subscribeSubjectActorAndOther(pushStore); + notifications.create = async () => { + throw new Error('create boom'); + }; + const res = await post( + mount(authStore, trustStore, { notificationStore: notifications, pushStore }), + '/trust/confirm-moderator', + 'mod', + { accountId: SUBJECT }, + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ id: SUBJECT, name: 'Sub', role: 'moderator' }); + expect((await authStore.getAccount(SUBJECT))?.role).toBe('moderator'); + expect(parsedEvents(warn).some((event) => event['event'] === 'push.enqueue.failed')).toBe( + true, + ); }); }); - describe('POST /trust/confirm-moderator', () => { + describe('POST /trust/reject-moderator', () => { async function pending(): Promise<{ authStore: InMemoryAuthStore; trustStore: InMemoryTrustStore; @@ -609,380 +2287,766 @@ describe('POST /trust/*', () => { await seeded.trustStore.insertEdge({ id: 'propose', subjectId: SUBJECT, - actorId: FOUNDER, - kind: 'moderator_propose', - createdAt: 1, - }); - return seeded; - } - - it('returns 401, 403, 400, and 404 like the other staff POSTs', async () => { - const { authStore, trustStore } = await staffed(); - expect( - ( - await post(mount(authStore, trustStore), '/trust/confirm-moderator', undefined, { - accountId: SUBJECT, - }) - ).status, - ).toBe(401); - expect( - ( - await post(mount(authStore, trustStore), '/trust/confirm-moderator', 'other', { - accountId: SUBJECT, - }) - ).status, - ).toBe(403); + actorId: FOUNDER, + kind: 'moderator_propose', + createdAt: 1, + }); + return seeded; + } + + it('returns 400 and 404 for a bad or missing accountId', async () => { + const { authStore, trustStore } = await staffed(); expect( ( - await post(mount(authStore, trustStore), '/trust/confirm-moderator', 'founder', { - nope: true, + await post(mount(authStore, trustStore), '/trust/reject-moderator', 'founder', { + accountId: true, }) ).status, ).toBe(400); expect( ( - await post(mount(authStore, trustStore), '/trust/confirm-moderator', 'founder', { + await post(mount(authStore, trustStore), '/trust/reject-moderator', 'founder', { accountId: 'bad', }) ).status, ).toBe(404); expect( ( - await post(mount(authStore, trustStore), '/trust/confirm-moderator', 'founder', { + await post(mount(authStore, trustStore), '/trust/reject-moderator', 'founder', { accountId: SUBJECT, }) ).status, ).toBe(404); }); - it('returns 409 when confirming self, when not verified, when no propose exists, or when the caller proposed', async () => { - const self = await pending(); - expect( - ( - await post( - mount(self.authStore, self.trustStore), - '/trust/confirm-moderator', - 'founder', - { - accountId: FOUNDER, - }, - ) - ).status, - ).toBe(409); - const notVerified = await staffed(); - expect( - ( - await post( - mount(notVerified.authStore, notVerified.trustStore), - '/trust/confirm-moderator', - 'mod', - { accountId: OTHER }, - ) - ).status, - ).toBe(409); - const noPropose = await staffed([account({ id: SUBJECT, role: 'verified', name: 'Sub' })]); - expect( - ( - await post( - mount(noPropose.authStore, noPropose.trustStore), - '/trust/confirm-moderator', - 'mod', - { accountId: SUBJECT }, - ) - ).status, - ).toBe(409); - const same = await pending(); - expect( - ( - await post( - mount(same.authStore, same.trustStore), - '/trust/confirm-moderator', - 'founder', - { - accountId: SUBJECT, - }, - ) - ).status, - ).toBe(409); + it('returns 401 without a session', async () => { + const { authStore, trustStore } = await staffed(); + const res = await post(mount(authStore, trustStore), '/trust/reject-moderator', undefined, { + accountId: SUBJECT, + }); + expect(res.status).toBe(401); }); - it('promotes the subject when a different staff member confirms', async () => { + it('returns 403 when the caller is not staff', async () => { + const { authStore, trustStore } = await staffed(); + const res = await post(mount(authStore, trustStore), '/trust/reject-moderator', 'other', { + accountId: SUBJECT, + }); + expect(res.status).toBe(403); + }); + + it('returns 409 when the subject is not verified', async () => { + const { authStore, trustStore } = await staffed(); + const res = await post(mount(authStore, trustStore), '/trust/reject-moderator', 'founder', { + accountId: MOD, + }); + expect(res.status).toBe(409); + }); + + it('returns 409 when the subject is not pending', async () => { + const { authStore, trustStore } = await staffed([ + account({ id: SUBJECT, role: 'verified', name: 'Sub' }), + ]); + const res = await post(mount(authStore, trustStore), '/trust/reject-moderator', 'founder', { + accountId: SUBJECT, + }); + expect(res.status).toBe(409); + }); + + it('returns 409 when rejecting self', async () => { + const { authStore, trustStore } = await staffed(); + const res = await post(mount(authStore, trustStore), '/trust/reject-moderator', 'founder', { + accountId: FOUNDER, + }); + expect(res.status).toBe(409); + }); + + it('lets the proposer reject, keeps the propose edge, and logs', async () => { const { authStore, trustStore } = await pending(); - const res = await post(mount(authStore, trustStore), '/trust/confirm-moderator', 'mod', { + const res = await post(mount(authStore, trustStore), '/trust/reject-moderator', 'founder', { accountId: SUBJECT, }); expect(res.status).toBe(200); - expect(await res.json()).toEqual({ id: SUBJECT, name: 'Sub', role: 'moderator' }); - expect((await authStore.getAccount(SUBJECT))?.role).toBe('moderator'); - expect((await trustStore.listEdges()).some((row) => row.kind === 'moderator_confirm')).toBe( - true, - ); + expect(await res.json()).toEqual({ id: SUBJECT, name: 'Sub', role: 'verified' }); + expect((await authStore.getAccount(SUBJECT))?.role).toBe('verified'); + const edges = await trustStore.listEdges(); + expect(edges.some((row) => row.kind === 'moderator_propose')).toBe(true); + expect(edges.some((row) => row.kind === 'moderator_reject')).toBe(true); expect( - parsedEvents(warn).some((event) => event['event'] === 'trust.moderator_confirmed'), + parsedEvents(warn).some((event) => event['event'] === 'trust.moderator_rejected'), ).toBe(true); }); - it('returns 503 when listing throws and 409/503 on insert failure', async () => { - const extras = [account({ id: SUBJECT, role: 'verified', name: 'Sub' })]; - const listed = await staffed(extras); - expect( - ( - await post(mount(listed.authStore, throwingList), '/trust/confirm-moderator', 'mod', { - accountId: SUBJECT, - }) - ).status, - ).toBe(503); - const withPropose: TrustEdge[] = [ - { - id: 'propose', - subjectId: SUBJECT, - actorId: FOUNDER, - kind: 'moderator_propose', - createdAt: 1, - }, - ]; - const dupStore: TrustStore = { - listEdges: async () => withPropose, - listEdgesForSubject: async () => withPropose, - listEdgesTouching: async () => withPropose, - insertEdge: async () => { - throw new Error('duplicate trust edge'); - }, - deleteEdge: async () => undefined, - }; - const dup = await staffed(extras); - expect( - ( - await post(mount(dup.authStore, dupStore), '/trust/confirm-moderator', 'mod', { - accountId: SUBJECT, - }) - ).status, - ).toBe(409); - expect((await dup.authStore.getAccount(SUBJECT))?.role).toBe('verified'); - const boomStore: TrustStore = { - listEdges: async () => withPropose, - listEdgesForSubject: async () => withPropose, - listEdgesTouching: async () => withPropose, - insertEdge: async () => { - throw new Error('insert boom'); - }, - deleteEdge: async () => undefined, + it('allows a second propose after reject', async () => { + const { authStore, trustStore } = await pending(); + let t = now(); + const tick = (): number => { + t += 1; + return t; }; - const boom = await staffed(extras); + const app = mount(authStore, trustStore, { now: tick }); + const rejected = await post(app, '/trust/reject-moderator', 'founder', { + accountId: SUBJECT, + }); + expect(rejected.status).toBe(200); + const res = await post(app, '/trust/propose-moderator', 'mod', { + accountId: SUBJECT, + }); + expect(res.status).toBe(200); expect( - ( - await post(mount(boom.authStore, boomStore), '/trust/confirm-moderator', 'mod', { - accountId: SUBJECT, - }) - ).status, - ).toBe(503); - expect((await boom.authStore.getAccount(SUBJECT))?.role).toBe('verified'); + (await trustStore.listEdges()).filter((row) => row.kind === 'moderator_propose'), + ).toHaveLength(2); }); - it('completes the role write when the caller already stored a confirm edge', async () => { + it('returns 409 when proposing while a proposal is still pending', async () => { const { authStore, trustStore } = await pending(); + const res = await post(mount(authStore, trustStore), '/trust/propose-moderator', 'mod', { + accountId: SUBJECT, + }); + expect(res.status).toBe(409); + }); + + it('confirms using the latest propose actor after a reject', async () => { + const { authStore, trustStore } = await staffed([ + account({ id: SUBJECT, role: 'verified', name: 'Sub' }), + ]); await trustStore.insertEdge({ - id: 'confirm', + id: 'propose-1', + subjectId: SUBJECT, + actorId: FOUNDER, + kind: 'moderator_propose', + createdAt: 1, + }); + await trustStore.insertEdge({ + id: 'reject-1', subjectId: SUBJECT, actorId: MOD, - kind: 'moderator_confirm', + kind: 'moderator_reject', createdAt: 2, }); - const res = await post(mount(authStore, trustStore), '/trust/confirm-moderator', 'mod', { + await trustStore.insertEdge({ + id: 'propose-2', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose', + createdAt: 3, + }); + const res = await post(mount(authStore, trustStore), '/trust/confirm-moderator', 'founder', { accountId: SUBJECT, }); expect(res.status).toBe(200); - expect(await res.json()).toEqual({ id: SUBJECT, name: 'Sub', role: 'moderator' }); expect((await authStore.getAccount(SUBJECT))?.role).toBe('moderator'); - expect( - (await trustStore.listEdges()).filter((row) => row.kind === 'moderator_confirm'), - ).toHaveLength(1); }); - it('returns 409 when the caller owns a confirm edge but the subject is not verified', async () => { - const { authStore, trustStore } = await staffed([ - account({ id: SUBJECT, role: 'basis', name: 'Sub' }), + it('deletes moderator_proposal rows on reject', async () => { + const { authStore, trustStore } = await pending(); + const notifications = new InMemoryNotificationStore([ + { + id: '55555555-5555-4555-8555-555555555555', + recipientAccountId: MOD, + actorAccountId: FOUNDER, + type: 'moderator_proposal', + parentId: SUBJECT, + replyId: SUBJECT, + name: 'Founder', + text: 'Sub', + createdAt: new Date(now()), + readAt: null, + }, ]); - await trustStore.insertEdge({ - id: 'confirm', - subjectId: SUBJECT, - actorId: MOD, - kind: 'moderator_confirm', - createdAt: 2, + const res = await post( + mount(authStore, trustStore, { notificationStore: notifications }), + '/trust/reject-moderator', + 'founder', + { accountId: SUBJECT }, + ); + expect(res.status).toBe(200); + expect(await notifications.listByRecipient(MOD, 10)).toEqual([]); + }); + + it('deletes moderator_proposal rows on confirm and notifies the subject', async () => { + const { authStore, trustStore } = await pending(); + const notifications = new InMemoryNotificationStore([ + { + id: '55555555-5555-4555-8555-555555555555', + recipientAccountId: MOD, + actorAccountId: FOUNDER, + type: 'moderator_proposal', + parentId: SUBJECT, + replyId: SUBJECT, + name: 'Founder', + text: 'Sub', + createdAt: new Date(now()), + readAt: null, + }, + ]); + const res = await post( + mount(authStore, trustStore, { notificationStore: notifications }), + '/trust/confirm-moderator', + 'mod', + { accountId: SUBJECT }, + ); + expect(res.status).toBe(200); + expect(await notifications.listByRecipient(MOD, 10)).toEqual([]); + const appointed = await notifications.listByRecipient(SUBJECT, 10); + expect(appointed).toHaveLength(1); + expect(appointed[0]?.type).toBe('moderator_appointed'); + }); + + it('returns 503 when listing throws', async () => { + const { authStore } = await staffed([ + account({ id: SUBJECT, role: 'verified', name: 'Sub' }), + ]); + const res = await post(mount(authStore, throwingList), '/trust/reject-moderator', 'founder', { + accountId: SUBJECT, }); - const res = await post(mount(authStore, trustStore), '/trust/confirm-moderator', 'mod', { + expect(res.status).toBe(503); + expect(parsedEvents(warn).some((event) => event['event'] === 'trust.write.failed')).toBe( + true, + ); + }); + + it('returns 503 when reject insert throws', async () => { + const { authStore } = await pending(); + const withPropose: TrustEdge[] = [ + { + id: 'propose', + subjectId: SUBJECT, + actorId: FOUNDER, + kind: 'moderator_propose', + createdAt: 1, + }, + ]; + const store: TrustStore = { + ...boomInsert, + listEdgesForSubject: async () => withPropose, + }; + const res = await post(mount(authStore, store), '/trust/reject-moderator', 'founder', { + accountId: SUBJECT, + }); + expect(res.status).toBe(503); + expect(parsedEvents(warn).some((event) => event['event'] === 'trust.write.failed')).toBe( + true, + ); + }); + + it('returns 409 when reject insert reports a duplicate', async () => { + const { authStore } = await pending(); + const withPropose: TrustEdge[] = [ + { + id: 'propose', + subjectId: SUBJECT, + actorId: FOUNDER, + kind: 'moderator_propose', + createdAt: 1, + }, + ]; + const store: TrustStore = { + ...duplicateInsert, + listEdgesForSubject: async () => withPropose, + }; + const res = await post(mount(authStore, store), '/trust/reject-moderator', 'founder', { accountId: SUBJECT, }); expect(res.status).toBe(409); - expect((await authStore.getAccount(SUBJECT))?.role).toBe('basis'); }); - it('returns 503 when updateAccount throws on a caller-owned confirm retry', async () => { + it('returns 409 and drops the reject when a concurrent confirm already closed the grant', async () => { const { authStore, trustStore } = await pending(); - await trustStore.insertEdge({ - id: 'confirm', - subjectId: SUBJECT, - actorId: MOD, - kind: 'moderator_confirm', - createdAt: 2, - }); - const spy = vi - .spyOn(authStore, 'updateAccount') - .mockRejectedValueOnce(new Error('role boom')); - const first = await post(mount(authStore, trustStore), '/trust/confirm-moderator', 'mod', { - accountId: SUBJECT, - }); - expect(first.status).toBe(503); - expect(await first.json()).toEqual({ error: 'Trust chain is unavailable' }); - expect((await authStore.getAccount(SUBJECT))?.role).toBe('verified'); - const retry = await post(mount(authStore, trustStore), '/trust/confirm-moderator', 'mod', { + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 2) { + return rows; + } + return [ + ...rows, + { + id: 'confirm-race', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_confirm', + createdAt: 9_000_000_000_000, + }, + ]; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post(mount(authStore, store), '/trust/reject-moderator', 'founder', { accountId: SUBJECT, }); - expect(retry.status).toBe(200); - expect((await authStore.getAccount(SUBJECT))?.role).toBe('moderator'); - spy.mockRestore(); + expect(res.status).toBe(409); + expect((await trustStore.listEdges()).some((row) => row.kind === 'moderator_reject')).toBe( + false, + ); }); - it('returns 409 when a confirm edge from a different actor already exists', async () => { + it('returns 409 and drops the reject when a concurrent appoint already closed the grant', async () => { const { authStore, trustStore } = await pending(); - await trustStore.insertEdge({ - id: 'confirm', - subjectId: SUBJECT, - actorId: FOUNDER, - kind: 'moderator_confirm', - createdAt: 2, - }); - const res = await post(mount(authStore, trustStore), '/trust/confirm-moderator', 'mod', { + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 2) { + return rows; + } + return [ + ...rows, + { + id: 'appoint-race', + subjectId: SUBJECT, + actorId: FOUNDER, + kind: 'moderator_appoint', + createdAt: 9_000_000_000_000, + }, + ]; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post(mount(authStore, store), '/trust/reject-moderator', 'founder', { accountId: SUBJECT, }); expect(res.status).toBe(409); - expect((await authStore.getAccount(SUBJECT))?.role).toBe('verified'); + expect((await trustStore.listEdges()).some((row) => row.kind === 'moderator_reject')).toBe( + false, + ); }); - it('returns 200 idempotently when the caller already confirmed the subject', async () => { - const { authStore, trustStore } = await staffed([ - account({ id: SUBJECT, role: 'moderator', name: 'Sub' }), - ]); - await trustStore.insertEdge({ - id: 'confirm', - subjectId: SUBJECT, - actorId: MOD, - kind: 'moderator_confirm', - createdAt: 1, - }); - const res = await post(mount(authStore, trustStore), '/trust/confirm-moderator', 'mod', { + it('returns 503 when listing after reject insert throws', async () => { + const { authStore, trustStore } = await pending(); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + if (lists >= 2) { + throw new Error('list boom'); + } + return trustStore.listEdgesForSubject(id); + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post(mount(authStore, store), '/trust/reject-moderator', 'founder', { accountId: SUBJECT, }); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ id: SUBJECT, name: 'Sub', role: 'moderator' }); - expect((await authStore.getAccount(SUBJECT))?.role).toBe('moderator'); - expect((await trustStore.listEdges()).map((row) => row.id)).toEqual(['confirm']); + expect(res.status).toBe(503); + expect((await trustStore.listEdges()).some((row) => row.kind === 'moderator_reject')).toBe( + false, + ); }); - it('returns 503 when updateAccount throws after insert and retries the role write', async () => { + it('still 200 when listing after a won reject throws', async () => { const { authStore, trustStore } = await pending(); - const spy = vi - .spyOn(authStore, 'updateAccount') - .mockRejectedValueOnce(new Error('role boom')); - const first = await post(mount(authStore, trustStore), '/trust/confirm-moderator', 'mod', { + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + if (lists >= 3) { + throw new Error('list boom'); + } + return trustStore.listEdgesForSubject(id); + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post(mount(authStore, store), '/trust/reject-moderator', 'founder', { accountId: SUBJECT, }); - expect(first.status).toBe(503); - expect(await first.json()).toEqual({ error: 'Trust chain is unavailable' }); - expect((await authStore.getAccount(SUBJECT))?.role).toBe('verified'); - expect( - (await trustStore.listEdges()).some( - (row) => row.kind === 'moderator_confirm' && row.actorId === MOD, - ), - ).toBe(true); - const retry = await post(mount(authStore, trustStore), '/trust/confirm-moderator', 'mod', { + expect(res.status).toBe(200); + expect((await trustStore.listEdges()).some((row) => row.kind === 'moderator_reject')).toBe( + true, + ); + expect(parsedEvents(warn).some((event) => event['event'] === 'push.enqueue.failed')).toBe( + true, + ); + }); + + it('still 200 when listing after reject clear throws', async () => { + const { authStore, trustStore } = await pending(); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + if (lists >= 4) { + throw new Error('list boom'); + } + return trustStore.listEdgesForSubject(id); + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post(mount(authStore, store), '/trust/reject-moderator', 'founder', { accountId: SUBJECT, }); - expect(retry.status).toBe(200); - expect(await retry.json()).toEqual({ id: SUBJECT, name: 'Sub', role: 'moderator' }); - expect((await authStore.getAccount(SUBJECT))?.role).toBe('moderator'); - expect( - (await trustStore.listEdges()).filter((row) => row.kind === 'moderator_confirm'), - ).toHaveLength(1); - spy.mockRestore(); + expect(res.status).toBe(200); + expect(parsedEvents(warn).some((event) => event['event'] === 'push.enqueue.failed')).toBe( + true, + ); }); - it('notifies only the subject on a successful confirm', async () => { + it('fans out again when a re-propose lands after reject clear', async () => { const { authStore, trustStore } = await pending(); const notifications = new InMemoryNotificationStore(); - const pushStore = new InMemoryPushStore(); - await subscribeSubjectActorAndOther(pushStore); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 4) { + return rows; + } + return [ + ...rows, + { + id: 'p-after-clear', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose', + createdAt: 9_000_000_000_000, + }, + ]; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; const res = await post( - mount(authStore, trustStore, { notificationStore: notifications, pushStore }), - '/trust/confirm-moderator', - 'mod', + mount(authStore, store, { notificationStore: notifications }), + '/trust/reject-moderator', + 'founder', { accountId: SUBJECT }, ); expect(res.status).toBe(200); - expect(await res.json()).toEqual({ id: SUBJECT, name: 'Sub', role: 'moderator' }); - expect((await authStore.getAccount(SUBJECT))?.role).toBe('moderator'); - const listed = await notifications.listByRecipient(SUBJECT, 10); + const listed = await notifications.listByRecipient(FOUNDER, 10); expect(listed).toHaveLength(1); - expect(listed[0]?.type).toBe('moderator_appointed'); expect(listed[0]?.actorAccountId).toBe(MOD); + expect(listed[0]?.name).toBe('Mod'); + }); + + it('fans out after reject clear when the newer proposer account is missing', async () => { + const missing = '99999999-9999-4999-8999-999999999999'; + const { authStore, trustStore } = await pending(); + const notifications = new InMemoryNotificationStore(); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 4) { + return rows; + } + return [ + ...rows, + { + id: 'p-after-clear-missing', + subjectId: SUBJECT, + actorId: missing, + kind: 'moderator_propose', + createdAt: 9_000_000_000_000, + }, + ]; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post( + mount(authStore, store, { notificationStore: notifications }), + '/trust/reject-moderator', + 'founder', + { accountId: SUBJECT }, + ); + expect(res.status).toBe(200); + const listed = await notifications.listByRecipient(FOUNDER, 10); + expect(listed).toHaveLength(1); + expect(listed[0]?.actorAccountId).toBe(missing); + expect(listed[0]?.name).toBe('Someone'); + }); + + it('drops reject-clear fan-out rows when pending empties after notify', async () => { + const { authStore, trustStore } = await pending(); + const notifications = new InMemoryNotificationStore(); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 4) { + return rows; + } + if (lists < 6) { + return [ + ...rows, + { + id: 'p-after-clear', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose', + createdAt: 9_000_000_000_000, + }, + ]; + } + return rows; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post( + mount(authStore, store, { notificationStore: notifications }), + '/trust/reject-moderator', + 'founder', + { accountId: SUBJECT }, + ); + expect(res.status).toBe(200); expect(await notifications.listByRecipient(FOUNDER, 10)).toEqual([]); - expect(await notifications.listByRecipient(MOD, 10)).toEqual([]); - expect(await notifications.listByRecipient(OTHER, 10)).toEqual([]); - const claimed = await pushStore.claimPending(10, now(), 60_000); - expect(claimed.map((row) => row.accountId)).toEqual([SUBJECT]); }); - it('notifies the subject on an idempotent already-moderator confirm 200', async () => { + it('does not drop proposal rows when a re-propose lands before reject clear', async () => { + const { authStore, trustStore } = await pending(); + const notifications = new InMemoryNotificationStore([ + { + id: '55555555-5555-4555-8555-555555555555', + recipientAccountId: MOD, + actorAccountId: FOUNDER, + type: 'moderator_proposal', + parentId: SUBJECT, + replyId: SUBJECT, + name: 'Founder', + text: 'Sub', + createdAt: new Date(now()), + readAt: null, + }, + ]); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 3) { + return rows; + } + return [ + ...rows, + { + id: 'p-reopen', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose', + createdAt: 9_000_000_000_000, + }, + ]; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post( + mount(authStore, store, { notificationStore: notifications }), + '/trust/reject-moderator', + 'founder', + { accountId: SUBJECT }, + ); + expect(res.status).toBe(200); + expect(await notifications.listByRecipient(MOD, 10)).toHaveLength(1); + }); + + it('returns 200 without dropping proposal rows when a newer propose reopened', async () => { + const { authStore, trustStore } = await pending(); + const notifications = new InMemoryNotificationStore([ + { + id: '55555555-5555-4555-8555-555555555555', + recipientAccountId: MOD, + actorAccountId: FOUNDER, + type: 'moderator_proposal', + parentId: SUBJECT, + replyId: SUBJECT, + name: 'Founder', + text: 'Sub', + createdAt: new Date(now()), + readAt: null, + }, + ]); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 2) { + return rows; + } + return [ + ...rows, + { + id: 'p2', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose', + createdAt: 9_000_000_000_000, + }, + ]; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post( + mount(authStore, store, { notificationStore: notifications }), + '/trust/reject-moderator', + 'founder', + { accountId: SUBJECT }, + ); + expect(res.status).toBe(200); + expect(await notifications.listByRecipient(MOD, 10)).toHaveLength(1); + }); + + it('returns 200 when a same-actor same-timestamp re-propose reopened after reject', async () => { const { authStore, trustStore } = await staffed([ - account({ id: SUBJECT, role: 'moderator', name: 'Sub' }), + account({ id: SUBJECT, role: 'verified', name: 'Sub' }), ]); await trustStore.insertEdge({ - id: 'confirm', + id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', subjectId: SUBJECT, - actorId: MOD, - kind: 'moderator_confirm', - createdAt: 1, + actorId: FOUNDER, + kind: 'moderator_propose', + createdAt: now(), }); - const notifications = new InMemoryNotificationStore(); - const pushStore = new InMemoryPushStore(); - await subscribeSubjectActorAndOther(pushStore); - const res = await post( - mount(authStore, trustStore, { notificationStore: notifications, pushStore }), - '/trust/confirm-moderator', - 'mod', - { accountId: SUBJECT }, + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 2) { + return rows; + } + return [ + ...rows, + { + id: 'ffffffff-ffff-4fff-8fff-ffffffffffff', + subjectId: SUBJECT, + actorId: FOUNDER, + kind: 'moderator_propose', + createdAt: now(), + }, + ]; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post(mount(authStore, store), '/trust/reject-moderator', 'mod', { + accountId: SUBJECT, + }); + expect(res.status).toBe(200); + expect((await trustStore.listEdges()).some((row) => row.kind === 'moderator_reject')).toBe( + true, ); + }); + + it('returns 200 when a same-timestamp newer propose reopened after reject', async () => { + const { authStore, trustStore } = await pending(); + let lists = 0; + const store: TrustStore = { + listEdges: () => trustStore.listEdges(), + listEdgesTouching: (id) => trustStore.listEdgesTouching(id), + listEdgesForSubject: async (id) => { + lists += 1; + const rows = await trustStore.listEdgesForSubject(id); + if (lists < 2) { + return rows; + } + return [ + ...rows, + { + id: 'ffffffff-ffff-4fff-8fff-ffffffffffff', + subjectId: SUBJECT, + actorId: MOD, + kind: 'moderator_propose', + createdAt: now(), + }, + ]; + }, + insertEdge: (row) => trustStore.insertEdge(row), + deleteEdge: (subjectId, kind) => trustStore.deleteEdge(subjectId, kind), + deleteEdgeById: (id) => trustStore.deleteEdgeById(id), + }; + const res = await post(mount(authStore, store), '/trust/reject-moderator', 'founder', { + accountId: SUBJECT, + }); expect(res.status).toBe(200); - expect((await authStore.getAccount(SUBJECT))?.role).toBe('moderator'); - expect(await notifications.listByRecipient(SUBJECT, 10)).toHaveLength(1); - expect(await notifications.listByRecipient(FOUNDER, 10)).toEqual([]); - expect(await notifications.listByRecipient(MOD, 10)).toEqual([]); - expect(await notifications.listByRecipient(OTHER, 10)).toEqual([]); - expect((await pushStore.claimPending(10, now(), 60_000)).map((row) => row.accountId)).toEqual( - [SUBJECT], + expect((await trustStore.listEdges()).some((row) => row.kind === 'moderator_reject')).toBe( + true, ); }); - it('still returns 200 when notification create throws', async () => { + it('returns 409 when a same-timestamp propose stays latest after reject', async () => { + const { authStore, trustStore } = await staffed([ + account({ id: SUBJECT, role: 'verified', name: 'Sub' }), + ]); + await trustStore.insertEdge({ + id: 'ffffffff-ffff-4fff-8fff-ffffffffffff', + subjectId: SUBJECT, + actorId: FOUNDER, + kind: 'moderator_propose', + createdAt: now(), + }); + const res = await post(mount(authStore, trustStore), '/trust/reject-moderator', 'founder', { + accountId: SUBJECT, + }); + expect(res.status).toBe(409); + expect((await trustStore.listEdges()).some((row) => row.kind === 'moderator_reject')).toBe( + false, + ); + }); + + it('still 200 when dropping proposal notifications throws', async () => { const { authStore, trustStore } = await pending(); const notifications = new InMemoryNotificationStore(); - const pushStore = new InMemoryPushStore(); - await subscribeSubjectActorAndOther(pushStore); - notifications.create = async () => { - throw new Error('create boom'); + notifications.deleteByTypeAndReplyId = async () => { + throw new Error('boom'); }; const res = await post( - mount(authStore, trustStore, { notificationStore: notifications, pushStore }), - '/trust/confirm-moderator', - 'mod', + mount(authStore, trustStore, { notificationStore: notifications }), + '/trust/reject-moderator', + 'founder', { accountId: SUBJECT }, ); expect(res.status).toBe(200); - expect(await res.json()).toEqual({ id: SUBJECT, name: 'Sub', role: 'moderator' }); - expect((await authStore.getAccount(SUBJECT))?.role).toBe('moderator'); - expect(parsedEvents(warn).some((event) => event['event'] === 'push.enqueue.failed')).toBe( - true, - ); + expect( + parsedEvents(warn).some((event) => event['event'] === 'notifications.hidden.purge_failed'), + ).toBe(true); }); }); @@ -1385,6 +3449,7 @@ describe('GET /trust/proposals', () => { expect(await res.json()).toEqual({ proposals: [ { + id: 'propose', subject: { id: SUBJECT, name: 'Ada', role: 'verified' }, proposedBy: { id: MOD, name: 'Mod' }, createdAt: '2026-09-16T00:00:00.000Z', diff --git a/src/lib/notification-store.ts b/src/lib/notification-store.ts index fb8b132ad..3bfc7cab9 100644 --- a/src/lib/notification-store.ts +++ b/src/lib/notification-store.ts @@ -1,5 +1,6 @@ /** - * Persistence for in-app notifications (forum posts, replies, zaps, and moderator appointment). + * Persistence for in-app notifications (forum posts, replies, zaps, + * moderator appointment, and open moderator proposals). * * v1 default is in-memory. Production boot injects Postgres when * `DATABASE_URL` is set. New public tables are covered by `db_change` attach. @@ -63,17 +64,18 @@ export interface NotificationStore { /** * Mark one notification read. Missing / other recipient → `undefined`. * Already read → return as-is (do not overwrite `readAt`). + * `moderator_proposal` stays unread (do not stamp `readAt`). * * @param id - Notification id. * @param accountId - Recipient account. * @param readAt - Read stamp for a previously unread row. - * @returns The row after stamping `readAt`, the already-read row unchanged, or `undefined` if missing/other recipient. + * @returns The row after stamping `readAt`, the already-read or proposal row unchanged, or `undefined` if missing/other recipient. */ markRead(id: string, accountId: string, readAt: Date): Promise; /** * Mark every unread notification for the recipient read. Already-read rows - * stay unchanged. + * stay unchanged. `moderator_proposal` rows are skipped (stay unread). * * @param accountId - Recipient account. * @param readAt - Read stamp for previously unread rows. @@ -88,6 +90,15 @@ export interface NotificationStore { * @returns Number of rows removed. */ deleteByMessageIds(ids: readonly string[]): Promise; + + /** + * Delete notifications whose `type` and `replyId` both match. + * + * @param type - Notification kind to match. + * @param replyId - Event id (`reply_id`) to match. + * @returns Number of rows removed. + */ + deleteByTypeAndReplyId(type: NotificationType, replyId: string): Promise; } /** Idempotent DDL for the notification table (matches `docs/schema/notification.sql`). */ @@ -218,18 +229,19 @@ export class InMemoryNotificationStore implements NotificationStore { /** * Stamp `readAt` on an unread row owned by `accountId`. + * `moderator_proposal` is returned unchanged. * * @param id - Notification id. * @param accountId - Recipient account. * @param readAt - Read stamp. - * @returns A copy after stamping `readAt`, the already-read row unchanged, or `undefined` if missing/other recipient. + * @returns A copy after stamping `readAt`, the already-read or proposal row unchanged, or `undefined` if missing/other recipient. */ markRead(id: string, accountId: string, readAt: Date): Promise { const row = this.#rows.find((item) => item.id === id && item.recipientAccountId === accountId); if (row === undefined) { return Promise.resolve(undefined); } - if (row.readAt !== null) { + if (row.readAt !== null || row.type === 'moderator_proposal') { return Promise.resolve(copyNotification(row)); } row.readAt = new Date(readAt.getTime()); @@ -237,14 +249,19 @@ export class InMemoryNotificationStore implements NotificationStore { } /** - * Stamp `readAt` on every unread row for `accountId`. + * Stamp `readAt` on every unread row for `accountId` except + * `moderator_proposal`. * * @param accountId - Recipient account. * @param readAt - Read stamp. */ markAllRead(accountId: string, readAt: Date): Promise { for (const row of this.#rows) { - if (row.recipientAccountId === accountId && row.readAt === null) { + if ( + row.recipientAccountId === accountId && + row.readAt === null && + row.type !== 'moderator_proposal' + ) { row.readAt = new Date(readAt.getTime()); } } @@ -272,6 +289,25 @@ export class InMemoryNotificationStore implements NotificationStore { } return Promise.resolve(removed); } + + /** + * Remove rows whose `type` and `replyId` both match. + * + * @param type - Notification kind. + * @param replyId - Event id. + * @returns Removed count. + */ + deleteByTypeAndReplyId(type: NotificationType, replyId: string): Promise { + let removed = 0; + for (let i = this.#rows.length - 1; i >= 0; i -= 1) { + const row = this.#rows[i]; + if (row !== undefined && row.type === type && row.replyId === replyId) { + this.#rows.splice(i, 1); + removed += 1; + } + } + return Promise.resolve(removed); + } } /** Row shape selected from `notification`. */ @@ -410,12 +446,14 @@ export class PostgresNotificationStore implements NotificationStore { } /** - * Stamp `read_at` when the row is unread and owned by `accountId`. + * Stamp `read_at` when the row is unread, owned by `accountId`, and not + * `moderator_proposal` (mark-read does not stamp them; rows drop on + * confirm, on reject when pending is then empty, or on appoint). * * @param id - Notification id. * @param accountId - Recipient. * @param readAt - Read stamp. - * @returns The mapped row after stamping `read_at`, the already-read row unchanged, or `undefined` if missing/other recipient. + * @returns The mapped row after stamping `read_at`, the already-read or proposal row unchanged, or `undefined` if missing/other recipient. */ async markRead( id: string, @@ -424,7 +462,7 @@ export class PostgresNotificationStore implements NotificationStore { ): Promise { const updated = await this.#sql.query( `UPDATE notification SET read_at = $3 - WHERE id = $1 AND recipient_account_id = $2 AND read_at IS NULL + WHERE id = $1 AND recipient_account_id = $2 AND read_at IS NULL AND type <> 'moderator_proposal' RETURNING ${NOTIFICATION_SELECT}`, [id, accountId, readAt], ); @@ -436,14 +474,16 @@ export class PostgresNotificationStore implements NotificationStore { } /** - * Stamp `read_at` on every unread row for `accountId`. + * Stamp `read_at` on every unread row for `accountId` except + * `moderator_proposal` (mark-read does not stamp them; rows drop on + * confirm, on reject when pending is then empty, or on appoint). * * @param accountId - Recipient (`$1`). * @param readAt - Read stamp (`$2`). */ async markAllRead(accountId: string, readAt: Date): Promise { await this.#sql.execute( - `UPDATE notification SET read_at = $2 WHERE recipient_account_id = $1 AND read_at IS NULL`, + `UPDATE notification SET read_at = $2 WHERE recipient_account_id = $1 AND read_at IS NULL AND type <> 'moderator_proposal'`, [accountId, readAt], ); } @@ -469,6 +509,21 @@ export class PostgresNotificationStore implements NotificationStore { ); return rows.length; } + + /** + * Delete rows whose `type` and `reply_id` both match. + * + * @param type - Notification kind (`$1`). + * @param replyId - Event id (`$2`). + * @returns Removed count from `RETURNING id`. + */ + async deleteByTypeAndReplyId(type: NotificationType, replyId: string): Promise { + const rows = await this.#sql.query<{ id: string }>( + `DELETE FROM notification WHERE type = $1 AND reply_id = $2 RETURNING id`, + [type, replyId], + ); + return rows.length; + } } function copyNotification(row: NotificationRow): NotificationRow { diff --git a/src/lib/notification.ts b/src/lib/notification.ts index df1ec66e0..f26eb1386 100644 --- a/src/lib/notification.ts +++ b/src/lib/notification.ts @@ -1,18 +1,22 @@ /** * In-app notification domain: public JSON projection, living-room fan-out, - * and targeted `moderator_appointed` (subject only, not a fan-out). + * targeted `moderator_appointed` (subject only, not a fan-out), and staff + * `moderator_proposal` fan-out. * * Living-room in-app recipients are the union of `auth.listAccounts()` (when * `auth` is set) and `push_subscription` account ids, except skip, then * filtered by each recipient's `notificationLevel` when `auth` is set. * `GET /notifications` applies the same filter to stored rows. Targeted - * `moderator_appointed` does not fan out. Web Push is still only - * for `push_subscription` rows. Member HTTP never exposes recipient or actor - * account ids. Callers catch failures so persist still succeeds. + * `moderator_appointed` does not fan out. `moderator_proposal` fans out to + * other staff and is not stamped by mark-read; rows drop on confirm, on + * reject when pending is then empty, or on appoint. + * Web Push is still only for `push_subscription` rows. Member HTTP never + * exposes recipient or actor account ids. Callers catch failures so persist + * still succeeds. */ import { ROLE_ORDER, roleAtLeast } from '@/lib/auth/roles'; -import type { AuthStore, NotificationLevel } from '@/lib/auth/store'; +import type { AccountRole, AuthStore, NotificationLevel } from '@/lib/auth/store'; import { logEvent } from '@/lib/log'; import type { MessageRow } from '@/lib/message'; import type { MessageStore } from '@/lib/message-store'; @@ -32,7 +36,8 @@ export const NOTIFICATION_LIST_LIMIT = 200; export const NOTIFICATION_FILTER_SCAN_LIMIT = 1000; /** Persisted notification kind. */ -export type NotificationType = 'forum_post' | 'forum_reply' | 'zap' | 'moderator_appointed'; +export type NotificationType = + 'forum_post' | 'forum_reply' | 'zap' | 'moderator_appointed' | 'moderator_proposal'; /** Persisted notification row (store-internal; includes account ids). */ export interface NotificationRow { @@ -40,17 +45,17 @@ export interface NotificationRow { id: string; /** Account that should see this notification. */ recipientAccountId: string; - /** Account that caused the notification (poster, replier, zap payer, or appointing staff). */ + /** Account that caused the notification (poster, replier, zap payer, or staff). */ actorAccountId: string; /** Notification kind. */ type: NotificationType; - /** Forum note the event refers to (the post itself for `forum_post`; subject account id for `moderator_appointed`). */ + /** Forum note the event refers to (the post itself for `forum_post`; subject account id for staff trust kinds). */ parentId: string; - /** Event id (`post.id`, `reply.id`, zap receipt UUID, or subject account id for `moderator_appointed`). */ + /** Event id (`post.id`, `reply.id`, zap receipt UUID, or subject account id for staff trust kinds). */ replyId: string; /** Actor display-name snapshot. */ name: string; - /** Event text; may be `""` for photo-only or `moderator_appointed`; zap amount as a decimal string. */ + /** Event text; may be `""` for photo-only or `moderator_appointed`; subject name for `moderator_proposal`; zap amount as a decimal string. */ text: string; /** Creation instant. */ createdAt: Date; @@ -64,13 +69,13 @@ export interface PublicNotification { id: string; /** Notification kind. */ type: NotificationType; - /** Forum note the event refers to (the post itself for `forum_post`; subject account id for `moderator_appointed`). */ + /** Forum note the event refers to (the post itself for `forum_post`; subject account id for staff trust kinds). */ parentId: string; - /** Event id (`post.id`, `reply.id`, zap receipt UUID, or subject account id for `moderator_appointed`). */ + /** Event id (`post.id`, `reply.id`, zap receipt UUID, or subject account id for staff trust kinds). */ replyId: string; /** Actor display-name snapshot. */ name: string; - /** Event text; may be `""` for photo-only or `moderator_appointed`; zap amount as a decimal string. */ + /** Event text; may be `""` for photo-only or `moderator_appointed`; subject name for `moderator_proposal`; zap amount as a decimal string. */ text: string; /** ISO-8601 creation timestamp. */ createdAt: string; @@ -170,8 +175,8 @@ export function wantsNotification(args: { /** * Keep stored in-app rows that the owner's current {@link NotificationLevel} - * would still accept. `moderator_appointed` always stays (targeted, not - * living-room fan-out). `all` returns `rows` unchanged. Parent lookup uses + * would still accept. `moderator_appointed` and `moderator_proposal` always + * stay (not living-room fan-out). `all` returns `rows` unchanged. Parent lookup uses * `parentById` (`forum_post` / `forum_reply` / `zap` `parentId`); a missing * parent is unpaid and not personal. Zap `text` is the amount string. * Zap actor staff is the stored actor via {@link isStaffAccount} only when @@ -194,7 +199,7 @@ export function notificationsMatchingLevel(args: { args.accounts.map((account) => [account.id, isStaffAccount(account)] as const), ); return args.rows.filter((row) => { - if (row.type === 'moderator_appointed') { + if (row.type === 'moderator_appointed' || row.type === 'moderator_proposal') { return true; } const parent = args.parentById.get(row.parentId); @@ -789,3 +794,113 @@ export async function notifyModeratorAppointed(args: { throw new Error('push.fanout.failed'); } } + +/** + * Notify other staff of an open moderator proposal (not a living-room + * fan-out). Persist a `moderator_proposal` row for each recipient when + * `notifications` is set, and enqueue one Web Push (`url` + * `/moderate/proposals`, tag `moderator_proposal:`, outbox + * `type: 'forum'`) when `pushStore` is set. Skip the proposing actor, + * `isPlatform === true`, and anyone below moderator. Founder is included. + * Missing both stores is a no-op. Unique duplicate create is fine. This + * helper may throw; callers wrap it. Mark-read does not dismiss these rows. + * + * @param args - Optional stores, recipients, subject, actor, clock. + * @returns Resolves after the optional persist and push enqueue (including no-ops). + * @throws If recipient `create`, `unreadCount`, or `enqueue` rejects. + */ +export async function notifyModeratorProposed(args: { + /** Optional notification persistence. */ + notifications?: NotificationStore; + /** Optional push outbox. */ + pushStore?: PushStore; + /** Optional listed inbox unread; missing contributes 0. */ + inboxUnreadCount?: (accountId: string) => Promise; + /** Live accounts to consider; filtered to other staff here. */ + recipients: readonly { id: string; isPlatform?: boolean; role: AccountRole }[]; + /** Account that was proposed. */ + subject: { id: string; name: string | null }; + /** Staff member who proposed. */ + actor: { id: string; name: string | null }; + /** Enqueue / row clock. */ + nowMs: number; +}): Promise { + if (args.notifications === undefined && args.pushStore === undefined) { + return; + } + const staffIds: string[] = []; + for (const account of args.recipients) { + if (account.id === args.actor.id || account.isPlatform === true) { + continue; + } + if (!roleAtLeast(account.role, 'moderator')) { + continue; + } + staffIds.push(account.id); + } + const createdAt = new Date(args.nowMs); + const actorName = args.actor.name ?? 'Someone'; + const subjectText = args.subject.name ?? ''; + let failed = false; + if (args.notifications !== undefined) { + for (const accountId of staffIds) { + try { + await args.notifications.create({ + id: crypto.randomUUID(), + recipientAccountId: accountId, + actorAccountId: args.actor.id, + type: 'moderator_proposal', + parentId: args.subject.id, + replyId: args.subject.id, + name: actorName, + text: subjectText, + createdAt, + readAt: null, + }); + } catch { + failed = true; + logEvent('push.fanout.failed'); + } + } + } + if (args.pushStore !== undefined) { + const base = { + type: 'forum' as const, + title: 'Moderator proposal', + body: 'A verified member was proposed as moderator.', + url: '/moderate/proposals', + tag: `moderator_proposal:${args.subject.id}`, + }; + for (const accountId of staffIds) { + try { + let payload = JSON.stringify(base); + if (args.notifications !== undefined || args.inboxUnreadCount !== undefined) { + const notifUnread = + args.notifications === undefined ? 0 : await args.notifications.unreadCount(accountId); + const inboxUnread = + args.inboxUnreadCount === undefined ? 0 : await args.inboxUnreadCount(accountId); + payload = JSON.stringify({ ...base, unreadCount: notifUnread + inboxUnread }); + } + const row: PushOutboxRow = { + id: crypto.randomUUID(), + accountId, + type: 'forum', + messageId: args.subject.id, + payload, + status: 'pending', + attempts: 0, + claimedUntil: null, + createdAt, + deliveredEndpoints: [], + }; + await args.pushStore.enqueue(row); + } catch { + failed = true; + logEvent('push.fanout.failed'); + } + } + } + if (failed) { + throw new Error('push.fanout.failed'); + } +} diff --git a/src/lib/trust-store.ts b/src/lib/trust-store.ts index d5199c9a8..af3d9b5b1 100644 --- a/src/lib/trust-store.ts +++ b/src/lib/trust-store.ts @@ -8,9 +8,16 @@ import { isUniqueViolation, type SqlClient } from '@/lib/auth/sql'; import type { TrustEdge, TrustKind } from '@/lib/trust'; -/** Message thrown when `(subjectId, kind)` is already stored. */ +/** Message thrown when a live-unique `(subjectId, kind)` is already stored. */ const DUPLICATE_TRUST_EDGE = 'duplicate trust edge'; +/** One-per-subject kinds. Propose and reject may repeat. */ +const LIVE_UNIQUE_TRUST_KINDS: ReadonlySet = new Set([ + 'verify', + 'moderator_confirm', + 'moderator_appoint', +]); + /** * Persistence port for trust edges. */ @@ -39,7 +46,9 @@ export interface TrustStore { listEdgesTouching(accountId: string): Promise; /** - * Insert. Rejects a duplicate `(subjectId, kind)`. + * Insert. Rejects a duplicate live-unique `(subjectId, kind)` + * (`verify`, `moderator_confirm`, `moderator_appoint`). Propose and reject + * may repeat. * * @param edge - Fully formed edge (id, subject, actor, kind, time). * @returns The stored edge (a copy is fine). @@ -48,13 +57,22 @@ export interface TrustStore { insertEdge(edge: TrustEdge): Promise; /** - * Delete the stored `(subjectId, kind)` row, if any. + * Delete the latest stored row of `(subjectId, kind)` (`createdAt` desc, + * then `id` desc), if any. * * @param subjectId - Account that received the status. * @param kind - Grant kind to remove. * @returns A copy of the deleted edge, or `undefined` when none matched. */ deleteEdge(subjectId: string, kind: TrustKind): Promise; + + /** + * Delete the row with this `id`, if any. + * + * @param id - Stored edge id. + * @returns A copy of the deleted edge, or `undefined` when none matched. + */ + deleteEdgeById(id: string): Promise; } /** Idempotent DDL for the trust_edge table (matches `docs/schema/trust_edge.sql`). */ @@ -63,11 +81,14 @@ export const TRUST_SCHEMA_SQL: readonly string[] = [ id uuid PRIMARY KEY, subject_id uuid NOT NULL REFERENCES account (id), actor_id uuid NOT NULL REFERENCES account (id), - kind text NOT NULL CHECK (kind IN ('verify', 'moderator_propose', 'moderator_confirm', 'moderator_appoint')), + kind text NOT NULL CHECK (kind IN ('verify', 'moderator_propose', 'moderator_confirm', 'moderator_appoint', 'moderator_reject')), created_at timestamptz NOT NULL, CHECK (subject_id <> actor_id) )`, - `CREATE UNIQUE INDEX IF NOT EXISTS trust_edge_subject_kind_uidx ON trust_edge (subject_id, kind)`, + `ALTER TABLE trust_edge DROP CONSTRAINT IF EXISTS trust_edge_kind_check`, + `ALTER TABLE trust_edge ADD CONSTRAINT trust_edge_kind_check CHECK (kind IN ('verify', 'moderator_propose', 'moderator_confirm', 'moderator_appoint', 'moderator_reject'))`, + `DROP INDEX IF EXISTS trust_edge_subject_kind_uidx`, + `CREATE UNIQUE INDEX IF NOT EXISTS trust_edge_subject_kind_live_uidx ON trust_edge (subject_id, kind) WHERE kind IN ('verify', 'moderator_confirm', 'moderator_appoint')`, `CREATE INDEX IF NOT EXISTS trust_edge_actor_idx ON trust_edge (actor_id)`, ]; @@ -137,14 +158,16 @@ export class InMemoryTrustStore implements TrustStore { * * @param edge - Edge to store. * @returns A copy of the stored edge. - * @throws Error with message {@link DUPLICATE_TRUST_EDGE} when that pair exists. + * @throws Error with message {@link DUPLICATE_TRUST_EDGE} when a live-unique pair exists. */ async insertEdge(edge: TrustEdge): Promise { - const duplicate = this.#edges.some( - (stored) => stored.subjectId === edge.subjectId && stored.kind === edge.kind, - ); - if (duplicate) { - throw new Error(DUPLICATE_TRUST_EDGE); + if (LIVE_UNIQUE_TRUST_KINDS.has(edge.kind)) { + const duplicate = this.#edges.some( + (stored) => stored.subjectId === edge.subjectId && stored.kind === edge.kind, + ); + if (duplicate) { + throw new Error(DUPLICATE_TRUST_EDGE); + } } const stored = copyEdge(edge); this.#edges.push(stored); @@ -152,26 +175,54 @@ export class InMemoryTrustStore implements TrustStore { } /** - * Remove the `(subjectId, kind)` row and return a copy, or `undefined`. + * Remove the latest `(subjectId, kind)` row and return a copy, or `undefined`. * * @param subjectId - Account that received the status. * @param kind - Grant kind to remove. * @returns A copy of the deleted edge, or `undefined`. */ deleteEdge(subjectId: string, kind: TrustKind): Promise { - const index = this.#edges.findIndex( - (stored) => stored.subjectId === subjectId && stored.kind === kind, - ); + let bestIndex = -1; + let best: TrustEdge | undefined; + for (let i = 0; i < this.#edges.length; i += 1) { + const stored = this.#edges[i]; + if (stored === undefined || stored.subjectId !== subjectId || stored.kind !== kind) { + continue; + } + if ( + best === undefined || + stored.createdAt > best.createdAt || + (stored.createdAt === best.createdAt && stored.id > best.id) + ) { + best = stored; + bestIndex = i; + } + } + if (bestIndex < 0 || best === undefined) { + return Promise.resolve(undefined); + } + this.#edges.splice(bestIndex, 1); + return Promise.resolve(copyEdge(best)); + } + + /** + * Remove the row with this `id` and return a copy, or `undefined`. + * + * @param id - Stored edge id. + * @returns A copy of the deleted edge, or `undefined`. + */ + deleteEdgeById(id: string): Promise { + const index = this.#edges.findIndex((stored) => stored.id === id); if (index < 0) { return Promise.resolve(undefined); } - const removed = this.#edges[index]; - /* v8 ignore next 3 -- findIndex ≥ 0 always yields a row */ - if (removed === undefined) { + const stored = this.#edges[index]; + /* v8 ignore next 3 -- findIndex >= 0 means the slot exists */ + if (stored === undefined) { return Promise.resolve(undefined); } this.#edges.splice(index, 1); - return Promise.resolve(copyEdge(removed)); + return Promise.resolve(copyEdge(stored)); } } @@ -271,17 +322,40 @@ export class PostgresTrustStore implements TrustStore { } /** - * Delete the `(subjectId, kind)` row from `trust_edge`. + * Delete the latest `(subjectId, kind)` row from `trust_edge`. * - * @param subjectId - Account that received the status (`$1`). - * @param kind - Grant kind (`$2`). + * @param subjectId - Account that received the status (`$1` on the select). + * @param kind - Grant kind (`$2` on the select). * @returns The deleted row, or `undefined` when none matched. */ async deleteEdge(subjectId: string, kind: TrustKind): Promise { - const rows = await this.#sql.query( - `DELETE FROM trust_edge WHERE subject_id = $1 AND kind = $2 RETURNING id, subject_id, actor_id, kind, created_at`, + const latest = await this.#sql.query( + `SELECT id, subject_id, actor_id, kind, created_at FROM trust_edge WHERE subject_id = $1 AND kind = $2 ORDER BY created_at DESC, id DESC LIMIT 1`, [subjectId, kind], ); + const found = latest[0]; + if (found === undefined) { + return undefined; + } + const rows = await this.#sql.query( + `DELETE FROM trust_edge WHERE id = $1 RETURNING id, subject_id, actor_id, kind, created_at`, + [found.id], + ); + const row = rows[0]; + return row === undefined ? undefined : mapTrustRow(row); + } + + /** + * Delete the `trust_edge` row with this `id`. + * + * @param id - Stored edge id (`$1`). + * @returns The deleted row, or `undefined` when none matched. + */ + async deleteEdgeById(id: string): Promise { + const rows = await this.#sql.query( + `DELETE FROM trust_edge WHERE id = $1 RETURNING id, subject_id, actor_id, kind, created_at`, + [id], + ); const row = rows[0]; return row === undefined ? undefined : mapTrustRow(row); } diff --git a/src/lib/trust.ts b/src/lib/trust.ts index 942b37579..ac3978b90 100644 --- a/src/lib/trust.ts +++ b/src/lib/trust.ts @@ -4,18 +4,21 @@ * `verified` is a moderator confirming this person in real life * (forum badge), not Lightning-Address proof-of-control. Public * {@link buildTrustChain} never invents edges. At most one public incoming - * kind per subject: the oldest eligible sibling (`createdAt` then `id`). - * Eligible: `verify`, `moderator_appoint`, and `moderator_propose` only when - * the live subject is a `moderator`. `moderator_confirm` never. A pending - * propose (subject still `verified`) stays private. Later appoint, confirm, - * or propose do not replace an earlier eligible contact. + * edge per subject: the oldest eligible sibling (`createdAt` then `id`), + * skipping a non-chain oldest sibling so a later displayable contact can + * show. Eligible: `verify`, `moderator_appoint`, and `moderator_propose` + * only when the live subject is a `moderator`. `moderator_confirm` and + * `moderator_reject` never. A pending propose (subject still `verified`) + * stays private. Later appoint, confirm, or propose do not replace an + * earlier eligible contact. */ import { roleAtLeast } from '@/lib/auth/roles'; import type { Account, AccountRole } from '@/lib/auth/store'; /** Stored grant kind. Pending `moderator_propose` is staff-only until the subject is a moderator. */ -export type TrustKind = 'verify' | 'moderator_propose' | 'moderator_confirm' | 'moderator_appoint'; +export type TrustKind = + 'verify' | 'moderator_propose' | 'moderator_confirm' | 'moderator_appoint' | 'moderator_reject'; /** Edge kinds that appear on the public trust chain. */ export type TrustChainKind = 'verify' | 'moderator_propose' | 'moderator_appoint'; @@ -85,10 +88,13 @@ export interface AccountTrust { /** * One pending moderator proposal (staff list only; not the public chain). * - * Pending means a stored `moderator_propose` whose live subject is still - * `verified` and has no `moderator_confirm` or `moderator_appoint`. + * Pending means the latest propose/reject edge is `moderator_propose`, + * the live subject is still `verified`, and there is no + * `moderator_confirm` or `moderator_appoint`. */ export interface ModeratorProposal { + /** Propose-edge id (pending identity; same-ms ties use this, not actor+time). */ + id: string; /** Live subject; role is always `verified` for a pending row. */ subject: { id: string; name: string | null; role: 'verified' }; /** Propose-edge actor; missing account → `{ id, name: null }`. */ @@ -133,14 +139,15 @@ export function isStaffRole(role: AccountRole): boolean { * Nodes are accounts whose role is `founder`, `moderator`, or `verified` * (never `basis`), sorted founder → moderator → verified, then oldest * `createdAt`, then `id`. Groups stored edges by `subjectId` and projects - * at most one incoming kind per subject: the oldest eligible sibling - * (`createdAt` then `id`). Eligible: `verify`, `moderator_appoint`, and + * at most one incoming edge per subject: the oldest eligible sibling + * (`createdAt` then `id`), skipping a non-chain oldest sibling so a later + * displayable contact can show. Eligible: `verify`, `moderator_appoint`, and * `moderator_propose` only when the live subject is a `moderator`. - * `moderator_confirm` never. A pending propose (subject still `verified`) - * stays private. Later appoint, confirm, or propose do not replace an - * earlier eligible contact. Actor and subject must both be in the node - * set. No synthetic edges. Lightning addresses, view keys, and linking - * keys are omitted. + * `moderator_confirm` and `moderator_reject` never. A pending propose + * (subject still `verified`) stays private. Later appoint, confirm, or + * propose do not replace an earlier eligible contact. Actor and subject + * must both be in the node set. No synthetic edges. Lightning addresses, + * view keys, and linking keys are omitted. * * @param accounts - Live accounts (roles as stored). * @param edges - Stored trust edges (any order). @@ -174,7 +181,7 @@ export function buildTrustChain( if (siblings === undefined) { continue; } - if (!isProjectedTrustEdge(edge, byId.get(edge.subjectId), siblings)) { + if (!isProjectedTrustEdge(edge, byId.get(edge.subjectId), siblings, nodeIds)) { continue; } if (!nodeIds.has(edge.actorId) || !nodeIds.has(edge.subjectId)) { @@ -215,13 +222,14 @@ export function accountTrust( /** * Pending moderator proposals for the staff queue. * - * A row is pending when a `moderator_propose` edge exists, the live - * subject account is `verified`, and that subject has no - * `moderator_confirm` and no `moderator_appoint` (any actor). Missing - * subject accounts are omitted. Several proposes for one subject keep - * the latest by `createdAt` then `id` (same tie-break as - * {@link accountTrust}). `proposedBy` uses live actor names; a missing - * actor is `{ id, name: null }`. Sorted oldest `createdAt` first, then + * A subject with any `moderator_confirm` or `moderator_appoint` is + * closed. Among that subject's `moderator_propose` and + * `moderator_reject` edges, take the latest by `createdAt` then `id`. + * Pending iff that latest edge is `moderator_propose` and the live + * subject is `verified`. Missing subject accounts are omitted. + * `id` / `proposedBy` / `createdAt` come from that latest propose edge. + * `proposedBy` uses live actor names; a missing actor is + * `{ id, name: null }`. Sorted oldest `createdAt` first, then * propose-edge `id` (FIFO). Never includes `basis` / `moderator` / * `founder` subjects. Pure; no I/O. * @@ -235,27 +243,27 @@ export function pendingModeratorProposals( ): ModeratorProposal[] { const byId = new Map(accounts.map((account) => [account.id, account])); const closed = new Set(); - const latestPropose = new Map(); + const latestLifecycle = new Map(); for (const edge of edges) { if (edge.kind === 'moderator_confirm' || edge.kind === 'moderator_appoint') { closed.add(edge.subjectId); continue; } - if (edge.kind !== 'moderator_propose') { + if (edge.kind !== 'moderator_propose' && edge.kind !== 'moderator_reject') { continue; } - const prev = latestPropose.get(edge.subjectId); + const prev = latestLifecycle.get(edge.subjectId); if ( prev === undefined || edge.createdAt > prev.createdAt || (edge.createdAt === prev.createdAt && edge.id > prev.id) ) { - latestPropose.set(edge.subjectId, edge); + latestLifecycle.set(edge.subjectId, edge); } } const pending: { edge: TrustEdge; account: Account }[] = []; - for (const edge of latestPropose.values()) { - if (closed.has(edge.subjectId)) { + for (const edge of latestLifecycle.values()) { + if (closed.has(edge.subjectId) || edge.kind !== 'moderator_propose') { continue; } const account = byId.get(edge.subjectId); @@ -266,6 +274,7 @@ export function pendingModeratorProposals( } pending.sort((a, b) => compareTrustEdgesOldestFirst(a.edge, b.edge)); return pending.map(({ edge, account }) => ({ + id: edge.id, subject: { id: account.id, name: account.name, role: 'verified' }, proposedBy: { id: edge.actorId, name: byId.get(edge.actorId)?.name ?? null }, createdAt: edge.createdAt, @@ -303,34 +312,41 @@ export function isChainAccount( /** * Whether a stored edge appears on the public trust chain. * - * True iff `edge.kind` is the oldest eligible kind among `subjectEdges` + * True iff `edge` is the oldest eligible sibling among `subjectEdges` * (default `[edge]`), by `createdAt` then `id`. Eligible: `verify`, * `moderator_appoint`, and `moderator_propose` only when the live subject - * is a `moderator`. `moderator_confirm` never. Later siblings do not - * replace an earlier eligible contact. + * is a `moderator`. `moderator_confirm` and `moderator_reject` never. + * Later siblings do not replace an earlier eligible contact. At most one + * public incoming edge per subject, even when several rows share the + * winning kind. * * @param edge - Stored grant. * @param subject - Live subject account, if loaded. * @param subjectEdges - Stored edges for this subject (default `[edge]`). - * @returns `true` when the edge is the public incoming kind. + * @param chainActorIds - When set, skip siblings whose actor is not a + * public-chain node so a non-chain oldest sibling does not hide a later + * displayable contact. + * @returns `true` when the edge is the public incoming edge. */ export function isProjectedTrustEdge( edge: TrustEdge, subject: Account | undefined, subjectEdges: readonly TrustEdge[] = [edge], + chainActorIds?: ReadonlySet, ): edge is TrustEdge & { kind: TrustChainKind } { - const winning = winningPublicKind(subject, subjectEdges); - return winning !== undefined && edge.kind === winning; + const winning = winningPublicEdge(subject, subjectEdges, chainActorIds); + return winning !== undefined && edge.id === winning.id; } -/** Oldest eligible public incoming kind among `subjectEdges`, or none. */ -function winningPublicKind( +/** Oldest eligible public incoming edge among `subjectEdges`, or none. */ +function winningPublicEdge( subject: Account | undefined, subjectEdges: readonly TrustEdge[], -): TrustChainKind | undefined { + chainActorIds?: ReadonlySet, +): (TrustEdge & { kind: TrustChainKind }) | undefined { const eligible: TrustEdge[] = []; for (const sibling of subjectEdges) { - if (sibling.kind === 'moderator_confirm') { + if (sibling.kind === 'moderator_confirm' || sibling.kind === 'moderator_reject') { continue; } if (sibling.kind === 'moderator_propose' && subject?.role !== 'moderator') { @@ -347,13 +363,18 @@ function winningPublicKind( if (eligible.length === 0) { return undefined; } - const oldest = eligible.slice().sort(compareTrustEdgesOldestFirst)[0]; - /* v8 ignore next 3 -- eligible.length === 0 already returned */ - if (oldest === undefined) { - return undefined; + const sorted = eligible.slice().sort(compareTrustEdgesOldestFirst); + for (const oldest of sorted) { + /* v8 ignore next 3 -- confirm/reject never enter eligible */ + if (oldest.kind === 'moderator_confirm' || oldest.kind === 'moderator_reject') { + continue; + } + if (chainActorIds !== undefined && !chainActorIds.has(oldest.actorId)) { + continue; + } + return oldest as TrustEdge & { kind: TrustChainKind }; } - /* v8 ignore next -- confirm was filtered from eligible */ - return oldest.kind === 'moderator_confirm' ? undefined : oldest.kind; + return undefined; } /** Oldest `createdAt` first, then `id`. */ diff --git a/src/routes/debug-trust.ts b/src/routes/debug-trust.ts index 3ae531b2a..a370bf844 100644 --- a/src/routes/debug-trust.ts +++ b/src/routes/debug-trust.ts @@ -30,7 +30,13 @@ export interface DebugTrustRouteDeps { const insertBody = z.object({ subjectId: z.string(), actorId: z.string(), - kind: z.enum(['verify', 'moderator_propose', 'moderator_confirm', 'moderator_appoint']), + kind: z.enum([ + 'verify', + 'moderator_propose', + 'moderator_confirm', + 'moderator_appoint', + 'moderator_reject', + ]), }); /** Shared 503/401 gate for `/debug/trust-edges`. */ @@ -50,7 +56,13 @@ function requireDebugToken(deps: DebugTrustRouteDeps): MiddlewareHandler { /** Body schema for operator trust-edge delete. */ const deleteBody = z.object({ subjectId: z.string(), - kind: z.enum(['verify', 'moderator_propose', 'moderator_confirm', 'moderator_appoint']), + kind: z.enum([ + 'verify', + 'moderator_propose', + 'moderator_confirm', + 'moderator_appoint', + 'moderator_reject', + ]), }); /** diff --git a/src/routes/notifications.ts b/src/routes/notifications.ts index a1c90df36..49e6e507f 100644 --- a/src/routes/notifications.ts +++ b/src/routes/notifications.ts @@ -86,7 +86,7 @@ export function notificationRoutes(deps: NotificationRouteDeps): Hono { const kept = []; const droppedMessageIds = new Set(); for (const row of matched) { - if (row.type === 'moderator_appointed') { + if (row.type === 'moderator_appointed' || row.type === 'moderator_proposal') { kept.push(row); continue; } diff --git a/src/routes/trust-chain.ts b/src/routes/trust-chain.ts index e0d45cf80..d0586f66b 100644 --- a/src/routes/trust-chain.ts +++ b/src/routes/trust-chain.ts @@ -13,12 +13,13 @@ import { bearerToken } from '@/routes/me'; * * Bare `GET /trust-chain` returns founder seeds (no edges) so a thousand-person * chain is not dumped on first paint. `?around=` returns that account plus - * one hop of the oldest eligible public kind per subject (`createdAt` then - * `id`). Eligible: `verify`, `moderator_appoint`, and `moderator_propose` only - * when the live subject is a `moderator`. `moderator_confirm` is never - * projected. Neighborhood loads all edges for each subject in the touching - * set (`listEdgesForSubject`) so a non-touching older eligible edge still - * wins over a touching newer one. A pending propose (subject still + * one hop of the oldest eligible public edge per subject (`createdAt` then + * `id`), skipping a non-chain oldest sibling so a later displayable contact + * can show. Eligible: `verify`, `moderator_appoint`, and `moderator_propose` + * only when the live subject is a `moderator`. `moderator_confirm` and + * `moderator_reject` never. Neighborhood loads all edges for each subject in + * the touching set (`listEdgesForSubject`) so a non-touching older eligible + * edge still wins over a touching newer one. A pending propose (subject still * `verified`) stays private and is not a hop neighbor. Later appoint, * confirm, or propose do not replace an earlier eligible contact. */ @@ -89,14 +90,15 @@ function isInvalidUuid(error: unknown): boolean { /** * One hop around `aroundId`: the focus account, stored public edges of the - * oldest eligible kind per subject after `isProjectedTrustEdge` (`createdAt` + * oldest eligible edge per subject after `isProjectedTrustEdge` (`createdAt` * then `id`; `verify`, `moderator_appoint`, and `moderator_propose` only when - * the live subject is a `moderator`; never `moderator_confirm`), and the + * the live subject is a `moderator`; never `moderator_confirm`), skipping a + * non-chain oldest sibling so a later displayable contact can show, and the * accounts on those filtered edges. Loads all edges for each touching subject * (`listEdgesForSubject`) so a non-touching older eligible edge still wins * over a touching newer one. Pending-propose verified neighbors are not - * nodes. Confirm never. Later appoint, confirm, or propose do not replace - * an earlier eligible contact. + * nodes. Confirm and reject never. Later appoint, confirm, or propose do not + * replace an earlier eligible contact. * * @param deps - Auth and trust stores. * @param aroundId - Focus account id. @@ -141,13 +143,30 @@ async function neighborhood( siblingsBySubject.set(subjectId, await deps.trustStore.listEdgesForSubject(subjectId)); }), ); + for (const siblings of siblingsBySubject.values()) { + for (const sibling of siblings) { + if (byId.has(sibling.actorId)) { + continue; + } + const account = await deps.authStore.getAccount(sibling.actorId); + if (account !== undefined) { + byId.set(sibling.actorId, account); + } + } + } + const chainActorIds = new Set(); + for (const account of byId.values()) { + if (isChainAccount(account)) { + chainActorIds.add(account.id); + } + } const edges = touching.filter((edge) => { const siblings = siblingsBySubject.get(edge.subjectId); /* v8 ignore next 3 -- Promise.all set a list for every touching subjectId */ if (siblings === undefined) { return false; } - return isProjectedTrustEdge(edge, byId.get(edge.subjectId), siblings); + return isProjectedTrustEdge(edge, byId.get(edge.subjectId), siblings, chainActorIds); }); const ids = new Set([aroundId]); for (const edge of edges) { diff --git a/src/routes/trust.ts b/src/routes/trust.ts index 2c79afbc3..7d679dc0c 100644 --- a/src/routes/trust.ts +++ b/src/routes/trust.ts @@ -6,7 +6,7 @@ import type { Account, AuthStore } from '@/lib/auth/store'; import { inboxUnreadCountFor } from '@/lib/conversation-push'; import type { ConversationStore } from '@/lib/conversation-store'; import { logEvent } from '@/lib/log'; -import { notifyModeratorAppointed } from '@/lib/notification'; +import { notifyModeratorAppointed, notifyModeratorProposed } from '@/lib/notification'; import type { NotificationStore } from '@/lib/notification-store'; import type { PushStore } from '@/lib/push-store'; import { @@ -21,7 +21,7 @@ import { MESSAGE_ID_RE } from '@/routes/messages'; /** * Staff trust routes: list pending moderator proposals, verify a person, - * propose/confirm a moderator, or appoint a moderator as a founder. + * propose/confirm/reject a moderator, or appoint a moderator as a founder. * Bearer session required. */ @@ -37,7 +37,7 @@ export interface TrustRouteDeps { notificationStore?: NotificationStore; /** Optional Web Push outbox. */ pushStore?: PushStore; - /** Optional conversation store so appointed push unreadCount includes inbox. */ + /** Optional conversation store so appointed and propose push unreadCount include inbox. */ conversationStore?: ConversationStore; } @@ -95,10 +95,11 @@ async function loadTargetAccount( * * Mounted at `/trust` so the public paths are `GET /trust/proposals`, * `POST /trust/verify`, `POST /trust/propose-moderator`, - * `POST /trust/confirm-moderator`, and `POST /trust/appoint-moderator`. + * `POST /trust/confirm-moderator`, `POST /trust/reject-moderator`, and + * `POST /trust/appoint-moderator`. * * @param deps - Auth store, trust-edge store, clock, optional notification/push stores, and optional conversation store. - * @returns A Hono app with the staff GET and four staff POSTs. + * @returns A Hono app with the staff GET and five staff POSTs. */ export function trustRoutes(deps: TrustRouteDeps): Hono { return new Hono() @@ -116,6 +117,7 @@ export function trustRoutes(deps: TrustRouteDeps): Hono { deps.trustStore.listEdges(), ]); const proposals = pendingModeratorProposals(accounts, edges).map((row) => ({ + id: row.id, subject: row.subject, proposedBy: row.proposedBy, createdAt: new Date(row.createdAt).toISOString(), @@ -228,17 +230,15 @@ export function trustRoutes(deps: TrustRouteDeps): Hono { logEvent('trust.write.failed'); return c.json({ error: 'Trust chain is unavailable' }, 503); } - const hasStaffGrant = existing.some( - (edge) => - edge.kind === 'moderator_propose' || - edge.kind === 'moderator_confirm' || - edge.kind === 'moderator_appoint', + const hasClosedGrant = existing.some( + (edge) => edge.kind === 'moderator_confirm' || edge.kind === 'moderator_appoint', ); - if (hasStaffGrant) { + if (hasClosedGrant || pendingModeratorProposals([subject], existing).length > 0) { return c.json({ error: 'Conflict' }, 409); } + const created = newEdge(deps, subject.id, caller.id, 'moderator_propose'); try { - await deps.trustStore.insertEdge(newEdge(deps, subject.id, caller.id, 'moderator_propose')); + await deps.trustStore.insertEdge(created); } catch (error) { if (isDuplicateTrustEdge(error)) { return c.json({ error: 'Conflict' }, 409); @@ -246,7 +246,55 @@ export function trustRoutes(deps: TrustRouteDeps): Hono { logEvent('trust.write.failed'); return c.json({ error: 'Trust chain is unavailable' }, 503); } + let extras: Array<{ id: string }> = []; + try { + const after = await deps.trustStore.listEdgesForSubject(subject.id); + if ( + after.some( + (edge) => edge.kind === 'moderator_confirm' || edge.kind === 'moderator_appoint', + ) + ) { + await deps.trustStore.deleteEdgeById(created.id); + return c.json({ error: 'Conflict' }, 409); + } + const open = openProposesForSubject(after, subject.id); + const oldest = oldestOpenPropose(open); + if (oldest === undefined || oldest.id !== created.id) { + await deps.trustStore.deleteEdgeById(created.id); + try { + const remaining = await deps.trustStore.listEdgesForSubject(subject.id); + const live = pendingModeratorProposals([subject], remaining)[0]; + await clearModeratorProposalNotifications(deps, subject.id); + if (live !== undefined) { + await fanOutPendingProposal(deps, subject, live.id); + } + } catch { + logEvent('push.enqueue.failed'); + } + return c.json({ error: 'Conflict' }, 409); + } + extras = open.filter((row) => row.id !== created.id); + } catch { + /* v8 ignore next 5 -- rollback throw still 503 */ + try { + await deps.trustStore.deleteEdgeById(created.id); + } catch { + /* still 503 */ + } + logEvent('trust.write.failed'); + return c.json({ error: 'Trust chain is unavailable' }, 503); + } + for (const extra of extras) { + try { + await deps.trustStore.deleteEdgeById(extra.id); + } catch { + logEvent('trust.write.failed'); + } + } logEvent('trust.moderator_proposed', { subjectId: subject.id, actorId: caller.id }); + await clearModeratorProposalNotifications(deps, subject.id); + await notifyStaffProposed(deps, subject, caller); + await reconcileOpenProposalNotifications(deps, subject, created.id); return c.json(accountSummary(subject), 200); }) .post('/confirm-moderator', async (c) => { @@ -285,6 +333,7 @@ export function trustRoutes(deps: TrustRouteDeps): Hono { return c.json({ error: 'Conflict' }, 409); } if (subject.role === 'moderator') { + await clearModeratorProposalNotifications(deps, subject.id); await notifySubjectAppointed(deps, subject, caller); return c.json(accountSummary(subject), 200); } @@ -299,20 +348,20 @@ export function trustRoutes(deps: TrustRouteDeps): Hono { return c.json({ error: 'Trust chain is unavailable' }, 503); } logEvent('trust.moderator_confirmed', { subjectId: subject.id, actorId: caller.id }); + await clearModeratorProposalNotifications(deps, subject.id); await notifySubjectAppointed(deps, subject, caller); return c.json(accountSummary(updated), 200); } if (subject.role !== 'verified') { return c.json({ error: 'Conflict' }, 409); } - const propose = existing.find((edge) => edge.kind === 'moderator_propose'); - if (propose === undefined || propose.actorId === caller.id) { + const pending = pendingModeratorProposals([subject], existing)[0]; + if (pending === undefined || pending.proposedBy.id === caller.id) { return c.json({ error: 'Conflict' }, 409); } - const updated = { ...subject, role: 'moderator' as const }; + const created = newEdge(deps, subject.id, caller.id, 'moderator_confirm'); try { - await deps.trustStore.insertEdge(newEdge(deps, subject.id, caller.id, 'moderator_confirm')); - await deps.authStore.updateAccount(updated); + await deps.trustStore.insertEdge(created); } catch (error) { if (isDuplicateTrustEdge(error)) { return c.json({ error: 'Conflict' }, 409); @@ -320,10 +369,140 @@ export function trustRoutes(deps: TrustRouteDeps): Hono { logEvent('trust.write.failed'); return c.json({ error: 'Trust chain is unavailable' }, 503); } + try { + const after = await deps.trustStore.listEdgesForSubject(subject.id); + const withoutConfirm = after.filter((edge) => edge.id !== created.id); + const still = pendingModeratorProposals([subject], withoutConfirm)[0]; + const oldest = oldestOpenPropose(openProposesForSubject(withoutConfirm, subject.id)); + if (still === undefined || still.id !== pending.id || oldest?.id !== still.id) { + await deps.trustStore.deleteEdgeById(created.id); + return c.json({ error: 'Conflict' }, 409); + } + } catch { + /* v8 ignore next 5 -- rollback throw still 503 */ + try { + await deps.trustStore.deleteEdgeById(created.id); + } catch { + /* still 503 */ + } + logEvent('trust.write.failed'); + return c.json({ error: 'Trust chain is unavailable' }, 503); + } + const updated = { ...subject, role: 'moderator' as const }; + try { + await deps.authStore.updateAccount(updated); + } catch { + logEvent('trust.write.failed'); + return c.json({ error: 'Trust chain is unavailable' }, 503); + } logEvent('trust.moderator_confirmed', { subjectId: subject.id, actorId: caller.id }); + await clearModeratorProposalNotifications(deps, subject.id); await notifySubjectAppointed(deps, subject, caller); return c.json(accountSummary(updated), 200); }) + .post('/reject-moderator', async (c) => { + const caller = await authedAccount(deps, c.req.header('authorization')); + if (caller === null) { + return c.json({ error: 'Unauthorized' }, 401); + } + if (!isStaffRole(caller.role)) { + return c.json({ error: 'Forbidden' }, 403); + } + const parsed = accountIdBody.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) { + return c.json({ error: 'Expected a JSON body with an "accountId" string' }, 400); + } + if (!MESSAGE_ID_RE.test(parsed.data.accountId)) { + return c.json({ error: 'Not found' }, 404); + } + const loaded = await loadTargetAccount(deps.authStore, parsed.data.accountId); + if ('status' in loaded) { + return c.json({ error: loaded.error }, loaded.status); + } + const subject = loaded.account; + if (subject.id === caller.id) { + return c.json({ error: 'Conflict' }, 409); + } + if (subject.role !== 'verified') { + return c.json({ error: 'Conflict' }, 409); + } + let existing: TrustEdge[]; + try { + existing = await deps.trustStore.listEdgesForSubject(subject.id); + } catch { + logEvent('trust.write.failed'); + return c.json({ error: 'Trust chain is unavailable' }, 503); + } + if (pendingModeratorProposals([subject], existing).length === 0) { + return c.json({ error: 'Conflict' }, 409); + } + const created = newEdge(deps, subject.id, caller.id, 'moderator_reject'); + try { + await deps.trustStore.insertEdge(created); + } catch (error) { + if (isDuplicateTrustEdge(error)) { + return c.json({ error: 'Conflict' }, 409); + } + logEvent('trust.write.failed'); + return c.json({ error: 'Trust chain is unavailable' }, 503); + } + let dropNotifications = false; + let wonEmpty = false; + try { + const after = await deps.trustStore.listEdgesForSubject(subject.id); + if ( + after.some( + (edge) => edge.kind === 'moderator_confirm' || edge.kind === 'moderator_appoint', + ) + ) { + await deps.trustStore.deleteEdgeById(created.id); + return c.json({ error: 'Conflict' }, 409); + } + const still = pendingModeratorProposals([subject], after); + if (still.length === 0) { + logEvent('trust.moderator_rejected', { subjectId: subject.id, actorId: caller.id }); + wonEmpty = true; + } else { + const beforePending = pendingModeratorProposals([subject], existing)[0]; + const open = still[0]; + if (beforePending !== undefined && open !== undefined && open.id === beforePending.id) { + await deps.trustStore.deleteEdgeById(created.id); + return c.json({ error: 'Conflict' }, 409); + } + logEvent('trust.moderator_rejected', { subjectId: subject.id, actorId: caller.id }); + } + } catch { + /* v8 ignore next 5 -- rollback throw still 503 */ + try { + await deps.trustStore.deleteEdgeById(created.id); + } catch { + /* still 503 */ + } + logEvent('trust.write.failed'); + return c.json({ error: 'Trust chain is unavailable' }, 503); + } + if (wonEmpty) { + try { + const latest = await deps.trustStore.listEdgesForSubject(subject.id); + dropNotifications = pendingModeratorProposals([subject], latest).length === 0; + } catch { + logEvent('push.enqueue.failed'); + } + } + if (dropNotifications) { + await clearModeratorProposalNotifications(deps, subject.id); + try { + const afterClear = await deps.trustStore.listEdgesForSubject(subject.id); + const reopened = pendingModeratorProposals([subject], afterClear)[0]; + if (reopened !== undefined) { + await fanOutPendingProposal(deps, subject, reopened.id); + } + } catch { + logEvent('push.enqueue.failed'); + } + } + return c.json(accountSummary(subject), 200); + }) .post('/appoint-moderator', async (c) => { const caller = await authedAccount(deps, c.req.header('authorization')); if (caller === null) { @@ -360,6 +539,7 @@ export function trustRoutes(deps: TrustRouteDeps): Hono { return c.json({ error: 'Conflict' }, 409); } if (subject.role === 'moderator') { + await clearModeratorProposalNotifications(deps, subject.id); await notifySubjectAppointed(deps, subject, caller); return c.json(accountSummary(subject), 200); } @@ -371,6 +551,7 @@ export function trustRoutes(deps: TrustRouteDeps): Hono { return c.json({ error: 'Trust chain is unavailable' }, 503); } logEvent('trust.moderator_appointed', { subjectId: subject.id, actorId: caller.id }); + await clearModeratorProposalNotifications(deps, subject.id); await notifySubjectAppointed(deps, subject, caller); return c.json(accountSummary(updated), 200); } @@ -389,11 +570,121 @@ export function trustRoutes(deps: TrustRouteDeps): Hono { return c.json({ error: 'Trust chain is unavailable' }, 503); } logEvent('trust.moderator_appointed', { subjectId: subject.id, actorId: caller.id }); + await clearModeratorProposalNotifications(deps, subject.id); await notifySubjectAppointed(deps, subject, caller); return c.json(accountSummary(updated), 200); }); } +/** Best-effort drop of open-proposal in-app rows; persist still 200. */ +async function clearModeratorProposalNotifications( + deps: TrustRouteDeps, + subjectId: string, +): Promise { + if (deps.notificationStore === undefined) { + return; + } + try { + await deps.notificationStore.deleteByTypeAndReplyId('moderator_proposal', subjectId); + } catch { + logEvent('notifications.hidden.purge_failed'); + } +} + +/** Best-effort staff fan-out of an open proposal; persist still 200. */ +async function notifyStaffProposed( + deps: TrustRouteDeps, + subject: Account, + actor: { id: string; name: string | null }, +): Promise { + try { + const recipients = await deps.authStore.listAccounts(); + await notifyModeratorProposed({ + recipients, + subject: { id: subject.id, name: subject.name }, + actor: { id: actor.id, name: actor.name }, + nowMs: deps.now(), + ...(deps.notificationStore === undefined ? {} : { notifications: deps.notificationStore }), + ...(deps.pushStore === undefined ? {} : { pushStore: deps.pushStore }), + /* v8 ignore next 4 -- createApp always injects conversationStore */ + ...(deps.conversationStore === undefined + ? {} + : { inboxUnreadCount: inboxUnreadCountFor(deps.conversationStore, deps.authStore) }), + }); + } catch { + logEvent('push.enqueue.failed'); + } +} + +/** + * Best-effort fan-out for one pending propose-edge id. Re-lists before + * and after notify so a concurrent reject/confirm cannot leave stale + * `moderator_proposal` rows. A pending-id change at depth 0 (first list + * or after notify) clears first then fans out once more; a mismatch at + * depth 1 drops the rows. Throws stay 200 for the caller. + */ +async function fanOutPendingProposal( + deps: TrustRouteDeps, + subject: Account, + proposeId: string, + depth = 0, +): Promise { + try { + const edges = await deps.trustStore.listEdgesForSubject(subject.id); + const pending = pendingModeratorProposals([subject], edges)[0]; + if (pending === undefined || pending.id !== proposeId) { + await clearModeratorProposalNotifications(deps, subject.id); + if (depth === 0 && pending !== undefined) { + await fanOutPendingProposal(deps, subject, pending.id, 1); + } + return; + } + const actor = await deps.authStore.getAccount(pending.proposedBy.id); + await notifyStaffProposed(deps, subject, { + id: pending.proposedBy.id, + name: actor === undefined ? null : actor.name, + }); + const after = await deps.trustStore.listEdgesForSubject(subject.id); + const now = pendingModeratorProposals([subject], after)[0]; + if (now === undefined) { + await clearModeratorProposalNotifications(deps, subject.id); + } else if (now.id !== proposeId) { + await clearModeratorProposalNotifications(deps, subject.id); + if (depth === 0) { + await fanOutPendingProposal(deps, subject, now.id, 1); + } + } + } catch { + logEvent('push.enqueue.failed'); + } +} + +/** + * After propose notify, drop stale rows when this insert is no longer + * pending. If a newer propose already reopened, fan out for that actor. + * Persist is already 200; list/notify throw stays 200. + */ +async function reconcileOpenProposalNotifications( + deps: TrustRouteDeps, + subject: Account, + proposeId: string, +): Promise { + try { + const after = await deps.trustStore.listEdgesForSubject(subject.id); + const pending = pendingModeratorProposals([subject], after)[0]; + if (pending !== undefined && pending.id === proposeId) { + return; + } + await clearModeratorProposalNotifications(deps, subject.id); + if (pending === undefined) { + return; + } + await fanOutPendingProposal(deps, subject, pending.id); + } catch { + logEvent('push.enqueue.failed'); + } +} + /** Best-effort targeted notify of the appointed subject; persist still 200. */ async function notifySubjectAppointed( deps: TrustRouteDeps, @@ -417,6 +708,54 @@ async function notifySubjectAppointed( } } +/** Oldest open propose by `createdAt` then `id`, or none. */ +function oldestOpenPropose(open: readonly TrustEdge[]): TrustEdge | undefined { + let oldest: TrustEdge | undefined; + for (const edge of open) { + if ( + oldest === undefined || + edge.createdAt < oldest.createdAt || + (edge.createdAt === oldest.createdAt && edge.id < oldest.id) + ) { + oldest = edge; + } + } + return oldest; +} + +/** Propose rows after the latest reject for `subjectId`, or all proposes. */ +function openProposesForSubject(edges: readonly TrustEdge[], subjectId: string): TrustEdge[] { + let latestReject: TrustEdge | undefined; + const proposes: TrustEdge[] = []; + for (const edge of edges) { + if (edge.subjectId !== subjectId) { + continue; + } + if (edge.kind === 'moderator_reject') { + if ( + latestReject === undefined || + edge.createdAt > latestReject.createdAt || + (edge.createdAt === latestReject.createdAt && edge.id > latestReject.id) + ) { + latestReject = edge; + } + } + if (edge.kind === 'moderator_propose') { + proposes.push(edge); + } + } + if (latestReject === undefined) { + return proposes; + } + const reject = latestReject; + return proposes.filter((edge) => { + if (edge.createdAt !== reject.createdAt) { + return edge.createdAt > reject.createdAt; + } + return edge.id > reject.id; + }); +} + /** Fully formed edge using the injected clock and a fresh uuid. */ function newEdge( deps: TrustRouteDeps,