diff --git a/SPEC.md b/SPEC.md index 88ad55e..717e866 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1954,3 +1954,7 @@ actions. Role values exist on the account model; `GET /debug/accounts` and - Internationalization (English only) - Platform custody of **receiver** funds (receiving stays LUD-16 only) - Arbitrary LNDHub URLs (the external spend worker uses lightning.space only) + +### DELETE /messages/:id + +Bearer founder/moderator only (live account role). Deletes the post, direct replies and associated stored media via the existing store; leaves gift records and external relay copies unchanged. Returns 204; unauthorized 401; forbidden 403; invalid/missing UUID 404; storage failure 503. Durable mutations retain the db_change trigger audit trail. diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 8494990..0810daa 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -398,3 +398,10 @@ - **Errors:** 401 without session; 400 for unknown step, `step: "rules"`, or bad JSON. - **Used by:** App onboarding skip controls (api-first; app proxy may follow later). - **Auth:** `Authorization: Bearer` session. + +## Endpoint: DELETE /messages/:id + +- **Purpose:** Remove a forum post (or reply) from 21.gifts, with direct replies and stored media, through the existing message store deletion. +- **Auth:** Bearer session required. Only the live founder or moderator role is allowed; authors with basis/verified roles receive 403 even on their own posts. +- **Returns:** 204 without a body; 401 without a valid session; 403 for other roles; 404 for invalid or missing ids; 503 on storage failure. +- **Side effects:** Existing store cleanup removes photos, videos, invoice attempts and zap receipts for deleted rows. Gift records are unchanged. Postgres db_change triggers record durable deletions; structured audit events include the acting account id. Already published copies on external Nostr relays are outside this local deletion. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index c1123de..8827d12 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -602,6 +602,8 @@ - **Returns / side effects:** Hono app mounted at `/messages`. 401 without session on list/create/replies/invoice; 409 `{ error: 'missing_requirements', missing }` when action gates fail; 400 on bad body / invalid text / bad media / unpaid note / author's-wallet / LNURL failures; 404 for bad `inReplyTo` / missing rows; 429 rate limits; 503 on store/KEK/sign failure. Signed-in list/replies/create may include `accountId`; public `GET /:id` never includes it. - **Used by:** `createApp`. +DELETE /messages/:id permits only a live founder or moderator session, delegates to MessageStore.deleteById, and logs the actor and message id. Returns 204, 401, 403, 404, or 503. + ## Function: contactRoutes - **Purpose:** Hono sub-app for the private in-app contact mailbox: `POST /` only (no member GET). After auth, `requireAction(account, 'contact.post')` (rules + name). After the platform account exists, persists the contact row first, then opens/appends the member→platform conversation thread. Conversation append failure logs `conversations.contact_sync.failed` and still 200. diff --git a/e2e/forum-replies.spec.ts b/e2e/forum-replies.spec.ts index 3241c68..75090b6 100644 --- a/e2e/forum-replies.spec.ts +++ b/e2e/forum-replies.spec.ts @@ -71,6 +71,18 @@ test('e2e: forum note, public read, reply, and replyCount against the booted API (await list.json()) as { messages: Array<{ id: string; replyCount?: number }> } ).messages; expect(listedNotes.find((row) => row.id === note.id)?.replyCount).toBe(1); + expect((await request.delete('/messages/' + note.id)).status()).toBe(401); + expect((await request.delete('/messages/' + note.id, { headers: auth })).status()).toBe(403); + const promoted = await request.patch('/debug/accounts/' + ada?.id, { + headers: DEBUG, + data: { role: 'moderator' }, + }); + expect(promoted.status()).toBe(200); + expect((await request.delete('/messages/' + note.id, { headers: auth })).status()).toBe(204); + expect((await request.get('/messages/' + note.id)).status()).toBe(404); + expect((await request.get('/messages/' + note.id + '/replies', { headers: auth })).status()).toBe( + 404, + ); }); test('Function: issueSession — POST /debug/accounts/:id/session with the e2e token is 200', async ({ @@ -101,3 +113,7 @@ test('Function: issueSession — POST /debug/accounts/:id/session with the e2e t const me = await request.get('/me', { headers: { authorization: `Bearer ${token}` } }); expect(me.status()).toBe(200); }); + +test('DELETE /messages/:id denies unauthenticated requests', async ({ request }) => { + expect((await request.delete('/messages/:id')).status()).toBe(401); +}); diff --git a/src/__tests__/routes/messages.test.ts b/src/__tests__/routes/messages.test.ts index a1e3d0b..63c0247 100644 --- a/src/__tests__/routes/messages.test.ts +++ b/src/__tests__/routes/messages.test.ts @@ -3211,3 +3211,92 @@ describe('forum video', () => { } }); }); + +describe('DELETE /messages/:id moderation', () => { + const id = '11111111-1111-4111-8111-111111111111'; + const childId = '22222222-2222-4222-8222-222222222222'; + + it('requires a session', async () => { + const app = mount(await namedStore('Ada')); + expect((await app.request('/messages/' + id, { method: 'DELETE' })).status).toBe(401); + }); + + it.each(['basis', 'verified'] as const)('denies %s even for their own note', async (role) => { + const auth = await namedStore('Ada'); + const account = (await auth.getAccount('acc'))!; + await auth.updateAccount({ ...account, role }); + const store = new InMemoryMessageStore(); + const app = mount(auth, store); + const spy = vi.spyOn(store, 'deleteById'); + expect((await app.request('/messages/' + id, { method: 'DELETE', headers: AUTH })).status).toBe( + 403, + ); + expect(spy).not.toHaveBeenCalled(); + }); + + it.each(['founder', 'moderator'] as const)( + 'allows %s to delete another author and its replies/photo', + async (role) => { + const auth = await namedStore('Ada'); + await auth.updateAccount({ ...(await auth.getAccount('acc'))!, role }); + const store = new InMemoryMessageStore(); + const parent = { + id, + accountId: 'another-author', + name: 'Bob', + text: 'A post', + createdAt: new Date(now()), + hasPhoto: true, + ...unsignedNostrDefaults(), + }; + await store.create(parent, { contentType: 'image/jpeg', bytes: JPEG_BYTES }); + await store.create({ + ...parent, + id: childId, + parentId: id, + text: 'A reply', + hasPhoto: false, + }); + const app = mount(auth, store); + const response = await app.request('/messages/' + id, { method: 'DELETE', headers: AUTH }); + expect(response.status).toBe(204); + expect(await response.text()).toBe(''); + expect(await store.getById(id)).toBeUndefined(); + expect(await store.getById(childId)).toBeUndefined(); + expect(await store.getPhoto(id)).toBeNull(); + expect((await app.request('/messages/' + id)).status).toBe(404); + expect((await app.request('/messages/' + id + '/photo')).status).toBe(404); + expect(parsedEvents(warn)).toContainEqual( + expect.objectContaining({ messageId: id, accountId: 'acc', role }), + ); + }, + ); + + it('rejects a demoted moderator using the same session', async () => { + const auth = await namedStore('Ada'); + const account = (await auth.getAccount('acc'))!; + await auth.updateAccount({ ...account, role: 'moderator' }); + const app = mount(auth); + await auth.updateAccount({ ...account, role: 'basis' }); + expect((await app.request('/messages/' + id, { method: 'DELETE', headers: AUTH })).status).toBe( + 403, + ); + }); + + it('returns 404 for malformed or missing ids and 503 on storage failure', async () => { + const auth = await namedStore('Ada'); + await auth.updateAccount({ ...(await auth.getAccount('acc'))!, role: 'founder' }); + const store = new InMemoryMessageStore(); + const app = mount(auth, store); + expect( + (await app.request('/messages/bad-id', { method: 'DELETE', headers: AUTH })).status, + ).toBe(404); + expect((await app.request('/messages/' + id, { method: 'DELETE', headers: AUTH })).status).toBe( + 404, + ); + vi.spyOn(store, 'deleteById').mockRejectedValue(new Error('storage unavailable')); + expect((await app.request('/messages/' + id, { method: 'DELETE', headers: AUTH })).status).toBe( + 503, + ); + }); +}); diff --git a/src/routes/messages.ts b/src/routes/messages.ts index 5c1319a..6920342 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -586,6 +586,30 @@ export function messagesRoutes(deps: MessagesRouteDeps): Hono { return c.json({ error: 'Messages are unavailable' }, 503); } }) + .delete('/:id', async (c) => { + const account = await authedAccount(deps, c.req.header('authorization')); + if (account === null) { + return c.json({ error: 'Unauthorized' }, 401); + } + if (account.role !== 'founder' && account.role !== 'moderator') { + return c.json({ error: 'Forbidden' }, 403); + } + const id = c.req.param('id'); + if (!MESSAGE_ID_RE.test(id)) { + return c.json({ error: 'Not found' }, 404); + } + try { + const removed = await deps.store.deleteById(id); + if (!removed) { + return c.json({ error: 'Not found' }, 404); + } + logEvent('messages.deleted', { messageId: id, accountId: account.id, role: account.role }); + return c.body(null, 204); + } catch { + logEvent('messages.delete.failed', { messageId: id, accountId: account.id }); + return c.json({ error: 'Could not delete message' }, 503); + } + }) .get('/:id', async (c) => { const id = c.req.param('id'); if (!MESSAGE_ID_RE.test(id)) {