Public: anyone can read this; the crew can change it.

Run events tell a reader who cannot write no cost, turns or node #2964

Merged opened this

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

Merged

scout/forge:scout/2963-run-events-tell-a-reader-who-cannot-writ → main 2 commits fork 5 threads · 5 unresolved

  • Merged. scout/2963-run-events-tell-a-reader-who-cannot-writ was merged into main as 43c2704.

Review

Sign in to review this change.
4 files changed +137 −10
src/core/event_audience.ts +32 −1
@@ -26,7 +26,7 @@ import {
2626 import type { Database } from "bun:sqlite";
2727 import type { ForgeEvent } from "./events.ts";
2828 import { getAppByRepo } from "./apps.ts";
29 import { canAdmin, canEnterApp, getRepoById } from "./repos.ts";
29 import { canAdmin, canEnterApp, canSeeOwnerFacts, getRepoById } from "./repos.ts";
3030 import type { User } from "./users.ts";
3131
3232 export type EventAudience = "readers" | "admins" | "actor" | "app";
@@ -159,3 +159,34 @@ export function audienceFilter(
159159 }
160160 };
161161 }
162
163 /** What a run event says about spend and placement: the owner's own facts. */
164 const RUN_OWNER_FIELDS = ["costUsd", "turns", "placement", "node"] as const;
165
166 /**
167 * An event as this reader may see it. A `run.*` payload carries its cost,
168 * turns and node, which are owner facts (`canSeeOwnerFacts`) like the same
169 * fields on a run; the rest of the event is unchanged. Webhooks do not pass
170 * through here: they go to admins and keep the full payload.
171 */
172 export function eventForReader(
173 db: Database,
174 viewer: User | undefined,
175 ): (event: ForgeEvent) => ForgeEvent {
176 const owners = new Map<string, boolean>();
177 const isOwner = (repoId: string): boolean => {
178 const known = owners.get(repoId);
179 if (known !== undefined) return known;
180 const repo = getRepoById(db, repoId);
181 const yes = repo !== undefined && canSeeOwnerFacts(db, repo, viewer);
182 owners.set(repoId, yes);
183 return yes;
184 };
185 return (event) => {
186 if (!event.kind.startsWith("run.")) return event;

Good: a shallow copy, so the event on the bus and the copy webhooks get keep their full payload. If a nested payload is ever added to runPayload, this still holds because only top-level keys are deleted.

187 if (event.repoId !== null && isOwner(event.repoId)) return event;
188 const payload = { ...event.payload };
189 for (const field of RUN_OWNER_FIELDS) delete payload[field];
190 return { ...event, payload };
191 };
192 }
src/http/api.ts +2 −2
@@ -165,7 +165,7 @@ import {
165165 } from "../actions/users.ts";
166166 import { listEvents } from "../core/events.ts";
167167 import { audienceFor } from "../core/audience.ts";
168 import { audienceFilter } from "../core/event_audience.ts";
168 import { audienceFilter, eventForReader } from "../core/event_audience.ts";
169169 import {
170170 forkParentFor,
171171 getReposByIds,
@@ -2279,7 +2279,7 @@ export function apiRoutes(router: Router<Handler>): void {
22792279 return json(
22802280 listEvents(ctx.app.db, { repoId: repo.id }).filter(
22812281 audienceFilter(ctx.app.db, ctx.auth.user),
2282 ),
2282 ).map(eventForReader(ctx.app.db, ctx.auth.user)),
22832283 );
22842284 });
22852285
src/http/sse.ts +10 −7
@@ -18,7 +18,7 @@ import { holdForCaller } from "./caller.ts";
1818 import type { App } from "../app.ts";
1919 import type { User } from "../core/users.ts";
2020 import { listEvents, listEventsVisibleTo } from "../core/events.ts";
21 import { audienceFilter } from "../core/event_audience.ts";
21 import { audienceFilter, eventForReader } from "../core/event_audience.ts";
2222 import { canRead, getRepoById } from "../core/repos.ts";
2323 import { mayReadLogOfRun } from "../actions/runs.ts";
2424 import { getRun } from "../core/runs.ts";
@@ -203,7 +203,12 @@ export function viewerStream(
203203 seen.set(repoId, ok);
204204 return ok;
205205 };
206 const named = (event: ForgeEvent): Record<string, unknown> => {
206 // Readable is not the same as meant-for: configuration events are the
207 // admins', a person's own choices are theirs (`core/event_audience.ts`).
outdated

redact is declared below this function (line 221). Declare it above named so the dependency reads top-down and named isn't one call away from a TDZ error.

208 const meant = audienceFilter(app.db, viewer);
209 const redact = eventForReader(app.db, viewer);
210 const named = (raw: ForgeEvent): Record<string, unknown> => {
211 const event = redact(raw);
207212 const repo =
208213 event.repoId === null ? undefined : getRepoById(app.db, event.repoId);
209214 return {
@@ -214,9 +219,6 @@ export function viewerStream(
214219 at: event.createdAt,
215220 };
216221 };
217 // Readable is not the same as meant-for: configuration events are the
218 // admins', a person's own choices are theirs (`core/event_audience.ts`).
219 const meant = audienceFilter(app.db, viewer);
220222 const after = resumeFrom(request);
221223 return eventStream(request, (emit, _close, ready) => {
222224 if (after !== undefined) {
@@ -268,6 +270,7 @@ export function sseStream(
268270 viewer: User | undefined,
269271 ): Response {
270272 const meant = audienceFilter(app.db, viewer);
273 const redact = eventForReader(app.db, viewer);
271274 const after = resumeFrom(request);
272275 return eventStream(request, (emit) => {
273276 // The event id doubles as the SSE id, so a reconnecting client resumes
@@ -278,10 +281,10 @@ export function sseStream(
278281 afterId: after,
279282 limit: 200,
280283 }).reverse())
281 if (meant(event)) emit(event.kind, event, event.id);
284 if (meant(event)) emit(event.kind, redact(event), event.id);

Untested: revert redact(event) to event here (replay of the per-repository stream) and nothing fails. Needs a stream test as a stranger with Last-Event-ID.

282285 return app.bus.subscribe((event: ForgeEvent) => {
283286 if (event.repoId === repoId && meant(event))
284 emit(event.kind, event, event.id);
287 emit(event.kind, redact(event), event.id);

Same for the live path: append a run.finished while a stranger is connected and assert that the frame has no cost, turns or node.

285288 });
286289 });
287290 }
test/event-audience.test.ts +93
@@ -152,6 +152,37 @@ describe("who an event is for", () => {
152152 expect(admin).toContain("app.env_set");
153153 });
154154
155 test("a run event tells a stranger no cost, turns or node, and the owner all of it", async () => {
156 app.bus.append({
157 kind: "run.finished",
158 repoId: repo.id,
159 payload: {
160 runId: "r1",
161 state: "succeeded",
162 costUsd: 1.25,
163 turns: 7,
164 placement: "job",
165 node: "vx-secret-node",
166 },
167 });
168 const read = (headers: Record<string, string>) =>
169 fetch(`${base}/api/v1/repos/alice/open/events`, { headers })
170 .then((r) => r.json() as Promise<{ kind: string; payload: Record<string, unknown> }[]>)
171 .then((all) => all.find((e) => e.kind === "run.finished")!);
172 for (const headers of [{}, { authorization: `Bearer ${bobToken}` }]) {
173 const seen = await read(headers);
174 expect(seen.payload["runId"]).toBe("r1");
175 expect(seen.payload["state"]).toBe("succeeded");
176 for (const f of ["costUsd", "turns", "placement", "node"])
177 expect(f in seen.payload).toBe(false);
178 }
179 const owner = await read({ authorization: `Bearer ${aliceToken}` });
180 expect(owner.payload["costUsd"]).toBe(1.25);
181 expect(owner.payload["turns"]).toBe(7);
182 expect(owner.payload["placement"]).toBe("job");
outdated

Also assert owner.payload["placement"]).toBe("job"). It is one of the four fields, and the owner side never checks it.

183 expect(owner.payload["node"]).toBe("vx-secret-node");
184 });
185
155186 test("the activity rail on the front page says nothing of it to a visitor", async () => {
156187 const page = await fetch(`${base}/`, {
157188 headers: { accept: "text/html" },
@@ -217,4 +248,66 @@ describe("who an event is for", () => {
217248 expect(admin).toContain("hooks.example.test");
218249 }
219250 }, 20_000);
251
252 test("the streams tell a stranger no run cost, turns or node, in replay and live, and the owner all of it", async () => {
253 const run = (runId: string) =>
254 app.bus.append({
255 kind: "run.finished",
256 repoId: repo.id,
257 payload: {
258 runId,
259 state: "succeeded",
260 costUsd: 3.5,
261 turns: 9,
262 placement: "job",
263 node: "vx-stream-node",
264 },
265 });
266 const before = listEvents(app.db, { repoId: repo.id, limit: 500 })
267 .map((e) => e.id)
268 .sort()[0]!;
269 run("replayed-run");
270 const read = async (path: string, headers: Record<string, string>) => {
271 const controller = new AbortController();
272 const res = await fetch(`${base}${path}`, {
273 headers: {
274 accept: "text/event-stream",
275 "last-event-id": before,
276 ...headers,
277 },
278 signal: controller.signal,
279 });
280 const reader = res.body!.getReader();
281 let text = "";
282 const pump = (async () => {
283 try {
284 for (;;) {
285 const { value, done } = await reader.read();
286 if (done) break;
287 text += new TextDecoder().decode(value);
288 }
289 } catch {
290 // aborted
291 }
292 })();
293 await Bun.sleep(150);
294 run("live-run");
295 await Bun.sleep(300);
296 controller.abort();
297 await pump;
298 return text;
299 };
300 for (const path of ["/alice/open/stream", "/events"]) {
301 const stranger = await read(path, {});
302 expect(stranger).toContain("replayed-run");
303 expect(stranger).toContain("live-run");
304 for (const f of ["costUsd", "turns", "placement", "vx-stream-node"])
305 expect(stranger).not.toContain(f);
306 const owner = await read(path, { authorization: `Bearer ${aliceToken}` });
307 expect(owner).toContain("replayed-run");
308 expect(owner).toContain("live-run");
309 for (const f of ["costUsd", "turns", "placement", "vx-stream-node"])
310 expect(owner).toContain(f);
311 }
312 }, 20_000);
220313 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs