diff --git a/apps/server/src/routes/api.ts b/apps/server/src/routes/api.ts index 0dc0e46..cd5fb97 100644 --- a/apps/server/src/routes/api.ts +++ b/apps/server/src/routes/api.ts @@ -151,6 +151,12 @@ export function apiRoutes(services: Services) { topics: node.topicCount, posts: node.postCount, unread: node.unreadCount, + // What this token may do here. Without these a client can only learn + // that a forum is feed-only, locked, or above its rank by being + // refused, which for a posting client means finding out in public. + canPost: node.canPost, + canReply: node.canReply, + locked: node.isLocked, url: `/f/${node.slug}`, }); walk(node.children, depth + 1); @@ -481,6 +487,9 @@ function flattenForum(node: { topicCount: number; postCount: number; unreadCount: number; + canPost: boolean; + canReply: boolean; + isLocked: boolean; children: unknown[]; }): unknown { return { @@ -492,6 +501,11 @@ function flattenForum(node: { topics: node.topicCount, posts: node.postCount, unread: node.unreadCount, + // The nested tree answers the same question the flat list does: a client + // reading either one should not have to post to find out where it may post. + canPost: node.canPost, + canReply: node.canReply, + locked: node.isLocked, children: (node.children as Parameters[0][]).map(flattenForum), }; } diff --git a/apps/server/src/routes/openapi.ts b/apps/server/src/routes/openapi.ts index d0fddf4..1d73d99 100644 --- a/apps/server/src/routes/openapi.ts +++ b/apps/server/src/routes/openapi.ts @@ -90,7 +90,9 @@ export function openApiDocument(baseUrl: string, settings: Settings): Record { for (const row of rows) { if (row.is_hidden === 1 && !viewer.isModerator && !viewer.isAdmin) continue; const forum = toForum(row); + // A category is a container: nothing is posted into one directly, so there + // is no permission to resolve and nothing a client could do with the answer. + let canPost = false; + let canReply = false; if (forum.kind === 'forum') { const perms = await resolvePermissions(viewer, forum, await ancestryOf(forum.id, parents)); if (!perms.canView) continue; + // The same two conditions the write routes apply, so a client that trusts + // this cannot be surprised by a 403 the board could have predicted. + canPost = perms.canPost && !forum.isLocked; + canReply = perms.canReply && !forum.isLocked; } const unreadCount = unread.get(forum.id) ?? 0; visible.push({ @@ -121,6 +139,8 @@ export async function forumTree(viewer: Viewer): Promise { lastPost: lastPosts.get(forum.id) ?? null, unreadCount, unread: unreadCount > 0, + canPost, + canReply, }); } diff --git a/test/api.test.ts b/test/api.test.ts index 31f8b9d..63692ba 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -177,6 +177,73 @@ describe('the API and MCP surfaces', () => { assert.ok(afterBoard.forums.every((f) => f.unread === 0), 'nothing is unread after a board-wide mark'); }); + it('says what a token may do in each forum, so a client need not find out by being refused', async () => { + // A reply-only forum is how a feed is published to a board: the crawler + // opens the topics and members discuss them. Posting into one answers 403, + // and the forum list used to look identical to an ordinary forum, so a + // posting client could only discover the difference in public. + const feedOnly = await core.createForum({ + name: 'Industry news', + kind: 'forum', + memberPosting: 'replies', + }); + const locked = await core.createForum({ name: 'The archive', kind: 'forum' }); + await core.updateForum(locked.id, { isLocked: true }); + + const listed = (await api<{ + forums: { slug: string; kind: string; canPost: boolean; canReply: boolean; locked: boolean }[]; + }>('/api/v1/forums', true)).forums; + + const ordinary = listed.find((f) => f.slug === 'general'); + assert.ok(ordinary); + assert.equal(ordinary.canPost, true); + assert.equal(ordinary.canReply, true); + assert.equal(ordinary.locked, false); + + const feed = listed.find((f) => f.slug === feedOnly.slug); + assert.ok(feed); + assert.equal(feed.canPost, false, 'a reply-only forum says so before a topic is attempted'); + assert.equal(feed.canReply, true, 'and replying is the whole point of it'); + + const shut = listed.find((f) => f.slug === locked.slug); + assert.ok(shut); + assert.equal(shut.locked, true); + assert.equal(shut.canPost, false, 'a locked forum takes neither'); + assert.equal(shut.canReply, false); + + // A category holds forums, not topics. + const category = listed.find((f) => f.kind === 'category'); + assert.ok(category); + assert.equal(category.canPost, false); + + // The claim has to match what the write route actually does, or it is worse + // than no claim at all. + const refused = await app.fetch( + new Request(url(`/api/v1/forums/${feedOnly.slug}/topics`), { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ title: 'Should not land', body: 'Nor this.' }), + }), + ); + assert.equal(refused.status, 403); + + const accepted = await app.fetch( + new Request(url('/api/v1/forums/general/topics'), { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ title: 'This one lands', body: 'As advertised.' }), + }), + ); + assert.equal(accepted.status, 201); + + // The nested tree answers the same question. + const nested = (await api<{ + forums: { slug: string; canPost: boolean; children: { slug: string; canPost: boolean }[] }[]; + }>('/api/v1/board', true)).forums; + const flat = [...nested, ...nested.flatMap((f) => f.children)]; + assert.equal(flat.find((f) => f.slug === feedOnly.slug)?.canPost, false); + }); + it('flattens the forum tree with a usable depth', async () => { const { forums } = await api<{ forums: { slug: string; depth: number; kind: string }[] }>( '/api/v1/forums',