Skip to content

Commit 8ccdcdc

Browse files
ralyodioclaude
andauthored
Say in the forum list what a caller may do in each forum (#17)
A client could not tell a feed-only forum from an ordinary one until it tried to post into it. `news` on bbs.hqtui.com is `memberPosting: 'replies'`, so a new topic there answers 403, and `GET /api/v1/forums` described it exactly as it described `announcements`. A posting client therefore discovered the difference in public, which is what happened to myna's release announcement. The board already knew: `resolvePermissions` resolves `canPost` per viewer and `forum.isLocked` sits beside it, and the write route checks both. So the tree now carries `canPost` and `canReply` for the viewer it was built for, resolved by that same function, and both `/api/v1/forums` and `/api/v1/board` return them along with the forum's own `locked` flag. A category is never postable: it holds forums, not topics. The test asserts the claim against the write route in the same run, posting to a reply-only forum for the 403 and to an ordinary one for the 201. A permission hint that can drift from the thing it describes is worse than no hint. Claude-Session: https://claude.ai/code/session_011XqFUXQkK6npCGxBtgizQg Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 1ba17f7 commit 8ccdcdc

4 files changed

Lines changed: 104 additions & 1 deletion

File tree

‎apps/server/src/routes/api.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,12 @@ export function apiRoutes(services: Services) {
151151
topics: node.topicCount,
152152
posts: node.postCount,
153153
unread: node.unreadCount,
154+
// What this token may do here. Without these a client can only learn
155+
// that a forum is feed-only, locked, or above its rank by being
156+
// refused, which for a posting client means finding out in public.
157+
canPost: node.canPost,
158+
canReply: node.canReply,
159+
locked: node.isLocked,
154160
url: `/f/${node.slug}`,
155161
});
156162
walk(node.children, depth + 1);
@@ -481,6 +487,9 @@ function flattenForum(node: {
481487
topicCount: number;
482488
postCount: number;
483489
unreadCount: number;
490+
canPost: boolean;
491+
canReply: boolean;
492+
isLocked: boolean;
484493
children: unknown[];
485494
}): unknown {
486495
return {
@@ -492,6 +501,11 @@ function flattenForum(node: {
492501
topics: node.topicCount,
493502
posts: node.postCount,
494503
unread: node.unreadCount,
504+
// The nested tree answers the same question the flat list does: a client
505+
// reading either one should not have to post to find out where it may post.
506+
canPost: node.canPost,
507+
canReply: node.canReply,
508+
locked: node.isLocked,
495509
children: (node.children as Parameters<typeof flattenForum>[0][]).map(flattenForum),
496510
};
497511
}

‎apps/server/src/routes/openapi.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,9 @@ export function openApiDocument(baseUrl: string, settings: Settings): Record<str
9090
get: {
9191
operationId: 'listForums',
9292
summary: 'The same forums, flattened, with a depth on each.',
93-
responses: { '200': json('A flat list of forums.') },
93+
description:
94+
'Each forum carries canPost and canReply for the caller: whether this token may start a topic here and whether it may reply, resolved exactly as the write routes resolve it, so a feed-only forum, a locked one, or one above the caller\'s rank can be told apart before a post is attempted rather than by being refused. `locked` is the forum\'s own flag. A category is never postable.',
95+
responses: { '200': json('A flat list of forums, each with what the caller may do in it.') },
9496
},
9597
},
9698
'/api/v1/stats': {

‎packages/core/src/forums.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,16 @@ export interface ForumNode extends Forum {
6262
*/
6363
unreadCount: number;
6464
unread: boolean;
65+
/**
66+
* Whether the viewer the tree was built for may start a topic here, and may
67+
* reply to one. Resolved the same way the write routes resolve it, locked
68+
* forums and the member-posting policy included, so a client can tell a
69+
* feed-only forum from an ordinary one before it tries to post into it.
70+
*
71+
* A category is never postable: it holds forums, not topics.
72+
*/
73+
canPost: boolean;
74+
canReply: boolean;
6575
}
6676

6777
export interface LastPost {
@@ -110,9 +120,17 @@ export async function forumTree(viewer: Viewer): Promise<ForumNode[]> {
110120
for (const row of rows) {
111121
if (row.is_hidden === 1 && !viewer.isModerator && !viewer.isAdmin) continue;
112122
const forum = toForum(row);
123+
// A category is a container: nothing is posted into one directly, so there
124+
// is no permission to resolve and nothing a client could do with the answer.
125+
let canPost = false;
126+
let canReply = false;
113127
if (forum.kind === 'forum') {
114128
const perms = await resolvePermissions(viewer, forum, await ancestryOf(forum.id, parents));
115129
if (!perms.canView) continue;
130+
// The same two conditions the write routes apply, so a client that trusts
131+
// this cannot be surprised by a 403 the board could have predicted.
132+
canPost = perms.canPost && !forum.isLocked;
133+
canReply = perms.canReply && !forum.isLocked;
116134
}
117135
const unreadCount = unread.get(forum.id) ?? 0;
118136
visible.push({
@@ -121,6 +139,8 @@ export async function forumTree(viewer: Viewer): Promise<ForumNode[]> {
121139
lastPost: lastPosts.get(forum.id) ?? null,
122140
unreadCount,
123141
unread: unreadCount > 0,
142+
canPost,
143+
canReply,
124144
});
125145
}
126146

‎test/api.test.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,73 @@ describe('the API and MCP surfaces', () => {
177177
assert.ok(afterBoard.forums.every((f) => f.unread === 0), 'nothing is unread after a board-wide mark');
178178
});
179179

180+
it('says what a token may do in each forum, so a client need not find out by being refused', async () => {
181+
// A reply-only forum is how a feed is published to a board: the crawler
182+
// opens the topics and members discuss them. Posting into one answers 403,
183+
// and the forum list used to look identical to an ordinary forum, so a
184+
// posting client could only discover the difference in public.
185+
const feedOnly = await core.createForum({
186+
name: 'Industry news',
187+
kind: 'forum',
188+
memberPosting: 'replies',
189+
});
190+
const locked = await core.createForum({ name: 'The archive', kind: 'forum' });
191+
await core.updateForum(locked.id, { isLocked: true });
192+
193+
const listed = (await api<{
194+
forums: { slug: string; kind: string; canPost: boolean; canReply: boolean; locked: boolean }[];
195+
}>('/api/v1/forums', true)).forums;
196+
197+
const ordinary = listed.find((f) => f.slug === 'general');
198+
assert.ok(ordinary);
199+
assert.equal(ordinary.canPost, true);
200+
assert.equal(ordinary.canReply, true);
201+
assert.equal(ordinary.locked, false);
202+
203+
const feed = listed.find((f) => f.slug === feedOnly.slug);
204+
assert.ok(feed);
205+
assert.equal(feed.canPost, false, 'a reply-only forum says so before a topic is attempted');
206+
assert.equal(feed.canReply, true, 'and replying is the whole point of it');
207+
208+
const shut = listed.find((f) => f.slug === locked.slug);
209+
assert.ok(shut);
210+
assert.equal(shut.locked, true);
211+
assert.equal(shut.canPost, false, 'a locked forum takes neither');
212+
assert.equal(shut.canReply, false);
213+
214+
// A category holds forums, not topics.
215+
const category = listed.find((f) => f.kind === 'category');
216+
assert.ok(category);
217+
assert.equal(category.canPost, false);
218+
219+
// The claim has to match what the write route actually does, or it is worse
220+
// than no claim at all.
221+
const refused = await app.fetch(
222+
new Request(url(`/api/v1/forums/${feedOnly.slug}/topics`), {
223+
method: 'POST',
224+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
225+
body: JSON.stringify({ title: 'Should not land', body: 'Nor this.' }),
226+
}),
227+
);
228+
assert.equal(refused.status, 403);
229+
230+
const accepted = await app.fetch(
231+
new Request(url('/api/v1/forums/general/topics'), {
232+
method: 'POST',
233+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
234+
body: JSON.stringify({ title: 'This one lands', body: 'As advertised.' }),
235+
}),
236+
);
237+
assert.equal(accepted.status, 201);
238+
239+
// The nested tree answers the same question.
240+
const nested = (await api<{
241+
forums: { slug: string; canPost: boolean; children: { slug: string; canPost: boolean }[] }[];
242+
}>('/api/v1/board', true)).forums;
243+
const flat = [...nested, ...nested.flatMap((f) => f.children)];
244+
assert.equal(flat.find((f) => f.slug === feedOnly.slug)?.canPost, false);
245+
});
246+
180247
it('flattens the forum tree with a usable depth', async () => {
181248
const { forums } = await api<{ forums: { slug: string; depth: number; kind: string }[] }>(
182249
'/api/v1/forums',

0 commit comments

Comments
 (0)