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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/209-your-apps-is-everybody-s-apps-a-stranger → main 2 commits fork
5 files
changed +179
−42
src/core/repos.ts +34
| @@ -488,6 +488,40 @@ export function listReposVisibleTo( | ||
| 488 | 488 | return withCounts(db, rows); |
| 489 | 489 | } |
| 490 | 490 | |
| 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 | ||
| 491 | 525 | /** Repos owned by one user or org, visible to the viewer. */ |
| 492 | 526 | export function listReposByOwner( |
| 493 | 527 | db: Database, |
src/web/studio/asks.ts +28 −5
| @@ -18,7 +18,12 @@ import { | ||
| 18 | 18 | } from "../../core/issues.ts"; |
| 19 | 19 | import { looksForApps } from "../../core/looks.ts"; |
| 20 | 20 | 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"; | |
| 22 | 27 | import { listReviews } from "../../core/reviews.ts"; |
| 23 | 28 | import { |
| 24 | 29 | activeRunFor, |
| @@ -36,10 +41,12 @@ import { lookThumb, lookUrl, thumbLook } from "../pages/looks.ts"; | ||
| 36 | 41 | import { leanNote, leanVariant } from "./leans.ts"; |
| 37 | 42 | import { changeHref } from "./nouns.ts"; |
| 38 | 43 | |
| 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> } { | |
| 43 | 50 | const records = appsForRepos( |
| 44 | 51 | ctx.app.db, |
| 45 | 52 | repos.map((r) => r.id), |
| @@ -47,6 +54,22 @@ export function appsOf(ctx: Ctx, user: User | undefined) { | ||
| 47 | 54 | return { repos: repos.filter((r) => records.has(r.id)), records }; |
| 48 | 55 | } |
| 49 | 56 | |
| 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 | } | |
| 50 | 73 | |
| 51 | 74 | export type DecisionKind = "stopped" | "look" | "paused"; |
| 52 | 75 |
src/web/studio/home.ts +21 −35
| @@ -10,7 +10,6 @@ | ||
| 10 | 10 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 11 | 11 | import { crewForRepos } from "../../core/agents.ts"; |
| 12 | 12 | import { openPullsForRepos, type Pull } from "../../core/pulls.ts"; |
| 13 | import { canSeeOwnerFacts, writableRepoIds, type Repo } from "../../core/repos.ts"; | |
| 14 | 13 | import { listActiveRuns } from "../../core/runs.ts"; |
| 15 | 14 | import { getUsersByIds, type User } from "../../core/users.ts"; |
| 16 | 15 | import { isPausedAtCeiling, spentTodayForRepos } from "../../crew/autonomy.ts"; |
| @@ -23,7 +22,7 @@ import { page } from "../layout.ts"; | ||
| 23 | 22 | import { liveClientJs } from "../live.ts"; |
| 24 | 23 | import { btn, card, empty, icon, listCard, row, time } from "../ui.ts"; |
| 25 | 24 | import { onYouCard } from "../pages/site.ts"; |
| 26 | import { appsOf, asksPanel, decisionsFor } from "./asks.ts"; | |
| 25 | import { asksPanel, decisionsFor, writableAppsOf } from "./asks.ts"; | |
| 27 | 26 | import { withLikely } from "./leans.ts"; |
| 28 | 27 | import { changeHref } from "./nouns.ts"; |
| 29 | 28 | import { stopForm } from "./run.ts"; |
| @@ -38,44 +37,40 @@ import { pullWord, wordBadge, type WordCtx } from "./words.ts"; | ||
| 38 | 37 | */ |
| 39 | 38 | export async function studioHome(ctx: Ctx, user: User): Promise<Response> { |
| 40 | 39 | 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); | |
| 42 | 44 | const runtime = appsRuntime(ctx.app); |
| 43 | 45 | // Read once, shown twice: the panel below is these same asks, and the |
| 44 | 46 | // shelf counts them into each row's pill. |
| 45 | 47 | const decisions = withLikely(ctx, decisionsFor(ctx, appRepos)); |
| 46 | 48 | const appRows = await shelfRows(ctx, appRepos, { decisions }); |
| 47 | 49 | |
| 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, () => { | |
| 62 | 57 | const spentToday = spentTodayForRepos( |
| 63 | 58 | db, |
| 64 | costRepos.map((r) => r.id), | |
| 59 | appRepos.map((r) => r.id), | |
| 65 | 60 | ); |
| 66 | 61 | const crew = crewForRepos( |
| 67 | 62 | db, |
| 68 | costRepos.map((r) => r.id), | |
| 63 | appRepos.map((r) => r.id), | |
| 69 | 64 | ); |
| 70 | const spentUsd = costRepos.reduce( | |
| 65 | const spentUsd = appRepos.reduce( | |
| 71 | 66 | (sum, r) => sum + (spentToday.get(r.id) ?? 0), |
| 72 | 67 | 0, |
| 73 | 68 | ); |
| 74 | const paused = costRepos.filter((r) => | |
| 69 | const paused = appRepos.filter((r) => | |
| 75 | 70 | isPausedAtCeiling(r, crew, spentToday), |
| 76 | 71 | ).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>`; | |
| 79 | 74 | }); |
| 80 | 75 | |
| 81 | 76 | const apps = |
| @@ -106,16 +101,8 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> { | ||
| 106 | 101 | running.map((r) => r.actorId).filter((id): id is string => id !== null), |
| 107 | 102 | ); |
| 108 | 103 | 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. | |
| 119 | 106 | // The board: the same runs as a wall for a second monitor. |
| 120 | 107 | const nowHead = html`${icon("activity")} |
| 121 | 108 | <h2>Happening now</h2> |
| @@ -137,11 +124,10 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> { | ||
| 137 | 124 | ? listCard( |
| 138 | 125 | running.map((r) => { |
| 139 | 126 | const repo = byId.get(r.repoId)!; |
| 140 | const actorName = | |
| 127 | const who = | |
| 141 | 128 | r.actorId === null |
| 142 | 129 | ? "crew" |
| 143 | 130 | : (runActors.get(r.actorId)?.username ?? "crew"); |
| 144 | const who = ownerFacts.has(repo.id) ? actorName : "the crew"; | |
| 145 | 131 | const stop = stopForm(ctx, repo, r); |
| 146 | 132 | return row({ |
| 147 | 133 | ...(stop |
src/web/studio/shelf.ts +14 −2
| @@ -145,13 +145,19 @@ export async function shelfRows( | ||
| 145 | 145 | production?.failedDeploy !== undefined |
| 146 | 146 | ? "attn" |
| 147 | 147 | : 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); | |
| 148 | 152 | const happening = |
| 149 | 153 | word === undefined |
| 150 | 154 | ? stuck.has(repo.id) |
| 151 | 155 | ? html`<span class="muted" |
| 152 | 156 | >told — nobody on the crew to take it</span |
| 153 | 157 | >` |
| 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>` | |
| 155 | 161 | : html`<span class="studio-word" data-word="${word.word}" |
| 156 | 162 | >${word.word}</span |
| 157 | 163 | > |
| @@ -161,11 +167,17 @@ export async function shelfRows( | ||
| 161 | 167 | : useOpenPull |
| 162 | 168 | ? html`· ${time(openPull.updatedAt)}` |
| 163 | 169 | : 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}`; | |
| 164 | 176 | return row({ |
| 165 | 177 | href: repoBase(repo), |
| 166 | 178 | nav: true, |
| 167 | 179 | lead: dot(tone), |
| 168 | title: repo.name, | |
| 180 | title, | |
| 169 | 181 | titleAttr: repo.description || repo.name, |
| 170 | 182 | meta: html`${repo.description |
| 171 | 183 | ? html`${repo.description} · ` |
test/studio.test.ts +82
| @@ -1519,6 +1519,88 @@ describe("the studio", () => { | ||
| 1519 | 1519 | expect(dev).toContain("Your code, your machine"); |
| 1520 | 1520 | }); |
| 1521 | 1521 | |
| 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 | ||
| 1522 | 1604 | test("a forge with no public apps falls back to the dev landing, signed out in studio", async () => { |
| 1523 | 1605 | const dataDir2 = await mkdtemp(join(tmpdir(), "forge-studio-empty-")); |
| 1524 | 1606 | const app2 = createApp({ dataDir: dataDir2, port: 0 }); |
new above
offline