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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 7 additions & 0 deletions docs/handbook/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions docs/handbook/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions e2e/forum-replies.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ({
Expand Down Expand Up @@ -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);
});
89 changes: 89 additions & 0 deletions src/__tests__/routes/messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});
});
24 changes: 24 additions & 0 deletions src/routes/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
Loading