Skip to content
Merged
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
14 changes: 14 additions & 0 deletions apps/server/src/routes/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -481,6 +487,9 @@ function flattenForum(node: {
topicCount: number;
postCount: number;
unreadCount: number;
canPost: boolean;
canReply: boolean;
isLocked: boolean;
children: unknown[];
}): unknown {
return {
Expand All @@ -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<typeof flattenForum>[0][]).map(flattenForum),
};
}
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/routes/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,9 @@ export function openApiDocument(baseUrl: string, settings: Settings): Record<str
get: {
operationId: 'listForums',
summary: 'The same forums, flattened, with a depth on each.',
responses: { '200': json('A flat list of forums.') },
description:
'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.',
responses: { '200': json('A flat list of forums, each with what the caller may do in it.') },
},
},
'/api/v1/stats': {
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/forums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ export interface ForumNode extends Forum {
*/
unreadCount: number;
unread: boolean;
/**
* Whether the viewer the tree was built for may start a topic here, and may
* reply to one. Resolved the same way the write routes resolve it, locked
* forums and the member-posting policy included, so a client can tell a
* feed-only forum from an ordinary one before it tries to post into it.
*
* A category is never postable: it holds forums, not topics.
*/
canPost: boolean;
canReply: boolean;
}

export interface LastPost {
Expand Down Expand Up @@ -110,9 +120,17 @@ export async function forumTree(viewer: Viewer): Promise<ForumNode[]> {
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({
Expand All @@ -121,6 +139,8 @@ export async function forumTree(viewer: Viewer): Promise<ForumNode[]> {
lastPost: lastPosts.get(forum.id) ?? null,
unreadCount,
unread: unreadCount > 0,
canPost,
canReply,
});
}

Expand Down
67 changes: 67 additions & 0 deletions test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading