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

"Your apps" is everybody's apps: a stranger's front door is somebody else's app #223

Merged opened this

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

Merged

scout/forge:scout/209-your-apps-is-everybody-s-apps-a-stranger → main 2 commits fork

  • Merged. scout/209-your-apps-is-everybody-s-apps-a-stranger was merged into main as 921bba3.

Review

Sign in to review this change.
5 files changed +179 −42
src/core/repos.ts +34
@@ -488,6 +488,40 @@ export function listReposVisibleTo(
488488 return withCounts(db, rows);
489489 }
490490
491 /**
492 * Repos this viewer may write: their own, or an org's through a team with
493 * write or admin access — `REPO_WRITABLE`, the SQL twin of `canWrite`, for a
494 * list of rows rather than one permission check per row. An anonymous viewer
495 * writes nothing.
496 */
497 export function listReposWritableTo(
498 db: Database,
499 viewer: User | undefined,
500 options: ListReposOptions = {},
501 ): Repo[] {
502 if (!viewer) return [];
503 const params: (string | number)[] = [viewer.id];
504 let where = REPO_WRITABLE;
505 if (options.query !== undefined && options.query.trim() !== "") {
506 params.push(`%${likeEscape(options.query.trim())}%`);
507 const p = params.length;
508 where += ` AND (repo.name LIKE ?${p} ESCAPE '\\' COLLATE NOCASE
509 OR user.username LIKE ?${p} ESCAPE '\\' COLLATE NOCASE)`;
510 }
511 if (options.cursor !== undefined) {
512 params.push(options.cursor);
513 where += ` AND repo.id < ?${params.length}`;
514 }
515 params.push(pageLimit(options.limit));
516 const rows = db
517 .query<RepoRow, (string | number)[]>(
518 `${SELECT} WHERE ${where} ORDER BY repo.id DESC LIMIT ?${params.length}`,
519 )
520 .all(...params)
521 .map(toRepo);
522 return withCounts(db, rows);
523 }
524
491525 /** Repos owned by one user or org, visible to the viewer. */
492526 export function listReposByOwner(
493527 db: Database,
src/web/studio/asks.ts +28 −5
@@ -18,7 +18,12 @@ import {
1818 } from "../../core/issues.ts";
1919 import { looksForApps } from "../../core/looks.ts";
2020 import { joinMany, type Pull } from "../../core/pulls.ts";
21 import { canSeeOwnerFacts, listReposVisibleTo, type Repo } from "../../core/repos.ts";
21 import {
22 canSeeOwnerFacts,
23 listReposVisibleTo,
24 listReposWritableTo,
25 type Repo,
26 } from "../../core/repos.ts";
2227 import { listReviews } from "../../core/reviews.ts";
2328 import {
2429 activeRunFor,
@@ -36,10 +41,12 @@ import { lookThumb, lookUrl, thumbLook } from "../pages/looks.ts";
3641 import { leanNote, leanVariant } from "./leans.ts";
3742 import { changeHref } from "./nouns.ts";
3843
39 /** The apps a person can see, as repositories, with their records — the
40 * same visibility a signed-out reader gets from `listReposVisibleTo` too. */
41 export function appsOf(ctx: Ctx, user: User | undefined) {
42 const repos = listReposVisibleTo(ctx.app.db, user, { limit: 200 });
44 /** Repos narrowed to those with an app record, with the records — the one
45 * join `appsOf` and `writableAppsOf` share. */
46 function appsFiltered(
47 ctx: Ctx,
48 repos: readonly Repo[],
49 ): { repos: Repo[]; records: ReturnType<typeof appsForRepos> } {
4350 const records = appsForRepos(
4451 ctx.app.db,
4552 repos.map((r) => r.id),
@@ -47,6 +54,22 @@ export function appsOf(ctx: Ctx, user: User | undefined) {
4754 return { repos: repos.filter((r) => records.has(r.id)), records };
4855 }
4956
57 /** The apps a person can see, as repositories, with their records — the
58 * same visibility a signed-out reader gets from `listReposVisibleTo` too.
59 * Everyone's public apps sit in this list beside the viewer's own: right
60 * for search, the palette and the asks count, wrong for a shelf headed
61 * "your apps" — that reading is `writableAppsOf`. */
62 export function appsOf(ctx: Ctx, user: User | undefined) {
63 return appsFiltered(ctx, listReposVisibleTo(ctx.app.db, user, { limit: 200 }));
64 }
65
66 /** The apps a person can write — their own, or their organisations' — as
67 * repositories, with their records. This is "your apps": the studio home's
68 * shelf and its cost line, never widened by what a stranger merely made
69 * public. */
70 export function writableAppsOf(ctx: Ctx, user: User | undefined) {
71 return appsFiltered(ctx, listReposWritableTo(ctx.app.db, user, { limit: 200 }));
72 }
5073
5174 export type DecisionKind = "stopped" | "look" | "paused";
5275
src/web/studio/home.ts +21 −35
@@ -10,7 +10,6 @@
1010 import { appsRuntime } from "../../apps/runtime.ts";
1111 import { crewForRepos } from "../../core/agents.ts";
1212 import { openPullsForRepos, type Pull } from "../../core/pulls.ts";
13 import { canSeeOwnerFacts, writableRepoIds, type Repo } from "../../core/repos.ts";
1413 import { listActiveRuns } from "../../core/runs.ts";
1514 import { getUsersByIds, type User } from "../../core/users.ts";
1615 import { isPausedAtCeiling, spentTodayForRepos } from "../../crew/autonomy.ts";
@@ -23,7 +22,7 @@ import { page } from "../layout.ts";
2322 import { liveClientJs } from "../live.ts";
2423 import { btn, card, empty, icon, listCard, row, time } from "../ui.ts";
2524 import { onYouCard } from "../pages/site.ts";
26 import { appsOf, asksPanel, decisionsFor } from "./asks.ts";
25 import { asksPanel, decisionsFor, writableAppsOf } from "./asks.ts";
2726 import { withLikely } from "./leans.ts";
2827 import { changeHref } from "./nouns.ts";
2928 import { stopForm } from "./run.ts";
@@ -38,44 +37,40 @@ import { pullWord, wordBadge, type WordCtx } from "./words.ts";
3837 */
3938 export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
4039 const db = ctx.app.db;
41 const { repos: appRepos, records } = appsOf(ctx, user);
40 // The front door leads with what the viewer can write — their own apps
41 // and their organisations' — never widened by a stranger's public app
42 // (`writableAppsOf`, not the visibility every reader gets from `appsOf`).
43 const { repos: appRepos, records } = writableAppsOf(ctx, user);
4244 const runtime = appsRuntime(ctx.app);
4345 // Read once, shown twice: the panel below is these same asks, and the
4446 // shelf counts them into each row's pill.
4547 const decisions = withLikely(ctx, decisionsFor(ctx, appRepos));
4648 const appRows = await shelfRows(ctx, appRepos, { decisions });
4749
48 // What the crew cost today, across the apps the viewer can write, and how
49 // many of those are paused at their ceiling right now — the owner's first
50 // question at the front door, answered in one bounded read each
51 // (`writableRepoIds`, `spentTodayForRepos`, `crewForRepos`), never a
52 // query or an `autonomyStatus` call per app. A reader who cannot write
53 // any of these apps sees no money at all, the same floor an app's own
54 // page holds (`canSeeOwnerFacts`).
55 const writable = writableRepoIds(
56 db,
57 user,
58 appRepos.map((r) => r.id),
59 );
60 const costRepos = appRepos.filter((r) => writable.has(r.id));
61 const costLine = when(costRepos.length > 0, () => {
50 // What the crew cost today across every app on the shelf, and how many
51 // are paused at their ceiling right now — the owner's first question at
52 // the front door, in one bounded read each (`spentTodayForRepos`,
53 // `crewForRepos`), never a query or an `autonomyStatus` call per app.
54 // Every app here is already one the viewer can write, so no further
55 // filter is needed to keep the money to the owner's own.
56 const costLine = when(appRepos.length > 0, () => {
6257 const spentToday = spentTodayForRepos(
6358 db,
64 costRepos.map((r) => r.id),
59 appRepos.map((r) => r.id),
6560 );
6661 const crew = crewForRepos(
6762 db,
68 costRepos.map((r) => r.id),
63 appRepos.map((r) => r.id),
6964 );
70 const spentUsd = costRepos.reduce(
65 const spentUsd = appRepos.reduce(
7166 (sum, r) => sum + (spentToday.get(r.id) ?? 0),
7267 0,
7368 );
74 const paused = costRepos.filter((r) =>
69 const paused = appRepos.filter((r) =>
7570 isPausedAtCeiling(r, crew, spentToday),
7671 ).length;
77 const noun = `app${costRepos.length === 1 ? "" : "s"}`;
78 return html`<p class="small muted studio-cost">$${spentUsd.toFixed(2)} spent today across ${costRepos.length} ${noun} you can write · ${paused} paused at <a href="/map">their ceiling</a></p>`;
72 const noun = `app${appRepos.length === 1 ? "" : "s"}`;
73 return html`<p class="small muted studio-cost">$${spentUsd.toFixed(2)} spent today across ${appRepos.length} ${noun} you can write · ${paused} paused at <a href="/map">their ceiling</a></p>`;
7974 });
8075
8176 const apps =
@@ -106,16 +101,8 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
106101 running.map((r) => r.actorId).filter((id): id is string => id !== null),
107102 );
108103 const byId = new Map(appRepos.map((r) => [r.id, r]));
109 // Who is working is the owner's fact, not a stranger's to read — the same
110 // predicate the shelf row uses, asked only of the apps with a run in
111 // flight.
112 const ownerFacts = new Set(
113 [...new Set(running.map((r) => r.repoId))]
114 .map((id) => byId.get(id))
115 .filter((r): r is Repo => r !== undefined)
116 .filter((r) => canSeeOwnerFacts(db, r, user))
117 .map((r) => r.id),
118 );
104 // Who is working is shown by name here because every app on this shelf is
105 // already one the viewer can write — nothing here is a stranger's app.
119106 // The board: the same runs as a wall for a second monitor.
120107 const nowHead = html`${icon("activity")}
121108 <h2>Happening now</h2>
@@ -137,11 +124,10 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
137124 ? listCard(
138125 running.map((r) => {
139126 const repo = byId.get(r.repoId)!;
140 const actorName =
127 const who =
141128 r.actorId === null
142129 ? "crew"
143130 : (runActors.get(r.actorId)?.username ?? "crew");
144 const who = ownerFacts.has(repo.id) ? actorName : "the crew";
145131 const stop = stopForm(ctx, repo, r);
146132 return row({
147133 ...(stop
src/web/studio/shelf.ts +14 −2
@@ -145,13 +145,19 @@ export async function shelfRows(
145145 production?.failedDeploy !== undefined
146146 ? "attn"
147147 : envTone(production?.state);
148 // Telling the crew something is a writer's move — a reader who can only
149 // see this app is never invited to it, the same floor `ownerFacts` holds
150 // for who worked and what it cost.
151 const canTell = ownerFacts.has(repo.id);
148152 const happening =
149153 word === undefined
150154 ? stuck.has(repo.id)
151155 ? html`<span class="muted"
152156 >told — nobody on the crew to take it</span
153157 >`
154 : html`<span class="muted">quiet — tell the crew something</span>`
158 : canTell
159 ? html`<span class="muted">quiet — tell the crew something</span>`
160 : html`<span class="muted">quiet</span>`
155161 : html`<span class="studio-word" data-word="${word.word}"
156162 >${word.word}</span
157163 >
@@ -161,11 +167,17 @@ export async function shelfRows(
161167 : useOpenPull
162168 ? html`· ${time(openPull.updatedAt)}`
163169 : html`· ${time(latest!.finishedAt ?? latest!.createdAt)}`}`;
170 // Two apps can share a bare name across owners — a row for one that is
171 // not the viewer's own names its owner, everywhere a row can appear.
172 const mine = user !== undefined && repo.ownerId === user.id;
173 const title = mine
174 ? repo.name
175 : html`<span class="muted">${repo.ownerName}/</span>${repo.name}`;
164176 return row({
165177 href: repoBase(repo),
166178 nav: true,
167179 lead: dot(tone),
168 title: repo.name,
180 title,
169181 titleAttr: repo.description || repo.name,
170182 meta: html`${repo.description
171183 ? html`${repo.description} · `
test/studio.test.ts +82
@@ -1519,6 +1519,88 @@ describe("the studio", () => {
15191519 expect(dev).toContain("Your code, your machine");
15201520 });
15211521
1522 test("the front door leads with apps the viewer can write, never a stranger's public one — but /apps still lists it, owner named", async () => {
1523 // A public app of alice's own, distinct from "notes" (private) and
1524 // "beacon" (made public by an earlier test) — self-contained so this
1525 // test does not lean on another test's state.
1526 const madePublic = await createRepo(app, alice, {
1527 name: "lighthouse",
1528 visibility: "public",
1529 description: "everybody can see this one",
1530 });
1531 if (!madePublic.ok) throw new Error(String(madePublic.error));
1532 const seeded = await commitToBranch(app, alice, madePublic.value, {
1533 message: "Start",
1534 files: [
1535 {
1536 path: "package.json",
1537 content: '{"name":"lighthouse","scripts":{"start":"bun run server.ts"}}\n',
1538 },
1539 {
1540 path: "server.ts",
1541 content:
1542 "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n",
1543 },
1544 ],
1545 });
1546 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
1547 const enabled = await enableApp(app, alice, madePublic.value, {
1548 slug: "lighthouse",
1549 });
1550 if (!enabled.ok) throw new Error(String(enabled.error));
1551
1552 // Erin just signed up: no apps of her own, but the forge already has a
1553 // public one.
1554 const erin = await createUser(app.db, {
1555 username: "erin",
1556 email: "[email protected]",
1557 password: "a-good-passphrase",
1558 });
1559 if (!erin.ok) throw new Error(erin.error);
1560 const erinCookie = (
1561 (
1562 await fetch(`${base}/signin`, {
1563 method: "POST",
1564 body: new URLSearchParams({
1565 username: "erin",
1566 password: "a-good-passphrase",
1567 }),
1568 redirect: "manual",
1569 })
1570 ).headers.get("set-cookie") ?? ""
1571 ).split(";")[0]!;
1572 const erinGet = (path: string) =>
1573 fetch(`${base}${path}`, {
1574 headers: { cookie: `${erinCookie}; forge_mode=studio` },
1575 redirect: "manual",
1576 });
1577
1578 // Erin's front door: the empty state and New app, never alice's public
1579 // app — #69's case, finally reachable.
1580 const erinHome = await (await erinGet("/")).text();
1581 expect(erinHome).toContain("No apps yet");
1582 expect(erinHome).toContain('href="/apps/new"');
1583 expect(erinHome).not.toContain('href="/alice/lighthouse"');
1584 expect(erinHome).not.toContain("tell the crew something");
1585
1586 // Alice's own front door still leads with it.
1587 const aliceHome = await (await get("/")).text();
1588 expect(aliceHome).toContain('href="/alice/lighthouse"');
1589
1590 // /apps hides nothing: both see it, and it names its owner since neither
1591 // request is alice's own view of her own row — erin's outright, and
1592 // even alice's own listing spells it the same way for every row beside
1593 // her own.
1594 const erinApps = await (await erinGet("/apps")).text();
1595 expect(erinApps).toContain('href="/alice/lighthouse"');
1596 expect(erinApps).toContain(
1597 '<span class="muted">alice/</span>lighthouse',
1598 );
1599
1600 const aliceApps = await (await get("/apps")).text();
1601 expect(aliceApps).toContain('href="/alice/lighthouse"');
1602 });
1603
15221604 test("a forge with no public apps falls back to the dev landing, signed out in studio", async () => {
15231605 const dataDir2 = await mkdtemp(join(tmpdir(), "forge-studio-empty-"));
15241606 const app2 = createApp({ dataDir: dataDir2, port: 0 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs