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

A stranger sees the app, not the owner's books: gate spend, crew and runs on write access #79

Merged opened this

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

Merged

scout/forge:scout/65-a-stranger-sees-the-app-not-the-owner-s → main 4 commits fork

  • Merged. scout/65-a-stranger-sees-the-app-not-the-owner-s was merged into main as d458278.

Review

Sign in to review this change.
6 files changed +464 −100
docs/STUDIO.md +22 −1
@@ -53,6 +53,23 @@ current about deployments: a deploy run for the app being viewed refetches
5353 the head card and previews list in place, so a change serving in its
5454 preview shows up without a reload.
5555
56 **The owner's facts and the public's.** What an app *is* — its description,
57 whether it is live, its address, the look, *Open the app*, and how many
58 changes it has shipped — is the point of a public app and stays on the
59 page for anyone who can read the repository, signed in or not. What
60 running it *costs* — every dollar figure, the day's ceiling and slot
61 count, the decision ledger, and who is on the crew — is the owner's own
62 book-keeping, not the app's, and is computed at all only for a viewer who
63 can write to the repository: `canSeeOwnerFacts` (`src/core/repos.ts`,
64 today exactly `canWrite`) is the one predicate every surface asks before
65 it renders or even queries one of these — the studio page, its live
66 `/app/state` fragment, a run's own page and its JSON and live-tail
67 answers over the API, and the board. What is happening now still reads as
68 the app's own story for such a viewer — a change being built, a preview
69 coming up — but by "the crew" rather than an agent's own name, and never
70 with a run's cost, turns or steer box. A private repository is unaffected:
71 it already answers only to people who can read it at all.
72
5673 **The box** is the front door. A sentence becomes an issue — the first line
5774 its title, the rest its body — and the routing decision hands it to a
5875 builder: the builder with the least in flight, then by name. That is one
@@ -225,7 +242,11 @@ against the ceiling, runs, shipped, and whether it is paused. Every region
225242 is live — it swaps itself when a matching event lands, and the dot in the
226243 corner says whether the stream is connected. `?apps=a,b` narrows it to some
227244 apps. Keys: `f` fullscreen, `1`–`4` one region full-bleed, `0` all.
228 Everything links somewhere; nothing needs a click.
245 Everything links somewhere; nothing needs a click. The board is a wall for
246 apps you can write to: a public app someone else owns can still appear in
247 it (`listReposVisibleTo` includes every public repo), but its cost, and
248 whoever is on its crew, never do — that row says only "the crew" and
249 carries no dollar figure, same `canSeeOwnerFacts` as the app's own page.
229250
230251 ## What the studio never does
231252
src/core/repos.ts +16
@@ -611,6 +611,22 @@ export function canWrite(
611611 return accessAllows(orgAccessFor(db, repo, user), "write");
612612 }
613613
614 /**
615 * Money, the crew roster, autonomy limits and a run's own cost describe
616 * running the app, not what it is — a stranger who can only read a public
617 * app sees what it does and what it has shipped, never what it costs or
618 * who is on it. The same test as `canWrite`, named for what it decides
619 * here, so every surface that shows one of these facts asks this one
620 * question rather than inventing its own.
621 */
622 export function canSeeOwnerFacts(
623 db: Database,
624 repo: Repo,
625 user: User | undefined,
626 ): boolean {
627 return canWrite(db, repo, user);
628 }
629
614630 /**
615631 * Which of these repositories the user may write — `canWrite` for many rows
616632 * in one query per chunk, through `REPO_WRITABLE`. The dashboard's inbox
src/http/api.ts +19 −3
@@ -109,6 +109,7 @@ import { getPull, listPulls, pullDiff, type Pull } from "../core/pulls.ts";
109109 import {
110110 canAdmin,
111111 canRead,
112 canSeeOwnerFacts,
112113 countsForRepos,
113114 getRepo,
114115 listReposByOwner,
@@ -2668,6 +2669,14 @@ export function apiRoutes(router: Router<Handler>): void {
26682669 return json({ url: link.value });
26692670 });
26702671
2672 // A run's cost and turn count are the owner's own facts (`canSeeOwnerFacts`,
2673 // same predicate the studio page uses) — a viewer who can only read a
2674 // public repository gets every other field, never those two.
2675 const runForViewer = (ctx: Ctx, repo: Repo) =>
2676 canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user)
2677 ? (r: Run): Run => r
2678 : (r: Run): Run => ({ ...r, costUsd: 0, turns: 0 });
2679
26712680 router.add("GET", "/api/v1/repos/:owner/:name/runs", (ctx) => {
26722681 const repo = loadRepo(ctx);
26732682 if (!repo) return notFound();
@@ -2682,14 +2691,15 @@ export function apiRoutes(router: Router<Handler>): void {
26822691 limit,
26832692 });
26842693 if (!runs.ok) return notFound();
2685 return json(paged(runs.value, limit, (r) => r.id));
2694 const forViewer = runForViewer(ctx, repo);
2695 return json(paged(runs.value.map(forViewer), limit, (r) => r.id));
26862696 });
26872697
26882698 router.add("GET", "/api/v1/repos/:owner/:name/runs/:id", (ctx) => {
26892699 const repo = loadRepo(ctx);
26902700 if (!repo) return notFound();
26912701 const run = getRepoRun(ctx.app, ctx.auth.user, repo, ctx.params.id ?? "");
2692 return run.ok ? json(run.value) : notFound();
2702 return run.ok ? json(runForViewer(ctx, repo)(run.value)) : notFound();
26932703 });
26942704
26952705 // The log: text, or a live tail as server-sent events (`?follow=1`, or
@@ -2712,6 +2722,12 @@ export function apiRoutes(router: Router<Handler>): void {
27122722 });
27132723 }
27142724 const run = found.value.run;
2725 // The cost and turn count are the owner's own facts, same as the studio
2726 // page beside this stream — a viewer who cannot write here never gets
2727 // them, whether the page renders or a fetch asks this stream directly.
2728 const ownerFacts = canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user);
2729 const forViewer = (r: Run): Run =>
2730 ownerFacts ? r : { ...r, costUsd: 0, turns: 0 };
27152731 // Every line carries its number, so a browser that reconnects — a
27162732 // proxy in front of the forge cuts long responses — sends the last it
27172733 // saw and hears only what followed, never the log again from the top.
@@ -2729,7 +2745,7 @@ export function apiRoutes(router: Router<Handler>): void {
27292745 for (const line of readRunLog(ctx.app.config.dataDir, run.id).lines)
27302746 send(line);
27312747 const finish = (final: Run): void => {
2732 emit("done", final, final.id);
2748 emit("done", forViewer(final), final.id);
27332749 close();
27342750 };
27352751 const unsubscribe = ctx.app.bus.subscribe((event) => {
src/web/pages/board.ts +27 −6
@@ -16,7 +16,11 @@ import {
1616 import { getIssue, getIssuesByIds, type Issue } from "../../core/issues.ts";
1717 import { looksForApps } from "../../core/looks.ts";
1818 import { listPulls, mergedPullsForRepos, type Pull } from "../../core/pulls.ts";
19 import { listReposVisibleTo, type Repo } from "../../core/repos.ts";
19 import {
20 canSeeOwnerFacts,
21 listReposVisibleTo,
22 type Repo,
23 } from "../../core/repos.ts";
2024 import { listActiveRuns, listRuns, type Run } from "../../core/runs.ts";
2125 import { getUsersByIds } from "../../core/users.ts";
2226 import { autonomyStatus, startOfTodayUtc } from "../../crew/autonomy.ts";
@@ -76,6 +80,10 @@ interface Scope {
7680 readonly ids: readonly string[];
7781 readonly byId: ReadonlyMap<string, Repo>;
7882 readonly records: ReadonlyMap<string, AppRecord>;
83 /** Repos whose spend, runs and decisions this viewer may see — write
84 * access, same as the studio. A repo the viewer can only read is on the
85 * board (it is still theirs to watch land) but carries none of that. */
86 readonly ownerFacts: ReadonlySet<string>;
7987 }
8088
8189 // ---- the page ----
@@ -108,6 +116,9 @@ export function boardPage(ctx: Ctx): Response {
108116 ids: repos.map((r) => r.id),
109117 byId: new Map(repos.map((r) => [r.id, r])),
110118 records,
119 ownerFacts: new Set(
120 repos.filter((r) => canSeeOwnerFacts(db, r, user)).map((r) => r.id),
121 ),
111122 };
112123
113124 const body = html`<div class="board">
@@ -200,10 +211,13 @@ function working(ctx: Ctx, scope: Scope): Html {
200211 );
201212 const rows = runs.map((run) => {
202213 const repo = scope.byId.get(run.repoId)!;
203 const who =
214 // Who is on the crew is an owner's fact — a repo this viewer can only
215 // read reads here as the app's own story, never by the agent's name.
216 const actorName =
204217 run.actorId === null
205218 ? "crew"
206219 : (actors.get(run.actorId)?.username ?? "crew");
220 const who = scope.ownerFacts.has(repo.id) ? actorName : "the crew";
207221 const issue = run.issueId === null ? undefined : about.get(run.issueId);
208222 const word = stateWord(run);
209223 return html`<li>
@@ -226,7 +240,7 @@ function working(ctx: Ctx, scope: Scope): Html {
226240 : reasonWords(run.reason)}</span
227241 >
228242 ${when(
229 run.costUsd > 0,
243 run.costUsd > 0 && scope.ownerFacts.has(repo.id),
230244 () => html`<span class="num">${money(run.costUsd)}</span>`,
231245 )}
232246 </span>
@@ -274,6 +288,7 @@ function landed(ctx: Ctx, scope: Scope): Html {
274288 const rows = pulls.map((pull: Pull) => {
275289 const repo = scope.byId.get(pull.repoId)!;
276290 const picture = pictureOf(repo);
291 const author = scope.ownerFacts.has(repo.id) ? pull.authorName : "the crew";
277292 return html`<li>
278293 <a
279294 class="board-card board-landed"
@@ -292,7 +307,7 @@ function landed(ctx: Ctx, scope: Scope): Html {
292307 >
293308 <span class="board-landed-title">${pull.title}</span>
294309 <span class="board-when">${time(pull.mergedAt ?? pull.createdAt)}</span>
295 <span class="board-landed-meta">${repo.name} · ${pull.authorName}</span>
310 <span class="board-landed-meta">${repo.name} · ${author}</span>
296311 </a>
297312 </li>`;
298313 });
@@ -310,7 +325,12 @@ function landed(ctx: Ctx, scope: Scope): Html {
310325
311326 function decided(ctx: Ctx, scope: Scope): Html {
312327 const db = ctx.app.db;
313 const decisions = listDecisionsForRepos(db, scope.ids, 12);
328 // The decision ledger is the owner's own record of what the crew chose —
329 // never shown for a repository this viewer can only read.
330 const ids = scope.repos
331 .filter((r) => scope.ownerFacts.has(r.id))
332 .map((r) => r.id);
333 const decisions = listDecisionsForRepos(db, ids, 12);
314334 const about = getIssuesByIds(
315335 db,
316336 decisions.map((d) => d.issueId).filter((id): id is string => id !== null),
@@ -368,8 +388,9 @@ function decided(ctx: Ctx, scope: Scope): Html {
368388 function today(ctx: Ctx, scope: Scope): Html {
369389 const db = ctx.app.db;
370390 const since = startOfTodayUtc(new Date()).toISOString();
391 // Today's spend against the ceiling is the owner's own dashboard.
371392 const rows = scope.repos
372 .filter((r) => r.autonomy !== "off")
393 .filter((r) => r.autonomy !== "off" && scope.ownerFacts.has(r.id))
373394 .map((repo) => {
374395 const s = autonomyStatus(ctx.app, repo);
375396 const runs = listRuns(db, repo.id, { kind: "agent", limit: 200 }).filter(
src/web/pages/studio.ts +147 −90
@@ -50,7 +50,7 @@ import {
5050 pullForBranch,
5151 type Pull,
5252 } from "../../core/pulls.ts";
53 import { canWrite, listReposVisibleTo, type Repo } from "../../core/repos.ts";
53 import { canSeeOwnerFacts, listReposVisibleTo, type Repo } from "../../core/repos.ts";
5454 import { listReviews } from "../../core/reviews.ts";
5555 import {
5656 activeRunFor,
@@ -300,12 +300,16 @@ function lastHumanWord(ctx: Ctx, issue: Issue, agentName: string): string {
300300 * stopped (red past the crew's own limit since a person last spoke), or
301301 * green and waiting for a look. Plus a repository paused at its ceiling.
302302 */
303 export function decisionsFor(ctx: Ctx, repos: readonly Repo[]): Decision[] {
303 export function decisionsFor(ctx: Ctx, allRepos: readonly Repo[]): Decision[] {
304304 const db = ctx.app.db;
305305 const out: Decision[] = [];
306 const byId = new Map(repos.map((r) => [r.id, r]));
307306 const user = ctx.auth.user;
308307 if (!user) return out;
308 // Shipping, closing and the day's ceiling are the owner's own decisions —
309 // a reader who cannot write here is never asked to make one, and never
310 // sees the dollar figure a "paused" card would otherwise carry.
311 const repos = allRepos.filter((r) => canSeeOwnerFacts(db, r, user));
312 const byId = new Map(repos.map((r) => [r.id, r]));
309313 const pulls = listOpenAgentPullsVisibleTo(db, user, 50).filter((p) =>
310314 byId.has(p.repoId),
311315 );
@@ -578,7 +582,7 @@ function decisionWords(stats: readonly DecisionStat[]): Html {
578582 function stopForm(ctx: Ctx, repo: Repo, run: Run): Html | null {
579583 if (run.state !== "running" && run.state !== "queued") return null;
580584 if (run.kind !== "agent") return null;
581 if (!canWrite(ctx.app.db, repo, ctx.auth.user)) return null;
585 if (!canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user)) return null;
582586 return html`<form
583587 method="post"
584588 action="${repoBase(repo)}/runs/${run.id}/cancel"
@@ -856,7 +860,7 @@ function appPanels(
856860 const previews = previewsPanel(base, runtime, record, openPulls, envs);
857861 const production = envs.find((e) => e.env === "production");
858862 const url = runtime?.urlFor(record, "production");
859 const writer = canWrite(db, repo, ctx.auth.user);
863 const writer = canSeeOwnerFacts(db, repo, ctx.auth.user);
860864 // The state in one word, and under it the same facts the App page shows —
861865 // why it is not up, and the deploy whose log says the rest.
862866 const live = envPhrase(production);
@@ -924,6 +928,10 @@ export async function studioApp(
924928 ): Promise<Response> {
925929 const db = ctx.app.db;
926930 const base = repoBase(repo);
931 // Money, the crew roster, autonomy limits and the decision ledger are the
932 // owner's facts, not the app's — a viewer who cannot write here reads this
933 // page as a stranger, whatever else they can see of the repository.
934 const ownerFacts = canSeeOwnerFacts(db, repo, ctx.auth.user);
927935 const wordCtx: WordCtx = {
928936 db,
929937 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
@@ -946,23 +954,32 @@ export async function studioApp(
946954 const decisions = withLikely(ctx, decisionsFor(ctx, [repo]));
947955 const openPulls = listPulls(db, repo.id, { state: "open", limit: 10 });
948956 const week = new Date(Date.now() - 7 * 24 * 60 * 60_000).toISOString();
949 const weekRuns = listRuns(db, repo.id, { kind: "agent", limit: 200 }).filter(
950 (r) => r.createdAt >= week,
951 );
952 const spent = weekRuns.reduce((s, r) => s + r.costUsd, 0);
953957 const shipped = listPulls(db, repo.id, {
954958 state: "closed",
955959 limit: 100,
956960 }).filter((p) => p.mergedAt !== null && p.mergedAt >= week).length;
957 const ledgerWeek = decisionStats(db, repo.id, week);
961 // The rest is spend, worked out only for a viewer who is going to see it.
962 const weekRuns = ownerFacts
963 ? listRuns(db, repo.id, { kind: "agent", limit: 200 }).filter(
964 (r) => r.createdAt >= week,
965 )
966 : [];
967 const spent = weekRuns.reduce((s, r) => s + r.costUsd, 0);
968 const ledgerWeek = ownerFacts ? decisionStats(db, repo.id, week) : [];
958969 const autonomy =
959 repo.autonomy === "off" ? undefined : autonomyStatus(ctx.app, repo);
970 !ownerFacts || repo.autonomy === "off"
971 ? undefined
972 : autonomyStatus(ctx.app, repo);
960973
961974 // ---- the app itself ----
962975 const { head, previews } = appPanels(ctx, repo, record, openPulls);
963976
964977 // ---- the box ----
965 const tell = html`<form
978 // Telling the crew, and who takes it, is the owner's own front door — a
979 // stranger sees the app, not who is behind it.
980 const tell = !ownerFacts
981 ? null
982 : html`<form
966983 class="card comment-form studio-tell"
967984 method="post"
968985 action="${base}/tell"
@@ -1018,16 +1035,21 @@ export async function studioApp(
10181035 (r, idx, all) => all.findIndex((x) => x.issueId === r.issueId) === idx,
10191036 )
10201037 .slice(0, 6);
1038 // Who is on the crew is an owner's fact same as its roster card — a
1039 // viewer who cannot write here reads "Now" as the app's own story, never
1040 // by the agent's name.
1041 const nameFor = (name: string): string => (ownerFacts ? name : "the crew");
10211042 const nowRows: Html[] = [
10221043 ...(await Promise.all(
10231044 openPulls.map(async (p) => {
10241045 const { word, note } = await pullWord(wordCtx, p);
1046 const who = nameFor(p.authorName);
10251047 return row({
10261048 href: `${base}/pulls/${p.number}`,
10271049 nav: true,
1028 lead: avatarOf(p.authorName),
1050 lead: avatarOf(who),
10291051 title: p.title,
1030 meta: html`${p.authorName} · ${note} · ${time(p.createdAt)}`,
1052 meta: html`${who} · ${note} · ${time(p.createdAt)}`,
10311053 trail: wordBadge(word),
10321054 });
10331055 }),
@@ -1035,10 +1057,9 @@ export async function studioApp(
10351057 ...(await Promise.all(
10361058 rowsNow.map(async (r) => {
10371059 const { word, note } = await runWord(wordCtx, r);
1038 const who =
1039 r.actorId === null
1040 ? "crew"
1041 : (actors.get(r.actorId)?.username ?? "crew");
1060 const who = nameFor(
1061 r.actorId === null ? "crew" : (actors.get(r.actorId)?.username ?? "crew"),
1062 );
10421063 const about = r.issueId === null ? undefined : issues.get(r.issueId);
10431064 const stop = stopForm(ctx, repo, r);
10441065 return row({
@@ -1059,17 +1080,18 @@ export async function studioApp(
10591080 });
10601081 }),
10611082 )),
1062 ...waiting.map((i) =>
1063 row({
1083 ...waiting.map((i) => {
1084 const who = nameFor(i.assigneeName ?? "crew");
1085 return row({
10641086 href: `${base}/issues/${i.number}`,
10651087 nav: true,
1066 lead: avatarOf(i.assigneeName ?? "crew"),
1088 lead: avatarOf(who),
10671089 title: i.title,
1068 meta: html`${i.assigneeName} · told ${time(i.createdAt)}, not picked up
1090 meta: html`${who} · told ${time(i.createdAt)}, not picked up
10691091 yet`,
10701092 trail: wordBadge("waiting"),
1071 }),
1072 ),
1093 });
1094 }),
10731095 ];
10741096 const now =
10751097 nowRows.length === 0
@@ -1094,8 +1116,12 @@ export async function studioApp(
10941116 );
10951117
10961118 // ---- the rail ----
1097 const crewCard =
1098 crew.length === 0
1119 // Crew, spend and autonomy are the owner's facts; a viewer who cannot
1120 // write here gets only the shipped count, never the roster, the money or
1121 // the ledger — and the page never asks for the rest on their behalf.
1122 const crewCard = !ownerFacts
1123 ? null
1124 : crew.length === 0
10991125 ? card(
11001126 html`<p class="muted small">
11011127 Nobody yet. <a href="${base}/settings/crew">Add someone.</a>
@@ -1125,65 +1151,74 @@ export async function studioApp(
11251151 },
11261152 );
11271153 const weekCard = card(
1128 html`<dl>
1129 <dt>Shipped</dt>
1130 <dd>${shipped} change${shipped === 1 ? "" : "s"}</dd>
1131 <dt>Runs</dt>
1132 <dd>${weekRuns.length}</dd>
1133 <dt>Spent</dt>
1134 <dd>
1135 $${spent.toFixed(2)}${shipped > 0
1136 ? html` <span class="muted"
1137 >· $${(spent / shipped).toFixed(2)} per shipped change</span
1138 >`
1139 : null}
1140 </dd>
1141 ${when(
1142 ledgerWeek.length > 0,
1143 () =>
1144 html`<dt>Decided</dt>
1145 <dd>${decisionWords(ledgerWeek)}</dd>`,
1146 )}
1147 </dl>`,
1154 !ownerFacts
1155 ? html`<dl>
1156 <dt>Shipped</dt>
1157 <dd>${shipped} change${shipped === 1 ? "" : "s"}</dd>
1158 </dl>`
1159 : html`<dl>
1160 <dt>Shipped</dt>
1161 <dd>${shipped} change${shipped === 1 ? "" : "s"}</dd>
1162 <dt>Runs</dt>
1163 <dd>${weekRuns.length}</dd>
1164 <dt>Spent</dt>
1165 <dd>
1166 $${spent.toFixed(2)}${shipped > 0
1167 ? html` <span class="muted"
1168 >· $${(spent / shipped).toFixed(2)} per shipped change</span
1169 >`
1170 : null}
1171 </dd>
1172 ${when(
1173 ledgerWeek.length > 0,
1174 () =>
1175 html`<dt>Decided</dt>
1176 <dd>${decisionWords(ledgerWeek)}</dd>`,
1177 )}
1178 </dl>`,
11481179 {
11491180 head: html`${icon("activity")}
11501181 <h2>This week</h2>`,
11511182 extra: "about",
11521183 },
11531184 );
1154 const ownCard = card(
1155 autonomy === undefined
1156 ? html`<p class="muted small">
1157 Off. <a href="${base}/settings/autonomy">Turn it on</a> and the crew
1158 works what is ready without being asked.
1159 </p>`
1160 : html`<dl>
1161 <dt>Mode</dt>
1162 <dd>${repo.autonomy === "autonomous" ? "Autonomous" : "Assisted"}</dd>
1163 <dt>Today</dt>
1164 <dd>
1165 $${autonomy.spentTodayUsd.toFixed(2)} of
1166 $${autonomy.ceilingUsd.toFixed(0)}
1167 </dd>
1168 <dt>Slots</dt>
1169 <dd>${autonomy.active} of ${autonomy.slots} in use</dd>
1170 ${autonomy.paused
1171 ? html`<dt>Paused</dt>
1172 <dd>
1173 ${autonomy.paused === "ceiling"
1174 ? "at the ceiling, until 00:00 UTC"
1175 : autonomy.paused}
1176 </dd>`
1177 : null}
1178 </dl>`,
1179 {
1180 head: html`${icon("play")}
1181 <h2>On its own</h2>
1182 <span class="spacer"></span>
1183 ${btn("Change", { href: `${base}/settings/autonomy`, size: "sm" })}`,
1184 extra: "about",
1185 },
1186 );
1185 const ownCard = !ownerFacts
1186 ? null
1187 : card(
1188 autonomy === undefined
1189 ? html`<p class="muted small">
1190 Off. <a href="${base}/settings/autonomy">Turn it on</a> and the
1191 crew works what is ready without being asked.
1192 </p>`
1193 : html`<dl>
1194 <dt>Mode</dt>
1195 <dd>
1196 ${repo.autonomy === "autonomous" ? "Autonomous" : "Assisted"}
1197 </dd>
1198 <dt>Today</dt>
1199 <dd>
1200 $${autonomy.spentTodayUsd.toFixed(2)} of
1201 $${autonomy.ceilingUsd.toFixed(0)}
1202 </dd>
1203 <dt>Slots</dt>
1204 <dd>${autonomy.active} of ${autonomy.slots} in use</dd>
1205 ${autonomy.paused
1206 ? html`<dt>Paused</dt>
1207 <dd>
1208 ${autonomy.paused === "ceiling"
1209 ? "at the ceiling, until 00:00 UTC"
1210 : autonomy.paused}
1211 </dd>`
1212 : null}
1213 </dl>`,
1214 {
1215 head: html`${icon("play")}
1216 <h2>On its own</h2>
1217 <span class="spacer"></span>
1218 ${btn("Change", { href: `${base}/settings/autonomy`, size: "sm" })}`,
1219 extra: "about",
1220 },
1221 );
11871222 const more = html`<p class="muted small">
11881223 <a href="${base}/map">The map</a> ·
11891224 <a href="${base}/issues">Everything told</a> ·
@@ -1257,12 +1292,18 @@ export async function studioRun(
12571292 ): Promise<Response> {
12581293 const db = ctx.app.db;
12591294 const base = repoBase(repo);
1295 const writer = canSeeOwnerFacts(db, repo, ctx.auth.user);
12601296 const actor =
12611297 run.actorId === null ? undefined : getUserById(db, run.actorId);
12621298 const issue =
12631299 run.issueId === null ? undefined : getIssueById(db, run.issueId);
1264 const who = actor?.username ?? "an agent";
1265 const title = runTitle(run, actor?.username, issue);
1300 // Who is on the crew is an owner's fact, same as the app's own page — a
1301 // viewer who cannot write here reads the transcript as the crew's own
1302 // work, never by the agent's name (title, every line, the live stream's
1303 // `data-who`, the running pulse). The steer box below is writer-only
1304 // already, so it only ever renders with the real name.
1305 const who = writer ? (actor?.username ?? "an agent") : "the crew";
1306 const title = runTitle(run, writer ? actor?.username : "the crew", issue);
12661307 const wordCtx: WordCtx = {
12671308 db,
12681309 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
@@ -1271,7 +1312,6 @@ export async function studioRun(
12711312 };
12721313 const { word } = await runWord(wordCtx, run);
12731314 const unfinished = !isTerminal(run.state);
1274 const writer = canWrite(db, repo, ctx.auth.user);
12751315 const duration = durationText(run);
12761316 const stream = `/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${run.id}/log?follow=1`;
12771317 const shown = lines.slice(-RUN_TRANSCRIPT_CAP);
@@ -1300,12 +1340,22 @@ export async function studioRun(
13001340 >#${i.number} ${i.title}</a
13011341 >`,
13021342 )}
1303 <span class="num" id="run-cost" ${run.costUsd > 0 ? null : html`hidden`}
1304 >${costText(run.costUsd)} spent</span
1305 >
1306 <span class="num" id="run-turns" ${run.turns > 0 ? null : html`hidden`}
1307 >${run.turns} turn${run.turns === 1 ? "" : "s"}</span
1308 >
1343 ${when(
1344 writer,
1345 () =>
1346 html`<span
1347 class="num"
1348 id="run-cost"
1349 ${run.costUsd > 0 ? null : html`hidden`}
1350 >${costText(run.costUsd)} spent</span
1351 >
1352 <span
1353 class="num"
1354 id="run-turns"
1355 ${run.turns > 0 ? null : html`hidden`}
1356 >${run.turns} turn${run.turns === 1 ? "" : "s"}</span
1357 >`,
1358 )}
13091359 ${when(
13101360 duration,
13111361 (d) =>
@@ -2197,6 +2247,12 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
21972247 const db = ctx.app.db;
21982248 const { repos: appRepos, records } = appsOf(ctx, user);
21992249 const runtime = appsRuntime(ctx.app);
2250 // A public app you can only read appears here too, but who is on its
2251 // crew is its owner's fact, not yours to see — same predicate as the
2252 // app's own page.
2253 const ownerFacts = new Set(
2254 appRepos.filter((r) => canSeeOwnerFacts(db, r, user)).map((r) => r.id),
2255 );
22002256 // One read for the newest run of every app, one for who ran them: the
22012257 // home must not get slower as the apps grow.
22022258 const latestRuns = latestAgentRunsForRepos(
@@ -2233,7 +2289,7 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
22332289 latest,
22342290 );
22352291 const who =
2236 latest?.actorId == null
2292 latest?.actorId == null || !ownerFacts.has(repo.id)
22372293 ? undefined
22382294 : actors.get(latest.actorId)?.username;
22392295 const asks = asksByRepo.get(repo.id) ?? 0;
@@ -2318,10 +2374,11 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
23182374 : listCard(
23192375 running.map((r) => {
23202376 const repo = byId.get(r.repoId)!;
2321 const who =
2377 const actorName =
23222378 r.actorId === null
23232379 ? "crew"
23242380 : (runActors.get(r.actorId)?.username ?? "crew");
2381 const who = ownerFacts.has(repo.id) ? actorName : "the crew";
23252382 const stop = stopForm(ctx, repo, r);
23262383 return row({
23272384 ...(stop
test/studio.test.ts +233
@@ -26,6 +26,7 @@ import {
2626 updateRepoSettings,
2727 } from "../src/actions/repos.ts";
2828 import { setCrewRole } from "../src/core/agents.ts";
29 import { recordDecision } from "../src/core/decisions.ts";
2930 import { getIssue, listIssues, type Issue } from "../src/core/issues.ts";
3031 import { recordLook } from "../src/core/looks.ts";
3132 import { getPull, type Pull } from "../src/core/pulls.ts";
@@ -37,6 +38,7 @@ import {
3738 getRun,
3839 markServing,
3940 noteRunProgress,
41 type Run,
4042 } from "../src/core/runs.ts";
4143 import { createUser, getUserByName, type User } from "../src/core/users.ts";
4244 import type { Decider } from "../src/crew/decide.ts";
@@ -2193,6 +2195,237 @@ describe("working: a run's own page in the studio", () => {
21932195 });
21942196 });
21952197
2198 /**
2199 * Issue #65: a public app's studio page is the app, not the owner's books.
2200 * `canSeeOwnerFacts` (`src/core/repos.ts`) is the one predicate — the same
2201 * as `canWrite` today — that every surface carrying money, the crew or the
2202 * decision ledger must ask before it computes or renders one of those.
2203 */
2204 describe("a stranger sees the app, not the owner's books", () => {
2205 let dataDir: string;
2206 let app: App;
2207 let servers: Servers;
2208 let base: string;
2209 let repo: Repo;
2210 let alice: User;
2211 let ownerCookie: string;
2212 let strangerCookie: string;
2213 let run: Run;
2214
2215 const signIn = async (username: string): Promise<string> =>
2216 (
2217 (
2218 await fetch(`${base}/signin`, {
2219 method: "POST",
2220 body: new URLSearchParams({
2221 username,
2222 password: "a-good-passphrase",
2223 }),
2224 redirect: "manual",
2225 })
2226 ).headers.get("set-cookie") ?? ""
2227 ).split(";")[0]!;
2228
2229 const get = (path: string, cookie?: string) =>
2230 fetch(`${base}${path}`, {
2231 headers: {
2232 cookie: cookie === undefined ? "forge_mode=studio" : `${cookie}; forge_mode=studio`,
2233 },
2234 redirect: "manual",
2235 });
2236
2237 beforeAll(async () => {
2238 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-public-"));
2239 app = createApp({ dataDir, port: 0 });
2240 servers = await serve(app);
2241 base = servers.url;
2242 const a = await createUser(app.db, {
2243 username: "alice",
2244 email: "[email protected]",
2245 password: "a-good-passphrase",
2246 adminIfFirst: true,
2247 });
2248 if (!a.ok) throw new Error(a.error);
2249 alice = a.value;
2250 const c = await createUser(app.db, {
2251 username: "carol",
2252 email: "[email protected]",
2253 password: "a-good-passphrase",
2254 });
2255 if (!c.ok) throw new Error(c.error);
2256 ownerCookie = await signIn("alice");
2257 strangerCookie = await signIn("carol");
2258 const made = await createRepo(app, alice, {
2259 name: "beacon",
2260 visibility: "public",
2261 description: "a light that stays on",
2262 });
2263 if (!made.ok) throw new Error(String(made.error));
2264 repo = made.value;
2265 const seeded = await commitToBranch(app, alice, repo, {
2266 message: "Start",
2267 files: [{ path: "README.md", content: "beacon\n" }],
2268 });
2269 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
2270 const enabled = await enableApp(app, alice, repo, { slug: "beacon" });
2271 if (!enabled.ok) throw new Error(String(enabled.error));
2272 const scout = await createAgent(app, alice, { username: "keeper" });
2273 if (!scout.ok) throw new Error(String(scout.error));
2274 expect(addToCrew(app, alice, repo, "keeper").ok).toBe(true);
2275 const on = await updateRepoSettings(app, alice, repo, {
2276 autonomy: "assisted",
2277 autonomyDirection: "",
2278 autonomyCeilingUsd: 25,
2279 autonomySlots: 2,
2280 autonomyBy: alice.id,
2281 });
2282 expect(on.ok).toBe(true);
2283
2284 const tell = openIssue(app, alice, repo, { title: "Trim the wick" });
2285 if (!tell.ok) throw new Error(String(tell.error));
2286 const branch = await commitToBranch(app, alice, repo, {
2287 branch: "keeper/trim-the-wick",
2288 base: "main",
2289 message: "Trim the wick",
2290 files: [{ path: "WICK.md", content: "trim\n" }],
2291 });
2292 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
2293 const opened = await openPull(app, alice, repo, {
2294 title: "Trim the wick",
2295 body: `Closes #${tell.value.number}`,
2296 baseBranch: "main",
2297 headBranch: "keeper/trim-the-wick",
2298 });
2299 if (!opened.ok) throw new Error(String(opened.error));
2300 const keeperId = getUserByName(app.db, "keeper")!.id;
2301 run = createRun(app.db, {
2302 repoId: repo.id,
2303 kind: "agent",
2304 issueId: tell.value.id,
2305 branch: "keeper/trim-the-wick",
2306 actorId: keeperId,
2307 reason: "assigned",
2308 });
2309 claimRun(app.db, run.id);
2310 run =
2311 finishRun(app.db, run.id, {
2312 state: "ok",
2313 summary: `opened pull #${opened.value.number}`,
2314 costUsd: 42.5,
2315 turns: 7,
2316 }) ?? run;
2317 const merged = await mergePull(app, alice, repo, opened.value, "squash");
2318 if (!merged.ok) throw new Error(String(merged.error));
2319 recordDecision(app.db, {
2320 kind: "land",
2321 repoId: repo.id,
2322 issueId: tell.value.id,
2323 state: { card: "ask", head: opened.value.headOid },
2324 answer: "ship",
2325 p: 0.9,
2326 by: "rule",
2327 ms: 3,
2328 });
2329 });
2330
2331 afterAll(async () => {
2332 servers.stop();
2333 app.close();
2334 await rm(dataDir, { recursive: true, force: true });
2335 });
2336
2337 test("anonymous and read-only visitors get the app, not the owner's numbers; the owner still gets everything", async () => {
2338 const anon = await (await get("/alice/beacon")).text();
2339 const stranger = await (await get("/alice/beacon", strangerCookie)).text();
2340 const owner = await (await get("/alice/beacon", ownerCookie)).text();
2341
2342 for (const page of [anon, stranger]) {
2343 expect(page).not.toMatch(/\$\d/);
2344 expect(page).not.toContain("keeper");
2345 expect(page).not.toContain(">Decided<");
2346 expect(page).not.toContain(">On its own<");
2347 expect(page).not.toContain(">Slots<");
2348 expect(page).not.toContain("Nobody yet");
2349 // The app itself is still there: what it is, that it shipped, a way in.
2350 expect(page).toContain("a light that stays on");
2351 expect(page).toContain(">Shipped<");
2352 expect(page).toContain("1 change");
2353 expect(page).toContain("Open the app");
2354 }
2355
2356 expect(owner).toContain("$42.50");
2357 expect(owner).toContain("keeper");
2358 expect(owner).toContain(">On its own<");
2359 expect(owner).toContain(">Decided<");
2360 });
2361
2362 test("the app's live fragment carries no owner fact either", async () => {
2363 const fragment = await (await get("/alice/beacon/app/state")).text();
2364 expect(fragment).not.toMatch(/\$\d/);
2365 expect(fragment).not.toContain("keeper");
2366 });
2367
2368 test("a run's own page hides cost, turns and the crew's name from a stranger and from an anonymous reader; the JSON API redacts the run the same way", async () => {
2369 const anonPage = await (await get(`/alice/beacon/runs/${run.id}`)).text();
2370 const strangerPage = await (
2371 await get(`/alice/beacon/runs/${run.id}`, strangerCookie)
2372 ).text();
2373 for (const page of [anonPage, strangerPage]) {
2374 expect(page).not.toContain("$42.50");
2375 expect(page).not.toContain("7 turns");
2376 expect(page).not.toContain("keeper");
2377 expect(page).toContain('data-who="the crew"');
2378 }
2379 const ownerPage = await (
2380 await get(`/alice/beacon/runs/${run.id}`, ownerCookie)
2381 ).text();
2382 expect(ownerPage).toContain("$42.50");
2383 expect(ownerPage).toContain("7 turns");
2384 expect(ownerPage).toContain('data-who="keeper"');
2385
2386 const asStranger = (await (
2387 await get(`/api/v1/repos/alice/beacon/runs/${run.id}`, strangerCookie)
2388 ).json()) as { costUsd: number; turns: number };
2389 expect(asStranger.costUsd).toBe(0);
2390 expect(asStranger.turns).toBe(0);
2391 const asOwner = (await (
2392 await get(`/api/v1/repos/alice/beacon/runs/${run.id}`, ownerCookie)
2393 ).json()) as { costUsd: number; turns: number };
2394 expect(asOwner.costUsd).toBe(42.5);
2395 expect(asOwner.turns).toBe(7);
2396
2397 const listAsStranger = (await (
2398 await get(`/api/v1/repos/alice/beacon/runs`, strangerCookie)
2399 ).json()) as { items: { costUsd: number }[] };
2400 expect(listAsStranger.items.every((r) => r.costUsd === 0)).toBe(true);
2401
2402 const tailAsStranger = await (
2403 await get(
2404 `/api/v1/repos/alice/beacon/runs/${run.id}/log?follow=1`,
2405 strangerCookie,
2406 )
2407 ).text();
2408 expect(tailAsStranger).toContain("event: done");
2409 expect(tailAsStranger).not.toContain("42.5");
2410 const tailAsOwner = await (
2411 await get(
2412 `/api/v1/repos/alice/beacon/runs/${run.id}/log?follow=1`,
2413 ownerCookie,
2414 )
2415 ).text();
2416 expect(tailAsOwner).toContain("42.5");
2417 });
2418
2419 test("the studio home names the crew for the owner, but only 'the crew' for a signed-in reader of the same public app", async () => {
2420 const asStranger = await (await get("/", strangerCookie)).text();
2421 expect(asStranger).not.toContain("keeper");
2422 expect(asStranger).toContain("beacon");
2423 const asOwner = await (await get("/", ownerCookie)).text();
2424 expect(asOwner).toContain("keeper");
2425 expect(asOwner).toContain("beacon");
2426 });
2427 });
2428
21962429 /**
21972430 * The one function every page words a deployment's speed with. The pages
21982431 * above prove it reaches the studio; these prove what it says at the edges,

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs