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

A private app answers to its writers and crew: reading the repository is not a key to the app, its previews or its terminal #743

Merged opened this in answer to

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

Merged

steward/620-private-app-writers → main 2 commits

  • Merged. steward/620-private-app-writers was merged into main as de109ab.

Review

Sign in to review this change.
20 files changed +422 −62
docs/APPS.md +14 −9
@@ -103,11 +103,15 @@ started`; a deploy no push asked for — a redeploy someone pressed, a
103103 visibility), and it is set apart from the repository's own: the code can
104104 be private while the app is public, or the other way round. A private
105105 environment sets no cookie of its own; a person arriving by its link is
106 sent to the forge to sign in, which checks `canRead` on the repository
107 and returns them with a signed, short-lived grant for the page they
108 wanted. Someone who cannot see the repository, and anything that is not
109 a person navigating, gets the forge's own "not found". Guessing the
110 hostname yields the same.
106 sent to the forge to sign in, which checks `canEnterApp` — the
107 repository's writers and its crew, never everyone who can read it — and
108 returns them with a signed, short-lived grant for the page they wanted;
109 the proxy asks the same question again on every request, so a grant
110 stops working the moment its holder may no longer enter. Anyone else,
111 and anything that is not a person navigating, gets the forge's own "not
112 found". Guessing the hostname yields the same. Previews and the
113 terminal follow the app, and making the repository public changes
114 nobody's way in.
111115 6. **A run's credential dies with the run.** An agent run gets a token
112116 scoped `rw` (no settings, no tokens, no administration) with an expiry,
113117 and the token is deleted when the run ends however it ends. It never
@@ -409,8 +413,9 @@ exactly this).
409413
410414 Behind the headers is the grant. A person who opens an app through the
411415 forge — the app's link, or the app's own **Sign in** — is sent to
412 `/apps/open`, which vouches for them if they can see the repository and
413 sends them back with a signed grant in a cookie naming them (twelve hours,
416 `/apps/open`, which vouches for them if they may enter the app (anyone,
417 for a public app; a writer or the crew, for a private one) and sends them
418 back with a signed grant in a cookie naming them (twelve hours,
414419 HttpOnly, SameSite=Lax). The forge answers two paths on every app's host:
415420 `/__forge/signin?to=<path>` starts that round trip, and
416421 `/__forge/signout?to=<path>` drops the grant. A private app admits nobody
@@ -441,8 +446,8 @@ variables (`FORGE_APP_DATA` and the rest, plus `FORGE_APP_TTY=1` and
441446 `FORGE_APP_USER`, the forge account at the keyboard or empty for a guest).
442447 `notes--12@forge` is the preview of pull 12's. The key still decides who
443448 you are: a private app's terminal answers only to a key the forge knows
444 that can read the repository; a public app's answers to anyone, as its web
445 page does. Resizes pass through; closing the connection sends the program
449 that can write the repository, or the crew's; a public app's answers to
450 anyone, as its web page does. Resizes pass through; closing the connection sends the program
446451 `SIGHUP`.
447452
448453 The scaffold ships a small one (`src/tty.ts`, plain ANSI, no dependencies)
docs/MCP.md +1 −1
@@ -20,7 +20,7 @@ for a tunnel and a forge that sleeps.
2020 cross-site page could ride, and this door has no page. An `ro` token reads;
2121 an `rw` token acts; the tools say which they are (`readOnlyHint`).
2222 - **What you may not do on a page you may not do here.** A private app is
23 "no such app" to someone who cannot read it; stopping a run, and knocking
23 "no such app" to someone who cannot write its repository; stopping a run, and knocking
2424 on an app with `probe`, are a writer's.
2525
2626 ## Tools
scripts/gate.ts +1
@@ -119,6 +119,7 @@ export const GATE: readonly string[] = [
119119 "test/origins-security-push.test.ts",
120120 "test/origins-sync.test.ts",
121121 "test/origins.test.ts",
122 "test/private-app.test.ts",
122123 "test/check-list.test.ts",
123124 "test/hollow.test.ts",
124125 "test/oauth-discovery.test.ts",
src/actions/apps.ts +8 −3
@@ -7,7 +7,7 @@
77 */
88
99 import type { App } from "../app.ts";
10 import { appsRuntime, type EnvStatus } from "../apps/runtime.ts";
10 import { appsRuntime, envsFor, type EnvStatus } from "../apps/runtime.ts";
1111 import { grantUrl, originFor } from "../apps/proxy.ts";
1212 import { scaffoldFiles } from "../apps/scaffold.ts";
1313 import {
@@ -42,7 +42,7 @@ import { listAllAgents } from "./crew.ts";
4242 import { plan, planIssueBody, planningAvailable } from "../crew/plan.ts";
4343 import type { Issue } from "../core/issues.ts";
4444 import { getPull } from "../core/pulls.ts";
45 import { canWrite, getRepo, type Repo } from "../core/repos.ts";
45 import { canEnterApp, canWrite, getRepo, type Repo } from "../core/repos.ts";
4646 import type { Run } from "../core/runs.ts";
4747 import type { User } from "../core/users.ts";
4848 import { resolveOid } from "../git/merge.ts";
@@ -454,7 +454,7 @@ export function appView(
454454 const runtime = appsRuntime(app);
455455 return ok({
456456 app: record,
457 environments: runtime === undefined ? [] : runtime.status(record),
457 environments: envsFor(app, actor, record),
458458 serving: runtime !== undefined,
459459 });
460460 }
@@ -476,6 +476,11 @@ export function openEnvUrl(
476476 const record = getAppByRepo(app.db, repo.id);
477477 if (!record) return err("not_found");
478478 if (!isEnvName(env)) return err("no_such_env");
479 // A private app is for its writers and crew; anyone else who can read the
480 // repository gets the answer a stranger gets, and nobody is told why.
481 if (record.access === "private" && actor !== undefined)
482 if (!canEnterApp(app.db, repo, record.access, actor))
483 return err("not_found");
479484 const runtime = appsRuntime(app);
480485 if (runtime === undefined) return err("no_runtime");
481486 // A grant names a person, so the app hears who they are: a signed-in
src/apps/proxy.ts +30 −3
@@ -19,7 +19,8 @@ import { createHmac, timingSafeEqual } from "node:crypto";
1919 import type { App } from "../app.ts";
2020 import { getAppById, getAppBySlug, type AppRecord } from "../core/apps.ts";
2121 import { getVerifiedDomain } from "../core/domains.ts";
22 import { canRead, getRepoById } from "../core/repos.ts";
22 import { canEnterApp, canRead, getRepoById } from "../core/repos.ts";
23 import { getUserById } from "../core/users.ts";
2324 import { err, ok, type Result } from "../kernel/result.ts";
2425 import { loadKey } from "../kernel/secret.ts";
2526 import { previewEnv, type EnvName } from "./data.ts";
@@ -310,6 +311,22 @@ const HOP_BY_HOP = [
310311 "upgrade",
311312 ];
312313
314 /** Whether the person a grant names may enter this app now. */
315 function stillEntitled(
316 runtime: AppsRuntime,
317 target: AppTarget,
318 grant: Grant,
319 ): boolean {
320 if (grant.who === undefined) return false;
321 const repo = getRepoById(runtime.app.db, target.app.repoId);
322 const user = getUserById(runtime.app.db, grant.who.id);
323 return (
324 repo !== undefined &&
325 user !== undefined &&
326 canEnterApp(runtime.app.db, repo, target.app.access, user)
327 );
328 }
329
313330 /**
314331 * What a request addressed to an app must pass before anything is served:
315332 * a private app's grant, then a live port. One gate for both the request
@@ -322,14 +339,24 @@ function admit(
322339 target: AppTarget,
323340 ): Result<{ status: EnvStatus; port: number; grant?: Grant }, Response> {
324341 const cookie = cookieValue(request, grantCookieName(target));
325 const grant =
342 const signed =
326343 cookie === undefined
327344 ? undefined
328345 : readGrant(runtime.app.config.dataDir, cookie, target);
346 // A private app's grant is asked again on every request whether the
347 // person it names may still enter: a grant outlives a change of heart —
348 // a writer removed, the app made private — by up to twelve hours, and
349 // reading the repository was never enough (#620).
350 const grant =
351 signed !== undefined &&
352 target.app.access === "private" &&
353 !stillEntitled(runtime, target, signed)
354 ? undefined
355 : signed;
329356 if (target.app.access === "private") {
330357 if (grant === undefined) {
331358 // A person arriving by link is sent to the forge to sign in; it vouches
332 // for them if they can see the repository and sends them back with the
359 // for them if they may enter the app and sends them back with the
333360 // grant, and otherwise gives the answer a private repository gives.
334361 // Anything that is not a person navigating — a script, an API call —
335362 // gets that answer here.
src/apps/runtime.ts +24 −1
@@ -25,7 +25,8 @@ import type { ForgeEvent } from "../core/events.ts";
2525 import { announceRun } from "../actions/runs.ts";
2626 import { requeueAbandonedChecks } from "../checks/service.ts";
2727 import { getPull, listPulls, type Pull } from "../core/pulls.ts";
28 import { getRepoById, type Repo } from "../core/repos.ts";
28 import { canEnterApp, getRepoById, type Repo } from "../core/repos.ts";
29 import type { User } from "../core/users.ts";
2930 import {
3031 cancelRun,
3132 listRuns,
@@ -333,6 +334,28 @@ const runtimes = new WeakMap<App, AppsRuntime>();
333334 export const appsRuntime = (app: App): AppsRuntime | undefined =>
334335 runtimes.get(app);
335336
337 /**
338 * An app's environments as `viewer` may see them. A private app's state,
339 * errors and runs are for the people who may enter it (`canEnterApp`); to
340 * anyone else who can read the repository it shows none — a deploy error
341 * can carry paths and configuration (#620). Every surface that shows a
342 * viewer an app's environments reads them here.
343 */
344 export function envsFor(
345 app: App,
346 viewer: User | undefined,
347 record: AppRecord,
348 ): EnvStatus[] {
349 const runtime = runtimes.get(app);
350 if (runtime === undefined) return [];
351 if (record.access === "private") {
352 const repo = getRepoById(app.db, record.repoId);
353 if (repo === undefined || !canEnterApp(app.db, repo, record.access, viewer))
354 return [];
355 }
356 return runtime.status(record);
357 }
358
336359 export class AppsRuntime {
337360 private readonly envs = new Map<string, Environment>();
338361 private readonly gate: Gate;
src/apps/tty.ts +6 −2
@@ -4,7 +4,8 @@
44 * pty, in the deployed checkout, with the environment's own variables;
55 * `notes--12@forge` opens the preview of pull 12's. The login name picks
66 * the app; the key still decides who you are, and a private app answers
7 * only to someone who can read its repository, exactly as the proxy does.
7 * only to someone who can write its repository or is on its crew, exactly
8 * as the proxy does.
89 *
910 * The same bridge serves the forge's own terminal UI: press `t` on an app
1011 * and the UI suspends itself, the app's terminal runs on the same pty, and
@@ -21,7 +22,7 @@ import type { App as Forge } from "../app.ts";
2122 import { getAppBySlug, type AppRecord } from "../core/apps.ts";
2223 import { identityKey } from "./proxy.ts";
2324 import { resolveAppEnv } from "../core/app_env.ts";
24 import { canRead, canWrite, getRepoById } from "../core/repos.ts";
25 import { canEnterApp, canRead, canWrite, getRepoById } from "../core/repos.ts";
2526 import type { User } from "../core/users.ts";
2627 import { err, ok, type Result } from "../kernel/result.ts";
2728 import type { SshSession } from "../ssh/server.ts";
@@ -120,6 +121,9 @@ export async function runAppTty(
120121 return err({ refusal: "not_found", detail: "" });
121122 if (target.app.access === "private" && user === undefined)
122123 return err({ refusal: "private", detail: "" });
124 // A private app's terminal is its writers' and crew's, as the app is.
125 if (!canEnterApp(forge.db, repo, target.app.access, user))
126 return err({ refusal: "not_found", detail: "" });
123127 if (host.term === undefined) return err({ refusal: "no_pty", detail: "" });
124128 const runtime = appsRuntime(forge);
125129 // A terminal opened on a sleeping environment wakes it first.
src/core/repos.ts +16
@@ -650,6 +650,22 @@ export function canWrite(
650650 return accessAllows(orgAccessFor(db, repo, user), "write");
651651 }
652652
653 /**
654 * Who may enter an app, its previews and its terminal. A public app is open
655 * to anyone. A private one answers to the people who can write the
656 * repository and to its crew — never to everyone who can read it, so making
657 * a repository public changes nobody's way into its app (#620).
658 */
659 export function canEnterApp(
660 db: Database,
661 repo: Repo,
662 access: "public" | "private",
663 user: User | undefined,
664 ): boolean {
665 if (access === "public") return true;
666 return canWrite(db, repo, user) || onCrew(db, repo, user);
667 }
668
653669 /**
654670 * Money, the crew roster, autonomy limits and a run's own cost describe
655671 * running the app, not what it is — a stranger who can only read a public
src/http/api.ts +10 −9
@@ -213,7 +213,7 @@ import {
213213 import { dependenciesOf, repoGraph } from "../core/dependencies.ts";
214214 import { decisionStats, listDecisions } from "../core/decisions.ts";
215215 import { globalMapJson, mapJson, marksFor } from "../web/pages/map.ts";
216 import { appsRuntime, type AppsRuntime } from "../apps/runtime.ts";
216 import { appsRuntime, envsFor, type AppsRuntime } from "../apps/runtime.ts";
217217 import { isEnvName } from "../apps/data.ts";
218218 import { getAppByRepo } from "../core/apps.ts";
219219 import type { Agent } from "../core/agents.ts";
@@ -774,7 +774,7 @@ function repoQuery(ctx: Ctx) {
774774 * and the domains of its own that are verified — the ones the forge serves.
775775 */
776776 function appJson(
777 db: Database,
777 ctx: Ctx,
778778 record: AppRecord,
779779 repo: Repo,
780780 runtime: AppsRuntime | undefined,
@@ -783,11 +783,12 @@ function appJson(
783783 ...record,
784784 repo: { owner: repo.ownerName, name: repo.name },
785785 url: runtime?.urlFor(record, "production") ?? null,
786 domains: listDomains(db, record.id)
786 domains: listDomains(ctx.app.db, record.id)
787787 .filter((d) => d.verifiedAt !== null)
788788 .map((d) => d.host),
789789 serving: runtime !== undefined,
790 environments: runtime?.status(record) ?? [],
790 environments:
791 runtime === undefined ? [] : envsFor(ctx.app, ctx.auth.user, record),
791792 };
792793 }
793794
@@ -3093,7 +3094,7 @@ export function apiRoutes(router: Router<Handler>): void {
30933094 for (const record of records) {
30943095 const repo = repos.get(record.repoId);
30953096 if (!repo || !canRead(ctx.app.db, repo, ctx.auth.user)) continue;
3096 items.push(appJson(ctx.app.db, record, repo, runtime));
3097 items.push(appJson(ctx, record, repo, runtime));
30973098 }
30983099 return json({ items, next_cursor: null });
30993100 });
@@ -3118,7 +3119,7 @@ export function apiRoutes(router: Router<Handler>): void {
31183119 {
31193120 repo: repoJson(ctx, made.value.repo),
31203121 app: appJson(
3121 ctx.app.db,
3122 ctx,
31223123 made.value.app,
31233124 made.value.repo,
31243125 appsRuntime(ctx.app),
@@ -3212,7 +3213,7 @@ export function apiRoutes(router: Router<Handler>): void {
32123213 const view = appView(ctx.app, ctx.auth.user, repo);
32133214 if (!view.ok) return notFound();
32143215 return json(
3215 appJson(ctx.app.db, view.value.app, repo, appsRuntime(ctx.app)),
3216 appJson(ctx, view.value.app, repo, appsRuntime(ctx.app)),
32163217 );
32173218 });
32183219
@@ -3225,7 +3226,7 @@ export function apiRoutes(router: Router<Handler>): void {
32253226 const enabled = enableApp(ctx.app, ctx.auth.user, repo, appPatch(input));
32263227 if (!enabled.ok) return respondAccess(enabled.error, 422);
32273228 return json(
3228 appJson(ctx.app.db, enabled.value, repo, appsRuntime(ctx.app)),
3229 appJson(ctx, enabled.value, repo, appsRuntime(ctx.app)),
32293230 201,
32303231 );
32313232 });
@@ -3243,7 +3244,7 @@ export function apiRoutes(router: Router<Handler>): void {
32433244 appPatch(input),
32443245 );
32453246 if (!updated.ok) return respondAccess(updated.error, 422);
3246 return json(appJson(ctx.app.db, updated.value, repo, appsRuntime(ctx.app)));
3247 return json(appJson(ctx, updated.value, repo, appsRuntime(ctx.app)));
32473248 });
32483249
32493250 router.add("DELETE", "/api/v1/repos/:owner/:name/app", (ctx) => {
src/http/mcp.ts +4 −4
@@ -37,7 +37,7 @@ import {
3737 import { ITEM_ID } from "../core/interactions.ts";
3838 import { share } from "../feed/share.ts";
3939 import { parseTargets } from "./feed.ts";
40 import { appsRuntime } from "../apps/runtime.ts";
40 import { appsRuntime, envsFor } from "../apps/runtime.ts";
4141 import { getIssue, getIssueById } from "../core/issues.ts";
4242 import { repoGraph } from "../core/dependencies.ts";
4343 import { getAppByRepo } from "../core/apps.ts";
@@ -156,9 +156,9 @@ const TOOLS: readonly Tool[] = [
156156 return good(
157157 repos.map((repo) => {
158158 const record = records.get(repo.id)!;
159 const production = runtime
160 ?.status(record)
161 .find((e) => e.env === "production");
159 const production = envsFor(ctx.app, user, record).find(
160 (e) => e.env === "production",
161 );
162162 return {
163163 app: `${repo.ownerName}/${repo.name}`,
164164 description: repo.description,
src/tui/screens/pull.ts +4 −3
@@ -43,8 +43,7 @@ import { previewEnv } from "../../apps/data.ts";
4343 import {
4444 appsRuntime,
4545 type EnvState,
46 type EnvStatus,
47 } from "../../apps/runtime.ts";
46 type EnvStatus, envsFor } from "../../apps/runtime.ts";
4847 import { getAppByRepo } from "../../core/apps.ts";
4948 import { getIssue, type Issue } from "../../core/issues.ts";
5049 import { getPull, type Pull } from "../../core/pulls.ts";
@@ -252,7 +251,9 @@ export function pullScreen(
252251 preview =
253252 record === undefined
254253 ? undefined
255 : appsRuntime(f.app)?.envStatus(record, previewEnv(number));
254 : envsFor(f.app, f.user, record).find(
255 (e) => e.env === previewEnv(number),
256 );
256257 check =
257258 row === undefined
258259 ? NO_CHECK
src/web/pages/apps.ts +2 −2
@@ -24,7 +24,7 @@ import { listAllAgents } from "../../actions/crew.ts";
2424 import { planningAvailable } from "../../crew/plan.ts";
2525 import { appNameJs, deriveAppName } from "./apps-name.ts";
2626 import { envPull, previewEnv, type EnvName } from "../../apps/data.ts";
27 import { appsRuntime, type EnvStatus } from "../../apps/runtime.ts";
27 import { appsRuntime, type EnvStatus, envsFor } from "../../apps/runtime.ts";
2828 import { appHostSuffix } from "../../apps/host.ts";
2929 import { appsForRepos, type AppRecord } from "../../core/apps.ts";
3030 import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts";
@@ -99,7 +99,7 @@ function appsForViewer(ctx: Ctx): AppEntry[] {
9999 for (const repo of repos) {
100100 const record = records.get(repo.id);
101101 if (record === undefined) continue;
102 const envs = runtime === undefined ? [] : runtime.status(record);
102 const envs = envsFor(ctx.app, ctx.auth.user, record);
103103 out.push({
104104 record,
105105 repo,
src/web/pages/search.ts +2 −3
@@ -12,7 +12,7 @@
1212 */
1313
1414 import { search } from "../../actions/search.ts";
15 import { appsRuntime } from "../../apps/runtime.ts";
15 import { appsRuntime, envsFor } from "../../apps/runtime.ts";
1616 import type { AppRecord } from "../../core/apps.ts";
1717 import { getPull } from "../../core/pulls.ts";
1818 import { canWrite, getRepo } from "../../core/repos.ts";
@@ -77,8 +77,7 @@ const privateBadge = (yes: boolean): Html =>
7777 /** Whether an app is live. No query — `runtime.status` reads what is
7878 * already in memory. */
7979 function appState(ctx: Ctx, record: AppRecord): { tone: Tone; word: string } {
80 const runtime = appsRuntime(ctx.app);
81 const envs = runtime === undefined ? [] : runtime.status(record);
80 const envs = envsFor(ctx.app, ctx.auth.user, record);
8281 const state = envs.find((e) => e.env === "production")?.state ?? "stopped";
8382 const tone: Tone =
8483 state === "running" ? "success" : state === "failed" ? "danger" : "default";
src/web/pages/settings.ts +6 −3
@@ -255,7 +255,10 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
255255 ${selectField({
256256 label: "Visibility",
257257 name: "visibility",
258 hint: "A private repository and a repository that does not exist look identical to anyone else.",
258 hint:
259 repo.visibility === "private"
260 ? "A private repository and a repository that does not exist look identical to anyone else. Making it public shows everyone its code, issues, pulls and run logs; a private app and its previews stay open only to people who can write here."
261 : "Public: anyone can read the code, issues, pulls and run logs. A private app and its previews stay open only to people who can write here.",
259262 options: [
260263 {
261264 value: "private",
@@ -425,11 +428,11 @@ function appSettingsView(
425428 hint:
426429 values.access === "public"
427430 ? `Right now anyone with the link can open it, signed in or not. The code stays as ${repo.visibility} as the repository is: the two are set separately.`
428 : `Right now a visitor is asked to sign in and let through only if they can see this repository${repo.visibility === "private" ? " — its collaborators, since the repository is private" : " — anyone signed in, since the repository is public"}. Everyone else sees not found. Choose Public to share it with people who cannot see the code.`,
431 : `Right now a visitor is asked to sign in and let through only if they can write this repository, or are on its crew; reading the code is not enough, so it stays this way if the repository is made public. Everyone else sees not found. Choose Public to share it with anyone.`,
429432 options: [
430433 {
431434 value: "private",
432 label: "Private — people who can see this repository",
435 label: "Private — people who can write this repository",
433436 selected: values.access === "private",
434437 },
435438 {
src/web/studio/change.ts +4 −4
@@ -7,7 +7,7 @@
77 * `?mode=dev` reads the code, unchanged (`pages/pull.ts`).
88 */
99
10 import { appsRuntime } from "../../apps/runtime.ts";
10 import { appsRuntime, envsFor } from "../../apps/runtime.ts";
1111 import type { AppRecord } from "../../core/apps.ts";
1212 import { pullStatus, revertOf, toldBy, type Pull } from "../../core/pulls.ts";
1313 import { canWrite, type Repo } from "../../core/repos.ts";
@@ -102,9 +102,9 @@ export async function studioPull(
102102 const run = checkSummary(db, pull.id, pull.headOid).run;
103103 if (run) deploy = { id: run.id, commitOid: run.commitOid };
104104 } else {
105 const production = wordCtx.runtime
106 ?.status(record)
107 .find((e) => e.env === "production");
105 const production = envsFor(ctx.app, ctx.auth.user, record).find(
106 (e) => e.env === "production",
107 );
108108 if (production?.runId)
109109 deploy = { id: production.runId, commitOid: production.commitOid };
110110 }
src/web/studio/now.ts +2 −2
@@ -8,7 +8,7 @@
88 */
99
1010 import { envPull } from "../../apps/data.ts";
11 import { appsRuntime } from "../../apps/runtime.ts";
11 import { appsRuntime, envsFor } from "../../apps/runtime.ts";
1212 import { listCrewRoles } from "../../core/agents.ts";
1313 import { getAppByRepo, type AppRecord } from "../../core/apps.ts";
1414 import { getIssueById, listIssues, type Issue } from "../../core/issues.ts";
@@ -207,7 +207,7 @@ export async function nowCard(
207207
208208 // An environment the sleep sweep is sparing because it is mid-work:
209209 // that is something going on in the app, and the reason it is awake.
210 const busyRows = (wordCtx.runtime?.status(record) ?? []).flatMap((env) => {
210 const busyRows = envsFor(ctx.app, ctx.auth.user, record).flatMap((env) => {
211211 if (env.busy === undefined) return [];
212212 const number = envPull(env.env);
213213 return [
src/web/studio/previews.ts +2 −3
@@ -12,8 +12,7 @@ import { envPull, previewEnv } from "../../apps/data.ts";
1212 import {
1313 appsRuntime,
1414 type AppsRuntime,
15 type EnvStatus,
16 } from "../../apps/runtime.ts";
15 type EnvStatus, envsFor } from "../../apps/runtime.ts";
1716 import { getAppByRepo, type AppRecord } from "../../core/apps.ts";
1817 import { listPulls, type Pull } from "../../core/pulls.ts";
1918 import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
@@ -271,7 +270,7 @@ export function appPanels(
271270 const db = ctx.app.db;
272271 const base = repoBase(repo);
273272 const runtime = appsRuntime(ctx.app);
274 const envs: EnvStatus[] = runtime === undefined ? [] : runtime.status(record);
273 const envs: EnvStatus[] = envsFor(ctx.app, ctx.auth.user, record);
275274 const writer = canSeeOwnerFacts(db, repo, ctx.auth.user);
276275 const previews = previewsPanel(base, runtime, record, openPulls, envs, writer);
277276 const production = envs.find((e) => e.env === "production");
src/web/studio/shelf.ts +5 −5
@@ -24,7 +24,7 @@
2424 * told-but-stuck when nobody on the crew can take it.
2525 */
2626
27 import { appsRuntime, type EnvStatus } from "../../apps/runtime.ts";
27 import { appsRuntime, type EnvStatus, envsFor } from "../../apps/runtime.ts";
2828 import { appsForRepos } from "../../core/apps.ts";
2929 import { reposWithBuilder } from "../../core/agents.ts";
3030 import { reposWithUnclaimedTell } from "../../core/issues.ts";
@@ -84,7 +84,9 @@ export function productionFactsForRepos(
8484 for (const repo of repos) {
8585 const record = records.get(repo.id);
8686 const production = record
87 ? runtime.status(record).find((e) => e.env === "production")
87 ? envsFor(ctx.app, ctx.auth.user, record).find(
88 (e) => e.env === "production",
89 )
8890 : undefined;
8991 productions.set(repo.id, production);
9092 if (production?.runId !== undefined && production.createdAt !== undefined)
@@ -158,9 +160,7 @@ export async function shelfRows(
158160 repos.map(async (repo) => {
159161 const record = records.get(repo.id);
160162 const envs =
161 runtime === undefined || record === undefined
162 ? []
163 : runtime.status(record);
163 record === undefined ? [] : envsFor(ctx.app, ctx.auth.user, record);
164164 const production = envs.find((e) => e.env === "production");
165165 const latest = latestRuns.get(repo.id);
166166 // A run still in flight is the thing that is moving, and outranks a
test/apps-adversarial.test.ts +6 −5
@@ -1501,10 +1501,9 @@ describe("apps adversarial: teardown races, grants, the proxy, and rule 1/4", ()
15011501 // `unauthorized` is reachable only through the other combination: a
15021502 // *public* repository (so `canRead` already passes for anyone) whose
15031503 // app access is explicitly overridden to "private". There, an
1504 // anonymous visitor has nothing to be vouched with, but any signed-in
1505 // account — even a stranger's — can read the (public) code, so it gets
1506 // a working grant. That is the documented contract (`canRead` plus
1507 // "the forge can name you"), not a bypass of crew/collaborator status.
1504 // anonymous visitor has nothing to be vouched with, and a signed-in
1505 // stranger who can read the public code is still not let in: a private
1506 // app answers to its writers and crew only (#620).
15081507 const overrideMade = await newApp(app, alice, {
15091508 name: "publicwithprivateapp",
15101509 visibility: "public",
@@ -1530,7 +1529,9 @@ describe("apps adversarial: teardown races, grants, the proxy, and rule 1/4", ()
15301529 overrideMade.value.repo,
15311530 "production",
15321531 );
1533 expect(strangerOnPublicRepo.ok).toBe(true);
1532 expect(strangerOnPublicRepo.ok).toBe(false);
1533 if (!strangerOnPublicRepo.ok)
1534 expect(strangerOnPublicRepo.error).toBe("not_found");
15341535 }, 60_000);
15351536
15361537 test("the proxy: hop-by-hop headers are stripped, an upstream that dies mid-request yields a clean error, a claimed upgrade is a request", async () => {
test/private-app.test.ts added +275
@@ -0,0 +1,275 @@
1 /**
2 * A private app stays private when its repository is public (#620).
3 * Reading the code is not a key: a private app, each of its previews, its
4 * terminal and its identity hand-off answer only to people who can write
5 * the repository and to its crew. A signed-in stranger who can read the
6 * repository, and an anonymous visitor, reach none of it — and making the
7 * repository public changes nobody's admission.
8 */
9
10 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11 import { mkdtemp, rm } from "node:fs/promises";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { addToCrew, createAgent } from "../src/actions/crew.ts";
15 import { newApp, openEnvUrl } from "../src/actions/apps.ts";
16 import { updateRepoSettings } from "../src/actions/repos.ts";
17 import { createApp, type App } from "../src/app.ts";
18 import { grantUrl } from "../src/apps/proxy.ts";
19 import { AppsRuntime, envsFor } from "../src/apps/runtime.ts";
20 import { runAppTty, type TtyHost } from "../src/apps/tty.ts";
21 import type { AppRecord } from "../src/core/apps.ts";
22 import { getRepoById, type Repo } from "../src/core/repos.ts";
23 import { createUser, type User } from "../src/core/users.ts";
24 import { serve, type Servers } from "../src/http/server.ts";
25 import { quietLogs } from "./helpers/globals.ts";
26
27 quietLogs();
28
29 const DOMAIN = "apps.test";
30
31 describe("a private app on a public repository stays private", () => {
32 let dataDir: string;
33 let app: App;
34 let servers: Servers;
35 let runtime: AppsRuntime;
36 let alice: User;
37 let carol: User;
38 let bot: User;
39 let record: AppRecord;
40 let carolCookie = "";
41 let aliceCookie = "";
42
43 const repo = (): Repo => getRepoById(app.db, record.repoId)!;
44
45 const signIn = async (username: string): Promise<string> => {
46 const response = await fetch(`${servers.url}/signin`, {
47 method: "POST",
48 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
49 redirect: "manual",
50 });
51 return (response.headers.get("set-cookie") ?? "").split(";")[0]!;
52 };
53
54 beforeAll(async () => {
55 dataDir = await mkdtemp(join(tmpdir(), "forge-private-app-"));
56 app = createApp({ dataDir, port: 0, host: "forge.test" });
57 servers = await serve(app);
58 runtime = new AppsRuntime(app, {
59 domain: DOMAIN,
60 scheme: "http",
61 publicPort: servers.port,
62 forgeUrl: servers.url,
63 concurrency: 1,
64 });
65 await runtime.start();
66 const people: User[] = [];
67 for (const [username, first] of [
68 ["alice", true],
69 ["carol", false],
70 ] as const) {
71 const made = await createUser(app.db, {
72 username,
73 email: `${username}@forge.local`,
74 password: "a-good-passphrase",
75 adminIfFirst: first,
76 });
77 if (!made.ok) throw new Error(made.error);
78 people.push(made.value);
79 }
80 [alice, carol] = people as [User, User];
81 const agent = await createAgent(app, alice, { username: "bot" });
82 if (!agent.ok) throw new Error(String(agent.error));
83 bot = agent.value.user;
84 // A private repository with a private app: the case that holds today.
85 const made = await newApp(app, alice, {
86 name: "ledger",
87 description: "Books",
88 visibility: "private",
89 });
90 if (!made.ok) throw new Error(String(made.error));
91 record = made.value.app;
92 expect(record.access).toBe("private");
93 expect(addToCrew(app, alice, repo(), "bot").ok).toBe(true);
94 aliceCookie = await signIn("alice");
95 carolCookie = await signIn("carol");
96 }, 120_000);
97
98 afterAll(async () => {
99 await runtime.stop();
100 servers.stop();
101 app.close();
102 await rm(dataDir, { recursive: true, force: true });
103 });
104
105 const makePublic = async () => {
106 const changed = await updateRepoSettings(app, alice, repo(), {
107 visibility: "public",
108 });
109 expect(changed.ok).toBe(true);
110 expect(repo().visibility).toBe("public");
111 };
112
113 const ttyHost: TtyHost = {
114 cols: 80,
115 rows: 24,
116 term: undefined,
117 write() {},
118 onData() {},
119 onResize() {},
120 };
121
122 /** Ask the app host for `/` as a script would, carrying `cookie`. */
123 const knock = (host: string, cookie = "") =>
124 fetch(`${servers.url}/`, {
125 headers: {
126 host,
127 accept: "application/json",
128 ...(cookie ? { cookie } : {}),
129 },
130 redirect: "manual",
131 });
132
133 /** The grant cookie a signed grant URL sets on the app host. */
134 const cookieFrom = async (link: string): Promise<string> => {
135 const url = new URL(link);
136 const res = await fetch(`${servers.url}${url.pathname}${url.search}`, {
137 headers: { host: url.host },
138 redirect: "manual",
139 });
140 return (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
141 };
142
143 test("the repository turns public; no app admission changes", async () => {
144 await makePublic();
145 expect(getRepoById(app.db, record.repoId)!.visibility).toBe("public");
146 });
147
148 test("a signed-in reader is refused the app and every preview; writers and the crew are not", () => {
149 for (const env of ["production", "pull-1", "pull-7"]) {
150 const stranger = openEnvUrl(app, carol, repo(), env);
151 expect(stranger.ok).toBe(false);
152 if (!stranger.ok) expect(stranger.error).toBe("not_found");
153 expect(openEnvUrl(app, alice, repo(), env).ok).toBe(true);
154 expect(openEnvUrl(app, bot, repo(), env).ok).toBe(true);
155 }
156 const anonymous = openEnvUrl(app, undefined, repo(), "production");
157 expect(anonymous.ok).toBe(false);
158 });
159
160 test("the identity hand-off refuses a reader and serves a writer", async () => {
161 const host = `ledger.${DOMAIN}`;
162 const open = (cookie: string, to = host) =>
163 fetch(`${servers.url}/apps/open?host=${encodeURIComponent(to)}&to=%2F`, {
164 headers: { cookie },
165 redirect: "manual",
166 });
167 expect((await open(carolCookie)).status).toBe(404);
168 expect((await open(carolCookie, `ledger--3.${DOMAIN}`)).status).toBe(404);
169 const writer = await open(aliceCookie);
170 expect(writer.status).toBe(302);
171 expect(new URL(writer.headers.get("location")!).pathname).toBe(
172 "/__forge/grant",
173 );
174 // The API's door answers the same.
175 const api = await fetch(
176 `${servers.url}/api/v1/repos/alice/ledger/app/production/open`,
177 { headers: { cookie: carolCookie } },
178 );
179 expect(api.status).toBe(404);
180 });
181
182 test("a grant a reader already holds stops working; a writer's keeps working", async () => {
183 const target = { app: record, env: "production" as const };
184 const host = `ledger.${DOMAIN}`;
185 const carols = await cookieFrom(
186 grantUrl(runtime, target, "/", host, {
187 id: carol.id,
188 name: carol.username,
189 role: "user",
190 }),
191 );
192 expect(carols).not.toBe("");
193 expect((await knock(host, carols)).status).toBe(404);
194 const alices = await cookieFrom(
195 grantUrl(runtime, target, "/", host, {
196 id: alice.id,
197 name: alice.username,
198 role: "admin",
199 }),
200 );
201 expect((await knock(host, alices)).status).not.toBe(404);
202 // Nobody named, nothing granted.
203 expect((await knock(host)).status).toBe(404);
204 });
205
206 test("the same holds for a preview's host", async () => {
207 const target = { app: record, env: "pull-2" as const };
208 const host = `ledger--2.${DOMAIN}`;
209 const grantFor = (who: User, role: "user" | "admin") =>
210 cookieFrom(
211 grantUrl(runtime, target, "/", host, {
212 id: who.id,
213 name: who.username,
214 role,
215 }),
216 );
217 // A person navigating who is not let in is sent to sign in (303); one
218 // let in reaches the preview, here one with no deploy (not a 303).
219 const page = (cookie: string) =>
220 fetch(`${servers.url}/`, {
221 headers: { host, accept: "text/html", cookie },
222 redirect: "manual",
223 });
224 expect((await page(await grantFor(carol, "user"))).status).toBe(303);
225 expect((await page(await grantFor(alice, "admin"))).status).not.toBe(303);
226 });
227
228 test("the terminal refuses a reader and an anonymous visitor; a writer and the crew get through", async () => {
229 const target = { app: record, env: "production" as const };
230 const stranger = await runAppTty(app, ttyHost, carol, target);
231 expect(stranger.ok).toBe(false);
232 if (!stranger.ok) expect(stranger.error.refusal).toBe("not_found");
233 const nobody = await runAppTty(app, ttyHost, undefined, target);
234 expect(nobody.ok).toBe(false);
235 if (!nobody.ok)
236 expect(["private", "not_found"]).toContain(nobody.error.refusal);
237 // Past the door, these two fail only for want of a terminal here.
238 for (const who of [alice, bot]) {
239 const through = await runAppTty(app, ttyHost, who, target);
240 expect(through.ok).toBe(false);
241 if (!through.ok) expect(through.error.refusal).toBe("no_pty");
242 }
243 });
244
245 test("a reader sees none of a private app's state or errors; a writer sees it", async () => {
246 // The runtime knows production from the first deploy on.
247 expect(envsFor(app, alice, record).length).toBeGreaterThan(0);
248 expect(envsFor(app, bot, record).length).toBeGreaterThan(0);
249 expect(envsFor(app, carol, record)).toEqual([]);
250 expect(envsFor(app, undefined, record)).toEqual([]);
251 const as = (cookie: string) =>
252 fetch(`${servers.url}/api/v1/repos/alice/ledger/app`, {
253 headers: { cookie },
254 }).then((r) => r.json() as Promise<any>);
255 expect((await as(carolCookie)).environments).toEqual([]);
256 expect((await as(aliceCookie)).environments.length).toBeGreaterThan(0);
257 });
258
259 test("the settings page says, before saving, what stays private", async () => {
260 const page = await fetch(`${servers.url}/alice/ledger/settings`, {
261 headers: { cookie: `${aliceCookie}; forge_mode=dev` },
262 });
263 expect(page.status).toBe(200);
264 const text = await page.text();
265 expect(text).toContain("run logs");
266 expect(text).toContain("stay open only to people who can write here");
267 const appSettings = await fetch(
268 `${servers.url}/alice/ledger/settings/app`,
269 { headers: { cookie: `${aliceCookie}; forge_mode=dev` } },
270 );
271 expect(await appSettings.text()).toContain(
272 "Private — people who can write this repository",
273 );
274 });
275 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs