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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/7524-a-test-proves-who-may-dismiss-a-feed-ite → main 3 commits fork 1 thread · 1 unresolved

  • Merged. mason/7524-a-test-proves-who-may-dismiss-a-feed-ite was merged into main as 142c66b.

Review

Sign in to review this change.
5 files changed +256 −25
src/core/interactions.ts −3
@@ -20,9 +20,6 @@ export const INTERACTION_KINDS = [
2020 ] as const;
2121 export type InteractionKind = (typeof INTERACTION_KINDS)[number];
2222
23 export const isInteractionKind = (v: unknown): v is InteractionKind =>
24 typeof v === "string" && (INTERACTION_KINDS as readonly string[]).includes(v);
25
2623 /** A feed item id: a namespace, then what it names — `event:01J…`, `ask:look:…`. */
2724 export const ITEM_ID = /^[a-z]{2,12}:[A-Za-z0-9_.:-]{1,160}$/;
2825
src/feed/interact.ts +41
@@ -6,8 +6,10 @@
66 import type { Database } from "bun:sqlite";
77 import { audienceFilter } from "../core/event_audience.ts";
88 import { getEvent } from "../core/events.ts";
9 import { ITEM_ID, type InteractionKind } from "../core/interactions.ts";
910 import { canRead, getRepo, getRepoById } from "../core/repos.ts";
1011 import type { User } from "../core/users.ts";
12 import { err, ok, type Result } from "../kernel/result.ts";
1113
1214 export interface InteractionTarget {
1315 readonly repoId: string;
@@ -38,3 +40,42 @@ export function interactionTarget(
3840 if (!repo || !canRead(db, repo, viewer)) return undefined;
3941 return { repoId: repo.id, actorId: null };
4042 }
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 {
1212 VARIANTS_MAX,
1313 } from "../actions/tell.ts";
1414 import { DIRECTION_NOTE } from "../crew/direct.ts";
15 import { interactionTarget } from "../feed/interact.ts";
15 import { readInteraction } from "../feed/interact.ts";
1616 import {
1717 ITEM_ID,
18 isInteractionKind,
18 INTERACTION_KINDS,

This line replaces the route's last use of isInteractionKind, so src/core/interactions.ts:23 is now an export nothing calls. Delete it in this pull, so readInteraction is the only place the kind rule lives.

1919 recordInteraction,
2020 } from "../core/interactions.ts";
2121 import { chipsOf, isKindChip, setChip } from "../core/feed_chips.ts";
@@ -232,15 +232,10 @@ export function feedRoutes(router: Router<Handler>): void {
232232 const viewer = ctx.auth.user!;
233233 const db = ctx.app.db;
234234 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;
244239 const made = recordInteraction(db, {
245240 viewerId: viewer.id,
246241 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";
44
55 export const tool: Tool = {
66 name: "feed_dismiss",
@@ -17,16 +17,12 @@ export const tool: Tool = {
1717 },
1818 writes: true,
1919 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;
2723 const made = recordInteraction(ctx.app.db, {
2824 viewerId: user.id,
29 itemId: item,
25 itemId,
3026 kind,
3127 repoId: target.repoId,
3228 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs