Public: anyone can read this; the crew can change it.
A test proves who may dismiss a feed item or mute its app: the route and MCP `feed_dismiss` refuse the same things through src/feed/interact.ts #7530
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7524-a-test-proves-who-may-dismiss-a-feed-ite → main 3 commits fork 1 thread · 1 unresolved
5 files
changed +256
−25
src/core/interactions.ts −3
| @@ -20,9 +20,6 @@ export const INTERACTION_KINDS = [ | ||
| 20 | 20 | ] as const; |
| 21 | 21 | export type InteractionKind = (typeof INTERACTION_KINDS)[number]; |
| 22 | 22 | |
| 23 | export const isInteractionKind = (v: unknown): v is InteractionKind => | |
| 24 | typeof v === "string" && (INTERACTION_KINDS as readonly string[]).includes(v); | |
| 25 | ||
| 26 | 23 | /** A feed item id: a namespace, then what it names — `event:01J…`, `ask:look:…`. */ |
| 27 | 24 | export const ITEM_ID = /^[a-z]{2,12}:[A-Za-z0-9_.:-]{1,160}$/; |
| 28 | 25 |
src/feed/interact.ts +41
| @@ -6,8 +6,10 @@ | ||
| 6 | 6 | import type { Database } from "bun:sqlite"; |
| 7 | 7 | import { audienceFilter } from "../core/event_audience.ts"; |
| 8 | 8 | import { getEvent } from "../core/events.ts"; |
| 9 | import { ITEM_ID, type InteractionKind } from "../core/interactions.ts"; | |
| 9 | 10 | import { canRead, getRepo, getRepoById } from "../core/repos.ts"; |
| 10 | 11 | import type { User } from "../core/users.ts"; |
| 12 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 11 | 13 | |
| 12 | 14 | export interface InteractionTarget { |
| 13 | 15 | readonly repoId: string; |
| @@ -38,3 +40,42 @@ export function interactionTarget( | ||
| 38 | 40 | if (!repo || !canRead(db, repo, viewer)) return undefined; |
| 39 | 41 | return { repoId: repo.id, actorId: null }; |
| 40 | 42 | } |
| 43 | ||
| 44 | /** Why an act on the feed was refused: the same code and words at every door. */ | |
| 45 | export interface InteractionRefusal { | |
| 46 | readonly code: "invalid_item" | "invalid_kind" | "not_found"; | |
| 47 | readonly message: string; | |
| 48 | } | |
| 49 | ||
| 50 | export interface InteractionAct { | |
| 51 | readonly itemId: string; | |
| 52 | readonly kind: InteractionKind; | |
| 53 | readonly target: InteractionTarget; | |
| 54 | } | |
| 55 | ||
| 56 | /** An item the viewer cannot read is refused in the same words as one that does not exist. */ | |
| 57 | export const NO_SUCH_ITEM = "no such item, or it is not visible to you"; | |
| 58 | ||
| 59 | const text = (v: unknown): string => (typeof v === "string" ? v.trim() : ""); | |
| 60 | ||
| 61 | /** | |
| 62 | * An act as a door received it (`{ item, kind, app? }`), checked: the item is | |
| 63 | * a feed item id, the kind is one of `kinds` (the door's own), and the | |
| 64 | * viewer may act on what the item is about. | |
| 65 | */ | |
| 66 | export function readInteraction( | |
| 67 | db: Database, | |
| 68 | viewer: User, | |
| 69 | input: { readonly item?: unknown; readonly kind?: unknown; readonly app?: unknown }, | |
| 70 | kinds: readonly InteractionKind[], | |
| 71 | ): Result<InteractionAct, InteractionRefusal> { | |
| 72 | const itemId = text(input.item); | |
| 73 | if (!ITEM_ID.test(itemId)) | |
| 74 | return err({ code: "invalid_item", message: "item is a feed item id" }); | |
| 75 | const kind = kinds.find((k) => k === input.kind); | |
| 76 | if (kind === undefined) | |
| 77 | return err({ code: "invalid_kind", message: `kind is one of ${kinds.join(", ")}` }); | |
| 78 | const target = interactionTarget(db, viewer, itemId, text(input.app)); | |
| 79 | if (!target) return err({ code: "not_found", message: NO_SUCH_ITEM }); | |
| 80 | return ok({ itemId, kind, target }); | |
| 81 | } |
src/http/feed.ts +6 −11
| @@ -12,10 +12,10 @@ import { | ||
| 12 | 12 | VARIANTS_MAX, |
| 13 | 13 | } from "../actions/tell.ts"; |
| 14 | 14 | import { DIRECTION_NOTE } from "../crew/direct.ts"; |
| 15 | import { interactionTarget } from "../feed/interact.ts"; | |
| 15 | import { readInteraction } from "../feed/interact.ts"; | |
| 16 | 16 | import { |
| 17 | 17 | ITEM_ID, |
| 18 | isInteractionKind, | |
| 18 | INTERACTION_KINDS, | |
| 19 | 19 | recordInteraction, |
| 20 | 20 | } from "../core/interactions.ts"; |
| 21 | 21 | import { chipsOf, isKindChip, setChip } from "../core/feed_chips.ts"; |
| @@ -232,15 +232,10 @@ export function feedRoutes(router: Router<Handler>): void { | ||
| 232 | 232 | const viewer = ctx.auth.user!; |
| 233 | 233 | const db = ctx.app.db; |
| 234 | 234 | const input = await body(ctx.request); |
| 235 | const itemId = str(input.item); | |
| 236 | const kind = input.kind; | |
| 237 | if (!ITEM_ID.test(itemId)) | |
| 238 | return apiError("invalid_item", 422, "item is a feed item id"); | |
| 239 | if (!isInteractionKind(kind)) | |
| 240 | return apiError("invalid_kind", 422, "kind is not one the feed knows"); | |
| 241 | ||
| 242 | const target = interactionTarget(db, viewer, itemId, str(input.app)); | |
| 243 | if (!target) return notFound(); | |
| 235 | const act = readInteraction(db, viewer, input, INTERACTION_KINDS); | |
| 236 | if (!act.ok) | |
| 237 | return apiError(act.error.code, act.error.code === "not_found" ? 404 : 422, act.error.message); | |
| 238 | const { itemId, kind, target } = act.value; | |
| 244 | 239 | const made = recordInteraction(db, { |
| 245 | 240 | viewerId: viewer.id, |
| 246 | 241 | itemId, |
src/http/mcp/tools/feed_dismiss.ts +7 −11
| @@ -1,6 +1,6 @@ | ||
| 1 | import { ITEM_ID, recordInteraction } from "../../../core/interactions.ts"; | |
| 2 | import { interactionTarget } from "../../../feed/interact.ts"; | |
| 3 | import { good, bad, type Tool, str } from "../shared.ts"; | |
| 1 | import { recordInteraction } from "../../../core/interactions.ts"; | |
| 2 | import { readInteraction } from "../../../feed/interact.ts"; | |
| 3 | import { good, bad, type Tool } from "../shared.ts"; | |
| 4 | 4 | |
| 5 | 5 | export const tool: Tool = { |
| 6 | 6 | name: "feed_dismiss", |
| @@ -17,16 +17,12 @@ export const tool: Tool = { | ||
| 17 | 17 | }, |
| 18 | 18 | writes: true, |
| 19 | 19 | run(ctx, user, args) { |
| 20 | const item = str(args["item"]); | |
| 21 | const kind = args["kind"]; | |
| 22 | if (!ITEM_ID.test(item)) return bad("invalid", "item is a feed item id"); | |
| 23 | if (kind !== "dismissed" && kind !== "muted") | |
| 24 | return bad("invalid", "kind is dismissed or muted"); | |
| 25 | const target = interactionTarget(ctx.app.db, user, item, str(args["app"])); | |
| 26 | if (!target) return bad("not_found", "no such item"); | |
| 20 | const act = readInteraction(ctx.app.db, user, args, ["dismissed", "muted"]); | |
| 21 | if (!act.ok) return bad(act.error.code, act.error.message); | |
| 22 | const { itemId, kind, target } = act.value; | |
| 27 | 23 | const made = recordInteraction(ctx.app.db, { |
| 28 | 24 | viewerId: user.id, |
| 29 | itemId: item, | |
| 25 | itemId, | |
| 30 | 26 | kind, |
| 31 | 27 | repoId: target.repoId, |
| 32 | 28 | actorId: target.actorId, |
test/feed-interact.test.ts added +202
| @@ -0,0 +1,202 @@ | ||
| 1 | /** | |
| 2 | * Who may dismiss a feed item or mute its app (#7524): `interactionTarget` | |
| 3 | * and `readInteraction` (src/feed/interact.ts) are the one rule, and the | |
| 4 | * route and MCP `feed_dismiss` refuse the same inputs with the same error. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { createRepo } from "../src/actions/repos.ts"; | |
| 12 | import type { Repo } from "../src/core/repos.ts"; | |
| 13 | import { createToken } from "../src/core/tokens.ts"; | |
| 14 | import { createUser, type User } from "../src/core/users.ts"; | |
| 15 | import { readFeed } from "../src/feed/feed.ts"; | |
| 16 | import { interactionTarget, NO_SUCH_ITEM, readInteraction } from "../src/feed/interact.ts"; | |
| 17 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 18 | import { promote } from "./helpers/members.ts"; | |
| 19 | ||
| 20 | let dataDir: string; | |
| 21 | let app: App; | |
| 22 | let servers: Servers; | |
| 23 | let alice: User; | |
| 24 | let bob: User; | |
| 25 | let bobTok: string; | |
| 26 | let open: Repo; | |
| 27 | let secret: Repo; | |
| 28 | let openEvent: string; | |
| 29 | let secretEvent: string; | |
| 30 | ||
| 31 | const DISMISS = ["dismissed", "muted"] as const; | |
| 32 | ||
| 33 | beforeAll(async () => { | |
| 34 | dataDir = await mkdtemp(join(tmpdir(), "forge-feed-interact-")); | |
| 35 | app = createApp({ dataDir, port: 0 }); | |
| 36 | servers = await serve(app); | |
| 37 | const mk = async (username: string, first = false) => { | |
| 38 | const u = await createUser(app.db, { | |
| 39 | origin: "seed", | |
| 40 | trusted: true, | |
| 41 | username, | |
| 42 | email: `${username}@forge.local`, | |
| 43 | password: "a-good-passphrase", | |
| 44 | adminIfFirst: first, | |
| 45 | }); | |
| 46 | if (!u.ok) throw new Error(u.error); | |
| 47 | promote(app.db, u.value); | |
| 48 | return u.value; | |
| 49 | }; | |
| 50 | await mk("founder", true); | |
| 51 | alice = await mk("alice"); | |
| 52 | bob = await mk("bob"); | |
| 53 | bobTok = createToken(app.db, bob.id, "rw", "rw").token; | |
| 54 | const o = await createRepo(app, alice, { name: "open", visibility: "public" }); | |
| 55 | const s = await createRepo(app, alice, { name: "secret", visibility: "private" }); | |
| 56 | if (!o.ok || !s.ok) throw new Error("repos"); | |
| 57 | open = o.value; | |
| 58 | secret = s.value; | |
| 59 | for (const [repo, summary] of [[open, "up"], [secret, "boom"]] as const) | |
| 60 | app.bus.append({ | |
| 61 | kind: "run.finished", | |
| 62 | repoId: repo.id, | |
| 63 | actorId: alice.id, | |
| 64 | payload: { kind: "deploy", state: "ok", summary, runId: `r-${summary}` }, | |
| 65 | }); | |
| 66 | const items = readFeed(app.db, alice).items.filter((i) => i.id.startsWith("event:")); | |
| 67 | openEvent = items.find((i) => i.app === "alice/open")!.id; | |
| 68 | secretEvent = items.find((i) => i.app === "alice/secret")!.id; | |
| 69 | }); | |
| 70 | ||
| 71 | afterAll(async () => { | |
| 72 | servers.stop(); | |
| 73 | app.close(); | |
| 74 | await rm(dataDir, { recursive: true, force: true }); | |
| 75 | }); | |
| 76 | ||
| 77 | describe("interactionTarget", () => { | |
| 78 | test("an event on a repository the viewer can read resolves to its app and actor", () => { | |
| 79 | expect(interactionTarget(app.db, bob, openEvent, "")).toEqual({ | |
| 80 | repoId: open.id, | |
| 81 | actorId: alice.id, | |
| 82 | }); | |
| 83 | }); | |
| 84 | ||
| 85 | test("an event the viewer cannot read is refused exactly as an unknown one", () => { | |
| 86 | expect(interactionTarget(app.db, alice, secretEvent, "")).toBeDefined(); | |
| 87 | expect(interactionTarget(app.db, bob, secretEvent, "")).toBeUndefined(); | |
| 88 | expect(interactionTarget(app.db, bob, "event:nothing-here", "")).toBeUndefined(); | |
| 89 | // Naming a readable app does not open an unreadable event. | |
| 90 | expect(interactionTarget(app.db, bob, secretEvent, "alice/open")).toBeUndefined(); | |
| 91 | }); | |
| 92 | ||
| 93 | test("an item that is not an event needs a readable `app`", () => { | |
| 94 | expect(interactionTarget(app.db, bob, "ask:look:x", "alice/open")).toEqual({ | |
| 95 | repoId: open.id, | |
| 96 | actorId: null, | |
| 97 | }); | |
| 98 | for (const app_ of ["", "alice", "alice/open/x", "/open", "alice/", "nobody/none", "alice/secret"]) | |
| 99 | expect(interactionTarget(app.db, bob, "ask:look:x", app_)).toBeUndefined(); | |
| 100 | expect(interactionTarget(app.db, alice, "ask:look:x", "alice/secret")?.repoId).toBe(secret.id); | |
| 101 | }); | |
| 102 | }); | |
| 103 | ||
| 104 | describe("readInteraction", () => { | |
| 105 | test("a kind outside the door's own is refused", () => { | |
| 106 | for (const kind of ["opened", "bogus", "", undefined, 1]) | |
| 107 | expect(readInteraction(app.db, bob, { item: openEvent, kind }, DISMISS)).toEqual({ | |
| 108 | ok: false, | |
| 109 | error: { code: "invalid_kind", message: "kind is one of dismissed, muted" }, | |
| 110 | }); | |
| 111 | }); | |
| 112 | ||
| 113 | test("a bad item id is refused before anything is looked up", () => { | |
| 114 | for (const item of ["../x", "", "event", 7]) | |
| 115 | expect(readInteraction(app.db, bob, { item, kind: "muted" }, DISMISS)).toEqual({ | |
| 116 | ok: false, | |
| 117 | error: { code: "invalid_item", message: "item is a feed item id" }, | |
| 118 | }); | |
| 119 | }); | |
| 120 | ||
| 121 | test("what the viewer cannot read is not_found, worded as nothing", () => { | |
| 122 | const refused = { ok: false, error: { code: "not_found", message: NO_SUCH_ITEM } } as const; | |
| 123 | expect(readInteraction(app.db, bob, { item: secretEvent, kind: "muted" }, DISMISS)).toEqual(refused); | |
| 124 | expect(readInteraction(app.db, bob, { item: "event:nothing-here", kind: "muted" }, DISMISS)).toEqual(refused); | |
| 125 | expect(readInteraction(app.db, bob, { item: "ask:look:x", kind: "muted" }, DISMISS)).toEqual(refused); | |
| 126 | }); | |
| 127 | ||
| 128 | test("a readable item resolves to the act", () => { | |
| 129 | expect(readInteraction(app.db, bob, { item: openEvent, kind: "dismissed" }, DISMISS)).toEqual({ | |
| 130 | ok: true, | |
| 131 | value: { itemId: openEvent, kind: "dismissed", target: { repoId: open.id, actorId: alice.id } }, | |
| 132 | }); | |
| 133 | }); | |
| 134 | }); | |
| 135 | ||
| 136 | describe("the route and MCP feed_dismiss refuse the same things", () => { | |
| 137 | const route = async (input: unknown) => { | |
| 138 | const res = await fetch(`${servers.url}/api/v1/feed/interactions`, { | |
| 139 | method: "POST", | |
| 140 | headers: { authorization: `Bearer ${bobTok}`, "content-type": "application/json" }, | |
| 141 | body: JSON.stringify(input), | |
| 142 | }); | |
| 143 | const body = (await res.json()) as { error?: string; message?: string; id?: string }; | |
| 144 | return { status: res.status, ...body }; | |
| 145 | }; | |
| 146 | const mcp = async (input: unknown) => { | |
| 147 | const res = await fetch(`${servers.url}/mcp`, { | |
| 148 | method: "POST", | |
| 149 | headers: { | |
| 150 | "content-type": "application/json", | |
| 151 | accept: "application/json, text/event-stream", | |
| 152 | authorization: `Bearer ${bobTok}`, | |
| 153 | }, | |
| 154 | body: JSON.stringify({ | |
| 155 | jsonrpc: "2.0", | |
| 156 | id: 1, | |
| 157 | method: "tools/call", | |
| 158 | params: { name: "feed_dismiss", arguments: input }, | |
| 159 | }), | |
| 160 | }); | |
| 161 | const body: any = await res.json(); | |
| 162 | return { isError: body.result.isError === true, text: body.result.content[0].text as string }; | |
| 163 | }; | |
| 164 | ||
| 165 | test("each refused input gets the same code and words at both doors", async () => { | |
| 166 | const refused: [Record<string, unknown>, string][] = [ | |
| 167 | [{ item: "../x", kind: "muted" }, "invalid_item"], | |
| 168 | [{ kind: "muted" }, "invalid_item"], | |
| 169 | [{ item: secretEvent, kind: "dismissed" }, "not_found"], | |
| 170 | [{ item: "event:nothing-here", kind: "dismissed" }, "not_found"], | |
| 171 | [{ item: "ask:look:x", kind: "muted" }, "not_found"], | |
| 172 | [{ item: "ask:look:x", kind: "muted", app: "alice" }, "not_found"], | |
| 173 | [{ item: "ask:look:x", kind: "muted", app: "alice/open/x" }, "not_found"], | |
| 174 | [{ item: "ask:look:x", kind: "muted", app: "alice/secret" }, "not_found"], | |
| 175 | ]; | |
| 176 | for (const [input, code] of refused) { | |
| 177 | const web = await route(input); | |
| 178 | const tool = await mcp(input); | |
| 179 | expect({ input, code: web.error }).toEqual({ input, code }); | |
| 180 | expect(web.status).toBe(code === "not_found" ? 404 : 422); | |
| 181 | expect(tool).toEqual({ isError: true, text: `${web.error}: ${web.message}` }); | |
| 182 | } | |
| 183 | }); | |
| 184 | ||
| 185 | test("a kind neither door knows is invalid_kind at both, each naming its own kinds", async () => { | |
| 186 | const input = { item: openEvent, kind: "bogus" }; | |
| 187 | const web = await route(input); | |
| 188 | const tool = await mcp(input); | |
| 189 | expect(web.status).toBe(422); | |
| 190 | expect(web.error).toBe("invalid_kind"); | |
| 191 | expect(tool).toEqual({ isError: true, text: "invalid_kind: kind is one of dismissed, muted" }); | |
| 192 | // The route records every act a card reports; the tool only dismisses or mutes. | |
| 193 | expect((await route({ item: openEvent, kind: "opened" })).status).toBe(201); | |
| 194 | expect((await mcp({ item: openEvent, kind: "opened" })).text).toStartWith("invalid_kind:"); | |
| 195 | }); | |
| 196 | ||
| 197 | test("what one door accepts, the other does too", async () => { | |
| 198 | const input = { item: "ask:look:y", kind: "muted", app: "alice/open" }; | |
| 199 | expect((await route(input)).status).toBe(201); | |
| 200 | expect((await mcp(input)).isError).toBe(false); | |
| 201 | }); | |
| 202 | }); |
new above
offline
This line replaces the route's last use of
isInteractionKind, sosrc/core/interactions.ts:23is now an export nothing calls. Delete it in this pull, soreadInteractionis the only place the kind rule lives.