diff --git a/src/domain/landing-actions.ts b/src/domain/landing-actions.ts index 07c97f4..a0aa6f7 100644 --- a/src/domain/landing-actions.ts +++ b/src/domain/landing-actions.ts @@ -5,6 +5,13 @@ * Someone who already has boards gets their boards; everyone else * gets the demo, when there is one. */ +/** + * How many of the visitor's boards the landing needs to know about to + * choose: two tells "one" from "several". A decision threshold, not a + * tunable limit. + */ +export const LANDING_BOARDS_NEEDED = 2; + export type LandingSecondary = | { kind: "demo" } | { kind: "myBoard"; slug: string } diff --git a/src/server/landing-viewer.ts b/src/server/landing-viewer.ts index b50ac20..a438d91 100644 --- a/src/server/landing-viewer.ts +++ b/src/server/landing-viewer.ts @@ -3,13 +3,15 @@ import { cookies } from "next/headers"; import { getConfig } from "../config"; import { getPolicy } from "../policy/server"; import { getBoardRepository } from "./board-repository"; -import { listMyBoards } from "./my-boards"; +import { LANDING_BOARDS_NEEDED } from "../domain/landing-actions"; +import { liveBoardSlugs } from "./my-boards"; import { getUserRepository } from "./user-repository"; import { resolveUserSession, USER_COOKIE_NAME } from "./user-session"; /** - * The signed-in visitor's own board slugs for the landing (PIN-264), - * newest activity first, as My boards lists them. Null when sign-in + * The signed-in visitor's own live board slugs for the landing + * (PIN-264), at most LANDING_BOARDS_NEEDED: only the claim checks, + * never the My boards library reads. Null when sign-in * is off, there is no session cookie (no read at all for anonymous * visitors), the session is not valid, or anything fails: the * landing then shows its usual demo button. The slugs only go back @@ -25,8 +27,7 @@ export async function viewerBoardSlugs(): Promise { const users = getUserRepository(); const user = await resolveUserSession(cookieValue, { users, sessionSecret: cfg.sessionSecret, policy: getPolicy() }); if (!user) return null; - const cards = await listMyBoards(user, { boards: getBoardRepository(), users, plusMode: cfg.plusMode }); - return cards.map((card) => card.slug); + return await liveBoardSlugs(user, { boards: getBoardRepository(), users }, LANDING_BOARDS_NEEDED); } catch { return null; } diff --git a/src/server/my-boards.test.ts b/src/server/my-boards.test.ts index 5f858a7..7dd1b51 100644 --- a/src/server/my-boards.test.ts +++ b/src/server/my-boards.test.ts @@ -5,7 +5,7 @@ import { InMemoryBoardRepository } from "../domain/board-repository"; import type { UserProfile } from "../domain/user"; import { InMemoryUserRepository } from "../domain/user-repository"; import { resolvePolicy } from "../policy/policy"; -import { listMyBoards, removeMyBoard } from "./my-boards"; +import { listMyBoards, liveBoardSlugs, removeMyBoard } from "./my-boards"; import { findClaimedParticipant, linkParticipantToUser } from "./user-session"; const USER: UserProfile = { uid: "synthetic-alice", createdAt: 1, updatedAt: 1 }; @@ -265,3 +265,44 @@ describe("removeMyBoard (PIN-166)", () => { expect(await users.getUserBoard(USER.uid, OTHER_SLUG)).not.toBeNull(); }); }); + +describe("liveBoardSlugs (PIN-264)", () => { + async function setup() { + const boards = new InMemoryBoardRepository(); + const users = new InMemoryUserRepository(); + await users.putUser(USER); + for (const [slug, pid, owner] of [["b1", "p1", USER.uid], ["stale", "ps", "synthetic-bob"], ["b2", "p2", USER.uid], ["b3", "p3", USER.uid]] as const) { + await boards.createBoard(board(slug, 1)); + await boards.putParticipant(participant(pid, slug, owner)); + } + for (const [boardId, participantId, at] of [["b1", "p1", 10], ["gone", "pg", 15], ["stale", "ps", 20], ["b2", "p2", 30], ["b3", "p3", 40]] as const) { + await users.putUserBoard(USER.uid, { boardId, participantId, claimedAt: at, updatedAt: at }); + } + return { boards, users }; + } + + it("keeps the same claim checks as My boards and stops at the limit", async () => { + const deps = await setup(); + const all = await liveBoardSlugs(USER, deps, 10); + expect([...all].sort()).toEqual(["b1", "b2", "b3"]); + expect(await liveBoardSlugs(USER, deps, 2)).toHaveLength(2); + expect(await liveBoardSlugs(USER, deps, 0)).toEqual([]); + // Same set as the full library, never a gone or re-claimed board. + const cards = await listMyBoards(USER, deps); + expect([...all].sort()).toEqual(cards.map((c) => c.slug).sort()); + }); + + it("never reads a board's elements, pins, scenes or events", async () => { + const deps = await setup(); + const touched: string[] = []; + for (const name of ["listElementsByOwner", "listPins", "listScenes", "listEvents"] as const) { + const original = deps.boards[name].bind(deps.boards) as (...args: unknown[]) => unknown; + (deps.boards as unknown as Record)[name] = (...args: unknown[]) => { + touched.push(name); + return original(...args); + }; + } + await liveBoardSlugs(USER, deps, 2); + expect(touched).toEqual([]); + }); +}); diff --git a/src/server/my-boards.ts b/src/server/my-boards.ts index 9a8e739..b0ded37 100644 --- a/src/server/my-boards.ts +++ b/src/server/my-boards.ts @@ -24,6 +24,32 @@ export interface MyBoardsDeps { plusMode?: PlusMode; } +/** + * The user's live board slugs, at most `limit` (PIN-264, Bugbot on + * #242): the same claim checks as listMyBoards (the board exists and + * the participant still carries this user's claim) without the + * per-board elements, pins, scenes and events reads. It stops as soon + * as it has `limit`. The landing only needs to tell none, one (and + * its slug) and several apart. + */ +export async function liveBoardSlugs( + user: UserProfile, + deps: Pick, + limit: number, +): Promise { + const slugs: string[] = []; + if (limit <= 0) return slugs; + for (const entry of await deps.users.listUserBoards(user.uid)) { + const board = await deps.boards.getBoard(entry.boardId); + if (!board) continue; + const participant = await deps.boards.getParticipant(entry.boardId, entry.participantId); + if (!participant || participant.userId !== user.uid) continue; + slugs.push(board.slug); + if (slugs.length >= limit) break; + } + return slugs; +} + export async function listMyBoards( user: UserProfile, deps: MyBoardsDeps,