From ffa0da63758fb96af5adf56e38905a262374a5f2 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 8 Sep 2026 22:35:23 +0000 Subject: [PATCH] tsbb login: leave out a forum this member cannot post in tsbb 0.5.1 says whether the caller may start a topic in a forum, so a feed-only one no longer has to announce itself with a 403 on the first release post. The subtlety is which caller is asking. The forum list read before the device flow is a guest's, and a guest may post nowhere: every canPost is false, so reading that as a refusal would reject a login that works. Verified against bbs.hqtui.com, where the same request without a token says false for all nine forums and with the token says true for eight and false for `news`. So the list is read twice. Before the flow, as a guest, for the checks that hold for everybody: an unknown slug, and a category, which holds forums rather than topics and costs no approval to refuse. After the token arrives, as the member, for canPost: a refused forum is dropped with a line saying so, and only a selection with nothing postable left in it fails the login. An older board omits the field, and `undefined` means "cannot tell" rather than "no", so every board before 0.5.1 behaves exactly as it did. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011XqFUXQkK6npCGxBtgizQg --- packages/core/src/net/adapters/tsbb.ts | 66 +++++++++- packages/core/test/tsbb-login-forums.test.ts | 122 +++++++++++++++++++ 2 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 packages/core/test/tsbb-login-forums.test.ts diff --git a/packages/core/src/net/adapters/tsbb.ts b/packages/core/src/net/adapters/tsbb.ts index 4c530a7..07923d7 100644 --- a/packages/core/src/net/adapters/tsbb.ts +++ b/packages/core/src/net/adapters/tsbb.ts @@ -44,6 +44,16 @@ interface Forum { slug: string; name?: string; description?: string; + /** A container, not a destination. Nothing is posted into a category. */ + kind?: string; + /** + * Whether the caller may start a topic here, from tsbb 0.5.1 on. Older + * boards omit it, and `undefined` has to mean "cannot tell" rather than + * "no": treating a silent board as forbidding everything would refuse a + * login that works. + */ + canPost?: boolean; + locked?: boolean; } interface DevicePoll { @@ -75,9 +85,18 @@ const splitForums = (raw: string | undefined): string[] => .map((slug) => slug.trim().replace(/^\/?f\//, "").replace(/^\/+|\/+$/g, "")) .filter(Boolean); -/** The forums a board publishes. Reading them needs no token. */ -export async function listForums(instance: string): Promise { - const result = await getJson<{ forums?: Forum[] } | Forum[]>(`${normalizeInstance(instance)}/api/v1/forums`); +/** + * The forums a board publishes. + * + * Reading the list needs no token, but `canPost` is answered for whoever asks: + * without a token that is a guest, who may post nowhere. Pass the token when + * the answer is meant to be about the member. + */ +export async function listForums(instance: string, token?: string): Promise { + const result = await getJson<{ forums?: Forum[] } | Forum[]>( + `${normalizeInstance(instance)}/api/v1/forums`, + token ? { headers: { authorization: `Bearer ${token}` } } : {}, + ); const forums = Array.isArray(result) ? result : (result.forums ?? []); return forums.filter((forum) => typeof forum?.slug === "string"); } @@ -180,6 +199,17 @@ export const tsbb: Network = { `${new URL(instance).host} has no forum ${unknown.join(", ")}. It has: ${[...known].join(", ")}`, ); } + // A category holds forums rather than topics, and that is true of + // everybody, so it can be refused before anyone approves anything. + const categories = chosen.filter( + (slug) => available.find((forum) => forum.slug === slug)?.kind === "category", + ); + if (categories.length) { + throw new Error( + `${categories.join(", ")} ${categories.length === 1 ? "is a category" : "are categories"} on ` + + `${new URL(instance).host}, not a forum. Topics go in the forums underneath.`, + ); + } } if (chosen.length > 1) { ctx.report(`Posts will cycle through ${chosen.join(" → ")}, one forum per post.`); @@ -231,6 +261,36 @@ export const tsbb: Network = { }); const username = me.username ?? me.name ?? "member"; + /* + * Now that there is a token, ask again as the member. + * + * The list read before the device flow answered for a guest, and a guest + * may post nowhere, so it could not be used to check this. From tsbb 0.5.1 + * a forum says whether the caller may start a topic in it; a feed-only or + * locked forum is dropped here with a line saying so, rather than being + * discovered by a 403 on the first announcement. An older board omits the + * field, and "cannot tell" must not be read as "no". + */ + if (chosen.length) { + const asMember = await listForums(instance, token).catch(() => [] as Forum[]); + const refused = chosen.filter( + (slug) => asMember.find((forum) => forum.slug === slug)?.canPost === false, + ); + if (refused.length) { + const keep = chosen.filter((slug) => !refused.includes(slug)); + if (!keep.length) { + throw new Error( + `${username} cannot start topics in ${refused.join(", ")} on ${new URL(instance).host}: ` + + "locked, reply-only, or above this member's rank. Pick a forum that takes topics.", + ); + } + ctx.report( + `${refused.join(", ")} takes replies only, or is locked, so it is left out. Posting to ${keep.join(", ")}.`, + ); + chosen = keep; + } + } + return { handle: `${username}@${new URL(instance).host}`, displayName: index.board?.name ? `${username} on ${index.board.name}` : username, diff --git a/packages/core/test/tsbb-login-forums.test.ts b/packages/core/test/tsbb-login-forums.test.ts new file mode 100644 index 0000000..78ece3d --- /dev/null +++ b/packages/core/test/tsbb-login-forums.test.ts @@ -0,0 +1,122 @@ +/** + * What login does with the board's answer about where a member may post. + * + * The whole device flow is stubbed here, because the interesting part is the + * two forum-list reads around it: one as a guest before anyone approves a code, + * and one as the member afterwards. `canPost` from the first is worthless — a + * guest may post nowhere — and reading it as a refusal would reject a login + * that works. + */ +import { test, expect, afterEach } from "bun:test"; +import { tsbb } from "../src/net/adapters/tsbb.ts"; + +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; +}); + +interface BoardForum { + slug: string; + kind?: string; + canPost?: boolean; +} + +/** A board whose forum list answers differently with and without a token. */ +function stubBoard(guest: BoardForum[], member: BoardForum[]): { asked: string[] } { + const asked: string[] = []; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(typeof input === "object" && "url" in input ? input.url : input); + const headers = new Headers( + (typeof input === "object" && "headers" in input ? (input as Request).headers : init?.headers) ?? {}, + ); + const authed = headers.get("authorization") !== null; + asked.push(`${authed ? "member" : "guest"} ${new URL(url).pathname}`); + + const body = (value: unknown, status = 200) => + new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }); + + if (url.endsWith("/api/v1")) return body({ api: "tsbb", board: { name: "Test Board" } }); + if (url.endsWith("/api/v1/forums")) return body({ forums: authed ? member : guest }); + if (url.endsWith("/api/v1/device/start")) { + return body({ userCode: "AAAA-BBBB", deviceCode: "dc", verifyUrl: "https://board.test/link", interval: 0 }); + } + if (url.endsWith("/api/v1/device/poll")) return body({ status: "approved", token: "tsbb_member" }); + if (url.endsWith("/api/v1/me")) return body({ username: "member" }); + throw new Error(`unexpected request: ${url}`); + }) as unknown as typeof fetch; + return { asked }; +} + +const ctx = () => { + const lines: string[] = []; + return { + lines, + report: (line: string) => lines.push(line), + openUrl: async () => {}, + }; +}; + +test("a forum the member cannot post in is left out, with a line saying why", async () => { + // The live case: `news` is fed by a blog and takes replies only. As a guest + // every canPost is false, which is exactly why the guest list cannot decide. + stubBoard( + [{ slug: "app-showcase", canPost: false }, { slug: "news", canPost: false }], + [{ slug: "app-showcase", canPost: true }, { slug: "news", canPost: false }], + ); + const context = ctx(); + + const account = await tsbb.login( + { instance: "https://board.test", forum: "app-showcase,news" }, + context as never, + ); + + expect(account.meta.forum).toBe("app-showcase"); + expect(context.lines.some((line) => /news.*replies only|left out/i.test(line))).toBe(true); +}); + +test("an older board that omits canPost keeps every forum it was given", async () => { + // "Cannot tell" must not read as "no", or myna would refuse a working login + // against every board older than tsbb 0.5.1. + stubBoard([{ slug: "general" }, { slug: "news" }], [{ slug: "general" }, { slug: "news" }]); + + const account = await tsbb.login( + { instance: "https://board.test", forum: "general,news" }, + ctx() as never, + ); + + expect(account.meta.forum).toBe("general,news"); +}); + +test("when nothing the member asked for takes topics, login says so", async () => { + stubBoard([{ slug: "news", canPost: false }], [{ slug: "news", canPost: false }]); + + await expect( + tsbb.login({ instance: "https://board.test", forum: "news" }, ctx() as never), + ).rejects.toThrow(/cannot start topics in news/); +}); + +test("a category is refused before anyone approves a code", async () => { + const board = stubBoard( + [{ slug: "community", kind: "category" }, { slug: "general", kind: "forum" }], + [{ slug: "community", kind: "category" }, { slug: "general", kind: "forum" }], + ); + + await expect( + tsbb.login({ instance: "https://board.test", forum: "community" }, ctx() as never), + ).rejects.toThrow(/is a category/); + + // Being a category is true of everybody, so this costs no device approval. + expect(board.asked.some((call) => call.includes("device/start"))).toBe(false); +}); + +test("the member list is read with the token, not without it", async () => { + const board = stubBoard( + [{ slug: "general", canPost: false }], + [{ slug: "general", canPost: true }], + ); + + await tsbb.login({ instance: "https://board.test", forum: "general" }, ctx() as never); + + expect(board.asked).toContain("guest /api/v1/forums"); + expect(board.asked).toContain("member /api/v1/forums"); +});