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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/65-a-stranger-sees-the-app-not-the-owner-s → main 4 commits fork
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 | ||
| 53 | 53 | the head card and previews list in place, so a change serving in its |
| 54 | 54 | preview shows up without a reload. |
| 55 | 55 | |
| 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 | ||
| 56 | 73 | **The box** is the front door. A sentence becomes an issue — the first line |
| 57 | 74 | its title, the rest its body — and the routing decision hands it to a |
| 58 | 75 | 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 | ||
| 225 | 242 | is live — it swaps itself when a matching event lands, and the dot in the |
| 226 | 243 | corner says whether the stream is connected. `?apps=a,b` narrows it to some |
| 227 | 244 | 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. | |
| 229 | 250 | |
| 230 | 251 | ## What the studio never does |
| 231 | 252 |
src/core/repos.ts +16
| @@ -611,6 +611,22 @@ export function canWrite( | ||
| 611 | 611 | return accessAllows(orgAccessFor(db, repo, user), "write"); |
| 612 | 612 | } |
| 613 | 613 | |
| 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 | ||
| 614 | 630 | /** |
| 615 | 631 | * Which of these repositories the user may write — `canWrite` for many rows |
| 616 | 632 | * 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"; | ||
| 109 | 109 | import { |
| 110 | 110 | canAdmin, |
| 111 | 111 | canRead, |
| 112 | canSeeOwnerFacts, | |
| 112 | 113 | countsForRepos, |
| 113 | 114 | getRepo, |
| 114 | 115 | listReposByOwner, |
| @@ -2668,6 +2669,14 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2668 | 2669 | return json({ url: link.value }); |
| 2669 | 2670 | }); |
| 2670 | 2671 | |
| 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 | ||
| 2671 | 2680 | router.add("GET", "/api/v1/repos/:owner/:name/runs", (ctx) => { |
| 2672 | 2681 | const repo = loadRepo(ctx); |
| 2673 | 2682 | if (!repo) return notFound(); |
| @@ -2682,14 +2691,15 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2682 | 2691 | limit, |
| 2683 | 2692 | }); |
| 2684 | 2693 | 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)); | |
| 2686 | 2696 | }); |
| 2687 | 2697 | |
| 2688 | 2698 | router.add("GET", "/api/v1/repos/:owner/:name/runs/:id", (ctx) => { |
| 2689 | 2699 | const repo = loadRepo(ctx); |
| 2690 | 2700 | if (!repo) return notFound(); |
| 2691 | 2701 | 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(); | |
| 2693 | 2703 | }); |
| 2694 | 2704 | |
| 2695 | 2705 | // 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 { | ||
| 2712 | 2722 | }); |
| 2713 | 2723 | } |
| 2714 | 2724 | 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 }; | |
| 2715 | 2731 | // Every line carries its number, so a browser that reconnects — a |
| 2716 | 2732 | // proxy in front of the forge cuts long responses — sends the last it |
| 2717 | 2733 | // saw and hears only what followed, never the log again from the top. |
| @@ -2729,7 +2745,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2729 | 2745 | for (const line of readRunLog(ctx.app.config.dataDir, run.id).lines) |
| 2730 | 2746 | send(line); |
| 2731 | 2747 | const finish = (final: Run): void => { |
| 2732 | emit("done", final, final.id); | |
| 2748 | emit("done", forViewer(final), final.id); | |
| 2733 | 2749 | close(); |
| 2734 | 2750 | }; |
| 2735 | 2751 | const unsubscribe = ctx.app.bus.subscribe((event) => { |
src/web/pages/board.ts +27 −6
| @@ -16,7 +16,11 @@ import { | ||
| 16 | 16 | import { getIssue, getIssuesByIds, type Issue } from "../../core/issues.ts"; |
| 17 | 17 | import { looksForApps } from "../../core/looks.ts"; |
| 18 | 18 | 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"; | |
| 20 | 24 | import { listActiveRuns, listRuns, type Run } from "../../core/runs.ts"; |
| 21 | 25 | import { getUsersByIds } from "../../core/users.ts"; |
| 22 | 26 | import { autonomyStatus, startOfTodayUtc } from "../../crew/autonomy.ts"; |
| @@ -76,6 +80,10 @@ interface Scope { | ||
| 76 | 80 | readonly ids: readonly string[]; |
| 77 | 81 | readonly byId: ReadonlyMap<string, Repo>; |
| 78 | 82 | 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>; | |
| 79 | 87 | } |
| 80 | 88 | |
| 81 | 89 | // ---- the page ---- |
| @@ -108,6 +116,9 @@ export function boardPage(ctx: Ctx): Response { | ||
| 108 | 116 | ids: repos.map((r) => r.id), |
| 109 | 117 | byId: new Map(repos.map((r) => [r.id, r])), |
| 110 | 118 | records, |
| 119 | ownerFacts: new Set( | |
| 120 | repos.filter((r) => canSeeOwnerFacts(db, r, user)).map((r) => r.id), | |
| 121 | ), | |
| 111 | 122 | }; |
| 112 | 123 | |
| 113 | 124 | const body = html`<div class="board"> |
| @@ -200,10 +211,13 @@ function working(ctx: Ctx, scope: Scope): Html { | ||
| 200 | 211 | ); |
| 201 | 212 | const rows = runs.map((run) => { |
| 202 | 213 | 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 = | |
| 204 | 217 | run.actorId === null |
| 205 | 218 | ? "crew" |
| 206 | 219 | : (actors.get(run.actorId)?.username ?? "crew"); |
| 220 | const who = scope.ownerFacts.has(repo.id) ? actorName : "the crew"; | |
| 207 | 221 | const issue = run.issueId === null ? undefined : about.get(run.issueId); |
| 208 | 222 | const word = stateWord(run); |
| 209 | 223 | return html`<li> |
| @@ -226,7 +240,7 @@ function working(ctx: Ctx, scope: Scope): Html { | ||
| 226 | 240 | : reasonWords(run.reason)}</span |
| 227 | 241 | > |
| 228 | 242 | ${when( |
| 229 | run.costUsd > 0, | |
| 243 | run.costUsd > 0 && scope.ownerFacts.has(repo.id), | |
| 230 | 244 | () => html`<span class="num">${money(run.costUsd)}</span>`, |
| 231 | 245 | )} |
| 232 | 246 | </span> |
| @@ -274,6 +288,7 @@ function landed(ctx: Ctx, scope: Scope): Html { | ||
| 274 | 288 | const rows = pulls.map((pull: Pull) => { |
| 275 | 289 | const repo = scope.byId.get(pull.repoId)!; |
| 276 | 290 | const picture = pictureOf(repo); |
| 291 | const author = scope.ownerFacts.has(repo.id) ? pull.authorName : "the crew"; | |
| 277 | 292 | return html`<li> |
| 278 | 293 | <a |
| 279 | 294 | class="board-card board-landed" |
| @@ -292,7 +307,7 @@ function landed(ctx: Ctx, scope: Scope): Html { | ||
| 292 | 307 | > |
| 293 | 308 | <span class="board-landed-title">${pull.title}</span> |
| 294 | 309 | <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> | |
| 296 | 311 | </a> |
| 297 | 312 | </li>`; |
| 298 | 313 | }); |
| @@ -310,7 +325,12 @@ function landed(ctx: Ctx, scope: Scope): Html { | ||
| 310 | 325 | |
| 311 | 326 | function decided(ctx: Ctx, scope: Scope): Html { |
| 312 | 327 | 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); | |
| 314 | 334 | const about = getIssuesByIds( |
| 315 | 335 | db, |
| 316 | 336 | decisions.map((d) => d.issueId).filter((id): id is string => id !== null), |
| @@ -368,8 +388,9 @@ function decided(ctx: Ctx, scope: Scope): Html { | ||
| 368 | 388 | function today(ctx: Ctx, scope: Scope): Html { |
| 369 | 389 | const db = ctx.app.db; |
| 370 | 390 | const since = startOfTodayUtc(new Date()).toISOString(); |
| 391 | // Today's spend against the ceiling is the owner's own dashboard. | |
| 371 | 392 | const rows = scope.repos |
| 372 | .filter((r) => r.autonomy !== "off") | |
| 393 | .filter((r) => r.autonomy !== "off" && scope.ownerFacts.has(r.id)) | |
| 373 | 394 | .map((repo) => { |
| 374 | 395 | const s = autonomyStatus(ctx.app, repo); |
| 375 | 396 | const runs = listRuns(db, repo.id, { kind: "agent", limit: 200 }).filter( |
src/web/pages/studio.ts +147 −90
| @@ -50,7 +50,7 @@ import { | ||
| 50 | 50 | pullForBranch, |
| 51 | 51 | type Pull, |
| 52 | 52 | } from "../../core/pulls.ts"; |
| 53 | import { canWrite, listReposVisibleTo, type Repo } from "../../core/repos.ts"; | |
| 53 | import { canSeeOwnerFacts, listReposVisibleTo, type Repo } from "../../core/repos.ts"; | |
| 54 | 54 | import { listReviews } from "../../core/reviews.ts"; |
| 55 | 55 | import { |
| 56 | 56 | activeRunFor, |
| @@ -300,12 +300,16 @@ function lastHumanWord(ctx: Ctx, issue: Issue, agentName: string): string { | ||
| 300 | 300 | * stopped (red past the crew's own limit since a person last spoke), or |
| 301 | 301 | * green and waiting for a look. Plus a repository paused at its ceiling. |
| 302 | 302 | */ |
| 303 | export function decisionsFor(ctx: Ctx, repos: readonly Repo[]): Decision[] { | |
| 303 | export function decisionsFor(ctx: Ctx, allRepos: readonly Repo[]): Decision[] { | |
| 304 | 304 | const db = ctx.app.db; |
| 305 | 305 | const out: Decision[] = []; |
| 306 | const byId = new Map(repos.map((r) => [r.id, r])); | |
| 307 | 306 | const user = ctx.auth.user; |
| 308 | 307 | 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])); | |
| 309 | 313 | const pulls = listOpenAgentPullsVisibleTo(db, user, 50).filter((p) => |
| 310 | 314 | byId.has(p.repoId), |
| 311 | 315 | ); |
| @@ -578,7 +582,7 @@ function decisionWords(stats: readonly DecisionStat[]): Html { | ||
| 578 | 582 | function stopForm(ctx: Ctx, repo: Repo, run: Run): Html | null { |
| 579 | 583 | if (run.state !== "running" && run.state !== "queued") return null; |
| 580 | 584 | 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; | |
| 582 | 586 | return html`<form |
| 583 | 587 | method="post" |
| 584 | 588 | action="${repoBase(repo)}/runs/${run.id}/cancel" |
| @@ -856,7 +860,7 @@ function appPanels( | ||
| 856 | 860 | const previews = previewsPanel(base, runtime, record, openPulls, envs); |
| 857 | 861 | const production = envs.find((e) => e.env === "production"); |
| 858 | 862 | const url = runtime?.urlFor(record, "production"); |
| 859 | const writer = canWrite(db, repo, ctx.auth.user); | |
| 863 | const writer = canSeeOwnerFacts(db, repo, ctx.auth.user); | |
| 860 | 864 | // The state in one word, and under it the same facts the App page shows — |
| 861 | 865 | // why it is not up, and the deploy whose log says the rest. |
| 862 | 866 | const live = envPhrase(production); |
| @@ -924,6 +928,10 @@ export async function studioApp( | ||
| 924 | 928 | ): Promise<Response> { |
| 925 | 929 | const db = ctx.app.db; |
| 926 | 930 | 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); | |
| 927 | 935 | const wordCtx: WordCtx = { |
| 928 | 936 | db, |
| 929 | 937 | dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name), |
| @@ -946,23 +954,32 @@ export async function studioApp( | ||
| 946 | 954 | const decisions = withLikely(ctx, decisionsFor(ctx, [repo])); |
| 947 | 955 | const openPulls = listPulls(db, repo.id, { state: "open", limit: 10 }); |
| 948 | 956 | 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); | |
| 953 | 957 | const shipped = listPulls(db, repo.id, { |
| 954 | 958 | state: "closed", |
| 955 | 959 | limit: 100, |
| 956 | 960 | }).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) : []; | |
| 958 | 969 | const autonomy = |
| 959 | repo.autonomy === "off" ? undefined : autonomyStatus(ctx.app, repo); | |
| 970 | !ownerFacts || repo.autonomy === "off" | |
| 971 | ? undefined | |
| 972 | : autonomyStatus(ctx.app, repo); | |
| 960 | 973 | |
| 961 | 974 | // ---- the app itself ---- |
| 962 | 975 | const { head, previews } = appPanels(ctx, repo, record, openPulls); |
| 963 | 976 | |
| 964 | 977 | // ---- 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 | |
| 966 | 983 | class="card comment-form studio-tell" |
| 967 | 984 | method="post" |
| 968 | 985 | action="${base}/tell" |
| @@ -1018,16 +1035,21 @@ export async function studioApp( | ||
| 1018 | 1035 | (r, idx, all) => all.findIndex((x) => x.issueId === r.issueId) === idx, |
| 1019 | 1036 | ) |
| 1020 | 1037 | .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"); | |
| 1021 | 1042 | const nowRows: Html[] = [ |
| 1022 | 1043 | ...(await Promise.all( |
| 1023 | 1044 | openPulls.map(async (p) => { |
| 1024 | 1045 | const { word, note } = await pullWord(wordCtx, p); |
| 1046 | const who = nameFor(p.authorName); | |
| 1025 | 1047 | return row({ |
| 1026 | 1048 | href: `${base}/pulls/${p.number}`, |
| 1027 | 1049 | nav: true, |
| 1028 | lead: avatarOf(p.authorName), | |
| 1050 | lead: avatarOf(who), | |
| 1029 | 1051 | title: p.title, |
| 1030 | meta: html`${p.authorName} · ${note} · ${time(p.createdAt)}`, | |
| 1052 | meta: html`${who} · ${note} · ${time(p.createdAt)}`, | |
| 1031 | 1053 | trail: wordBadge(word), |
| 1032 | 1054 | }); |
| 1033 | 1055 | }), |
| @@ -1035,10 +1057,9 @@ export async function studioApp( | ||
| 1035 | 1057 | ...(await Promise.all( |
| 1036 | 1058 | rowsNow.map(async (r) => { |
| 1037 | 1059 | 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 | ); | |
| 1042 | 1063 | const about = r.issueId === null ? undefined : issues.get(r.issueId); |
| 1043 | 1064 | const stop = stopForm(ctx, repo, r); |
| 1044 | 1065 | return row({ |
| @@ -1059,17 +1080,18 @@ export async function studioApp( | ||
| 1059 | 1080 | }); |
| 1060 | 1081 | }), |
| 1061 | 1082 | )), |
| 1062 | ...waiting.map((i) => | |
| 1063 | row({ | |
| 1083 | ...waiting.map((i) => { | |
| 1084 | const who = nameFor(i.assigneeName ?? "crew"); | |
| 1085 | return row({ | |
| 1064 | 1086 | href: `${base}/issues/${i.number}`, |
| 1065 | 1087 | nav: true, |
| 1066 | lead: avatarOf(i.assigneeName ?? "crew"), | |
| 1088 | lead: avatarOf(who), | |
| 1067 | 1089 | 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 | |
| 1069 | 1091 | yet`, |
| 1070 | 1092 | trail: wordBadge("waiting"), |
| 1071 | }), | |
| 1072 | ), | |
| 1093 | }); | |
| 1094 | }), | |
| 1073 | 1095 | ]; |
| 1074 | 1096 | const now = |
| 1075 | 1097 | nowRows.length === 0 |
| @@ -1094,8 +1116,12 @@ export async function studioApp( | ||
| 1094 | 1116 | ); |
| 1095 | 1117 | |
| 1096 | 1118 | // ---- 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 | |
| 1099 | 1125 | ? card( |
| 1100 | 1126 | html`<p class="muted small"> |
| 1101 | 1127 | Nobody yet. <a href="${base}/settings/crew">Add someone.</a> |
| @@ -1125,65 +1151,74 @@ export async function studioApp( | ||
| 1125 | 1151 | }, |
| 1126 | 1152 | ); |
| 1127 | 1153 | 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>`, | |
| 1148 | 1179 | { |
| 1149 | 1180 | head: html`${icon("activity")} |
| 1150 | 1181 | <h2>This week</h2>`, |
| 1151 | 1182 | extra: "about", |
| 1152 | 1183 | }, |
| 1153 | 1184 | ); |
| 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 | ); | |
| 1187 | 1222 | const more = html`<p class="muted small"> |
| 1188 | 1223 | <a href="${base}/map">The map</a> · |
| 1189 | 1224 | <a href="${base}/issues">Everything told</a> · |
| @@ -1257,12 +1292,18 @@ export async function studioRun( | ||
| 1257 | 1292 | ): Promise<Response> { |
| 1258 | 1293 | const db = ctx.app.db; |
| 1259 | 1294 | const base = repoBase(repo); |
| 1295 | const writer = canSeeOwnerFacts(db, repo, ctx.auth.user); | |
| 1260 | 1296 | const actor = |
| 1261 | 1297 | run.actorId === null ? undefined : getUserById(db, run.actorId); |
| 1262 | 1298 | const issue = |
| 1263 | 1299 | 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); | |
| 1266 | 1307 | const wordCtx: WordCtx = { |
| 1267 | 1308 | db, |
| 1268 | 1309 | dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name), |
| @@ -1271,7 +1312,6 @@ export async function studioRun( | ||
| 1271 | 1312 | }; |
| 1272 | 1313 | const { word } = await runWord(wordCtx, run); |
| 1273 | 1314 | const unfinished = !isTerminal(run.state); |
| 1274 | const writer = canWrite(db, repo, ctx.auth.user); | |
| 1275 | 1315 | const duration = durationText(run); |
| 1276 | 1316 | const stream = `/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${run.id}/log?follow=1`; |
| 1277 | 1317 | const shown = lines.slice(-RUN_TRANSCRIPT_CAP); |
| @@ -1300,12 +1340,22 @@ export async function studioRun( | ||
| 1300 | 1340 | >#${i.number} ${i.title}</a |
| 1301 | 1341 | >`, |
| 1302 | 1342 | )} |
| 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 | )} | |
| 1309 | 1359 | ${when( |
| 1310 | 1360 | duration, |
| 1311 | 1361 | (d) => |
| @@ -2197,6 +2247,12 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> { | ||
| 2197 | 2247 | const db = ctx.app.db; |
| 2198 | 2248 | const { repos: appRepos, records } = appsOf(ctx, user); |
| 2199 | 2249 | 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 | ); | |
| 2200 | 2256 | // One read for the newest run of every app, one for who ran them: the |
| 2201 | 2257 | // home must not get slower as the apps grow. |
| 2202 | 2258 | const latestRuns = latestAgentRunsForRepos( |
| @@ -2233,7 +2289,7 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> { | ||
| 2233 | 2289 | latest, |
| 2234 | 2290 | ); |
| 2235 | 2291 | const who = |
| 2236 | latest?.actorId == null | |
| 2292 | latest?.actorId == null || !ownerFacts.has(repo.id) | |
| 2237 | 2293 | ? undefined |
| 2238 | 2294 | : actors.get(latest.actorId)?.username; |
| 2239 | 2295 | const asks = asksByRepo.get(repo.id) ?? 0; |
| @@ -2318,10 +2374,11 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> { | ||
| 2318 | 2374 | : listCard( |
| 2319 | 2375 | running.map((r) => { |
| 2320 | 2376 | const repo = byId.get(r.repoId)!; |
| 2321 | const who = | |
| 2377 | const actorName = | |
| 2322 | 2378 | r.actorId === null |
| 2323 | 2379 | ? "crew" |
| 2324 | 2380 | : (runActors.get(r.actorId)?.username ?? "crew"); |
| 2381 | const who = ownerFacts.has(repo.id) ? actorName : "the crew"; | |
| 2325 | 2382 | const stop = stopForm(ctx, repo, r); |
| 2326 | 2383 | return row({ |
| 2327 | 2384 | ...(stop |
test/studio.test.ts +233
| @@ -26,6 +26,7 @@ import { | ||
| 26 | 26 | updateRepoSettings, |
| 27 | 27 | } from "../src/actions/repos.ts"; |
| 28 | 28 | import { setCrewRole } from "../src/core/agents.ts"; |
| 29 | import { recordDecision } from "../src/core/decisions.ts"; | |
| 29 | 30 | import { getIssue, listIssues, type Issue } from "../src/core/issues.ts"; |
| 30 | 31 | import { recordLook } from "../src/core/looks.ts"; |
| 31 | 32 | import { getPull, type Pull } from "../src/core/pulls.ts"; |
| @@ -37,6 +38,7 @@ import { | ||
| 37 | 38 | getRun, |
| 38 | 39 | markServing, |
| 39 | 40 | noteRunProgress, |
| 41 | type Run, | |
| 40 | 42 | } from "../src/core/runs.ts"; |
| 41 | 43 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 42 | 44 | import type { Decider } from "../src/crew/decide.ts"; |
| @@ -2193,6 +2195,237 @@ describe("working: a run's own page in the studio", () => { | ||
| 2193 | 2195 | }); |
| 2194 | 2196 | }); |
| 2195 | 2197 | |
| 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 | ||
| 2196 | 2429 | /** |
| 2197 | 2430 | * The one function every page words a deployment's speed with. The pages |
| 2198 | 2431 | * above prove it reaches the studio; these prove what it says at the edges, |
new above
offline