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
Check · Review · Land · Live done — Landed and live in production.
steward/620-private-app-writers → main 2 commits
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 | ||
| 103 | 103 | visibility), and it is set apart from the repository's own: the code can |
| 104 | 104 | be private while the app is public, or the other way round. A private |
| 105 | 105 | 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. | |
| 111 | 115 | 6. **A run's credential dies with the run.** An agent run gets a token |
| 112 | 116 | scoped `rw` (no settings, no tokens, no administration) with an expiry, |
| 113 | 117 | and the token is deleted when the run ends however it ends. It never |
| @@ -409,8 +413,9 @@ exactly this). | ||
| 409 | 413 | |
| 410 | 414 | Behind the headers is the grant. A person who opens an app through the |
| 411 | 415 | 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, | |
| 414 | 419 | HttpOnly, SameSite=Lax). The forge answers two paths on every app's host: |
| 415 | 420 | `/__forge/signin?to=<path>` starts that round trip, and |
| 416 | 421 | `/__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 | ||
| 441 | 446 | `FORGE_APP_USER`, the forge account at the keyboard or empty for a guest). |
| 442 | 447 | `notes--12@forge` is the preview of pull 12's. The key still decides who |
| 443 | 448 | 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 | |
| 446 | 451 | `SIGHUP`. |
| 447 | 452 | |
| 448 | 453 | 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. | ||
| 20 | 20 | cross-site page could ride, and this door has no page. An `ro` token reads; |
| 21 | 21 | an `rw` token acts; the tools say which they are (`readOnlyHint`). |
| 22 | 22 | - **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 | |
| 24 | 24 | on an app with `probe`, are a writer's. |
| 25 | 25 | |
| 26 | 26 | ## Tools |
scripts/gate.ts +1
| @@ -119,6 +119,7 @@ export const GATE: readonly string[] = [ | ||
| 119 | 119 | "test/origins-security-push.test.ts", |
| 120 | 120 | "test/origins-sync.test.ts", |
| 121 | 121 | "test/origins.test.ts", |
| 122 | "test/private-app.test.ts", | |
| 122 | 123 | "test/check-list.test.ts", |
| 123 | 124 | "test/hollow.test.ts", |
| 124 | 125 | "test/oauth-discovery.test.ts", |
src/actions/apps.ts +8 −3
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | 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"; | |
| 11 | 11 | import { grantUrl, originFor } from "../apps/proxy.ts"; |
| 12 | 12 | import { scaffoldFiles } from "../apps/scaffold.ts"; |
| 13 | 13 | import { |
| @@ -42,7 +42,7 @@ import { listAllAgents } from "./crew.ts"; | ||
| 42 | 42 | import { plan, planIssueBody, planningAvailable } from "../crew/plan.ts"; |
| 43 | 43 | import type { Issue } from "../core/issues.ts"; |
| 44 | 44 | 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"; | |
| 46 | 46 | import type { Run } from "../core/runs.ts"; |
| 47 | 47 | import type { User } from "../core/users.ts"; |
| 48 | 48 | import { resolveOid } from "../git/merge.ts"; |
| @@ -454,7 +454,7 @@ export function appView( | ||
| 454 | 454 | const runtime = appsRuntime(app); |
| 455 | 455 | return ok({ |
| 456 | 456 | app: record, |
| 457 | environments: runtime === undefined ? [] : runtime.status(record), | |
| 457 | environments: envsFor(app, actor, record), | |
| 458 | 458 | serving: runtime !== undefined, |
| 459 | 459 | }); |
| 460 | 460 | } |
| @@ -476,6 +476,11 @@ export function openEnvUrl( | ||
| 476 | 476 | const record = getAppByRepo(app.db, repo.id); |
| 477 | 477 | if (!record) return err("not_found"); |
| 478 | 478 | 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"); | |
| 479 | 484 | const runtime = appsRuntime(app); |
| 480 | 485 | if (runtime === undefined) return err("no_runtime"); |
| 481 | 486 | // 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"; | ||
| 19 | 19 | import type { App } from "../app.ts"; |
| 20 | 20 | import { getAppById, getAppBySlug, type AppRecord } from "../core/apps.ts"; |
| 21 | 21 | 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"; | |
| 23 | 24 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 24 | 25 | import { loadKey } from "../kernel/secret.ts"; |
| 25 | 26 | import { previewEnv, type EnvName } from "./data.ts"; |
| @@ -310,6 +311,22 @@ const HOP_BY_HOP = [ | ||
| 310 | 311 | "upgrade", |
| 311 | 312 | ]; |
| 312 | 313 | |
| 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 | ||
| 313 | 330 | /** |
| 314 | 331 | * What a request addressed to an app must pass before anything is served: |
| 315 | 332 | * a private app's grant, then a live port. One gate for both the request |
| @@ -322,14 +339,24 @@ function admit( | ||
| 322 | 339 | target: AppTarget, |
| 323 | 340 | ): Result<{ status: EnvStatus; port: number; grant?: Grant }, Response> { |
| 324 | 341 | const cookie = cookieValue(request, grantCookieName(target)); |
| 325 | const grant = | |
| 342 | const signed = | |
| 326 | 343 | cookie === undefined |
| 327 | 344 | ? undefined |
| 328 | 345 | : 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; | |
| 329 | 356 | if (target.app.access === "private") { |
| 330 | 357 | if (grant === undefined) { |
| 331 | 358 | // 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 | |
| 333 | 360 | // grant, and otherwise gives the answer a private repository gives. |
| 334 | 361 | // Anything that is not a person navigating — a script, an API call — |
| 335 | 362 | // gets that answer here. |
src/apps/runtime.ts +24 −1
| @@ -25,7 +25,8 @@ import type { ForgeEvent } from "../core/events.ts"; | ||
| 25 | 25 | import { announceRun } from "../actions/runs.ts"; |
| 26 | 26 | import { requeueAbandonedChecks } from "../checks/service.ts"; |
| 27 | 27 | 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"; | |
| 29 | 30 | import { |
| 30 | 31 | cancelRun, |
| 31 | 32 | listRuns, |
| @@ -333,6 +334,28 @@ const runtimes = new WeakMap<App, AppsRuntime>(); | ||
| 333 | 334 | export const appsRuntime = (app: App): AppsRuntime | undefined => |
| 334 | 335 | runtimes.get(app); |
| 335 | 336 | |
| 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 | ||
| 336 | 359 | export class AppsRuntime { |
| 337 | 360 | private readonly envs = new Map<string, Environment>(); |
| 338 | 361 | private readonly gate: Gate; |
src/apps/tty.ts +6 −2
| @@ -4,7 +4,8 @@ | ||
| 4 | 4 | * pty, in the deployed checkout, with the environment's own variables; |
| 5 | 5 | * `notes--12@forge` opens the preview of pull 12's. The login name picks |
| 6 | 6 | * 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. | |
| 8 | 9 | * |
| 9 | 10 | * The same bridge serves the forge's own terminal UI: press `t` on an app |
| 10 | 11 | * 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"; | ||
| 21 | 22 | import { getAppBySlug, type AppRecord } from "../core/apps.ts"; |
| 22 | 23 | import { identityKey } from "./proxy.ts"; |
| 23 | 24 | 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"; | |
| 25 | 26 | import type { User } from "../core/users.ts"; |
| 26 | 27 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 27 | 28 | import type { SshSession } from "../ssh/server.ts"; |
| @@ -120,6 +121,9 @@ export async function runAppTty( | ||
| 120 | 121 | return err({ refusal: "not_found", detail: "" }); |
| 121 | 122 | if (target.app.access === "private" && user === undefined) |
| 122 | 123 | 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: "" }); | |
| 123 | 127 | if (host.term === undefined) return err({ refusal: "no_pty", detail: "" }); |
| 124 | 128 | const runtime = appsRuntime(forge); |
| 125 | 129 | // A terminal opened on a sleeping environment wakes it first. |
src/core/repos.ts +16
| @@ -650,6 +650,22 @@ export function canWrite( | ||
| 650 | 650 | return accessAllows(orgAccessFor(db, repo, user), "write"); |
| 651 | 651 | } |
| 652 | 652 | |
| 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 | ||
| 653 | 669 | /** |
| 654 | 670 | * Money, the crew roster, autonomy limits and a run's own cost describe |
| 655 | 671 | * 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 { | ||
| 213 | 213 | import { dependenciesOf, repoGraph } from "../core/dependencies.ts"; |
| 214 | 214 | import { decisionStats, listDecisions } from "../core/decisions.ts"; |
| 215 | 215 | 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"; | |
| 217 | 217 | import { isEnvName } from "../apps/data.ts"; |
| 218 | 218 | import { getAppByRepo } from "../core/apps.ts"; |
| 219 | 219 | import type { Agent } from "../core/agents.ts"; |
| @@ -774,7 +774,7 @@ function repoQuery(ctx: Ctx) { | ||
| 774 | 774 | * and the domains of its own that are verified — the ones the forge serves. |
| 775 | 775 | */ |
| 776 | 776 | function appJson( |
| 777 | db: Database, | |
| 777 | ctx: Ctx, | |
| 778 | 778 | record: AppRecord, |
| 779 | 779 | repo: Repo, |
| 780 | 780 | runtime: AppsRuntime | undefined, |
| @@ -783,11 +783,12 @@ function appJson( | ||
| 783 | 783 | ...record, |
| 784 | 784 | repo: { owner: repo.ownerName, name: repo.name }, |
| 785 | 785 | url: runtime?.urlFor(record, "production") ?? null, |
| 786 | domains: listDomains(db, record.id) | |
| 786 | domains: listDomains(ctx.app.db, record.id) | |
| 787 | 787 | .filter((d) => d.verifiedAt !== null) |
| 788 | 788 | .map((d) => d.host), |
| 789 | 789 | serving: runtime !== undefined, |
| 790 | environments: runtime?.status(record) ?? [], | |
| 790 | environments: | |
| 791 | runtime === undefined ? [] : envsFor(ctx.app, ctx.auth.user, record), | |
| 791 | 792 | }; |
| 792 | 793 | } |
| 793 | 794 | |
| @@ -3093,7 +3094,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3093 | 3094 | for (const record of records) { |
| 3094 | 3095 | const repo = repos.get(record.repoId); |
| 3095 | 3096 | 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)); | |
| 3097 | 3098 | } |
| 3098 | 3099 | return json({ items, next_cursor: null }); |
| 3099 | 3100 | }); |
| @@ -3118,7 +3119,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3118 | 3119 | { |
| 3119 | 3120 | repo: repoJson(ctx, made.value.repo), |
| 3120 | 3121 | app: appJson( |
| 3121 | ctx.app.db, | |
| 3122 | ctx, | |
| 3122 | 3123 | made.value.app, |
| 3123 | 3124 | made.value.repo, |
| 3124 | 3125 | appsRuntime(ctx.app), |
| @@ -3212,7 +3213,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3212 | 3213 | const view = appView(ctx.app, ctx.auth.user, repo); |
| 3213 | 3214 | if (!view.ok) return notFound(); |
| 3214 | 3215 | return json( |
| 3215 | appJson(ctx.app.db, view.value.app, repo, appsRuntime(ctx.app)), | |
| 3216 | appJson(ctx, view.value.app, repo, appsRuntime(ctx.app)), | |
| 3216 | 3217 | ); |
| 3217 | 3218 | }); |
| 3218 | 3219 | |
| @@ -3225,7 +3226,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3225 | 3226 | const enabled = enableApp(ctx.app, ctx.auth.user, repo, appPatch(input)); |
| 3226 | 3227 | if (!enabled.ok) return respondAccess(enabled.error, 422); |
| 3227 | 3228 | return json( |
| 3228 | appJson(ctx.app.db, enabled.value, repo, appsRuntime(ctx.app)), | |
| 3229 | appJson(ctx, enabled.value, repo, appsRuntime(ctx.app)), | |
| 3229 | 3230 | 201, |
| 3230 | 3231 | ); |
| 3231 | 3232 | }); |
| @@ -3243,7 +3244,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3243 | 3244 | appPatch(input), |
| 3244 | 3245 | ); |
| 3245 | 3246 | 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))); | |
| 3247 | 3248 | }); |
| 3248 | 3249 | |
| 3249 | 3250 | router.add("DELETE", "/api/v1/repos/:owner/:name/app", (ctx) => { |
src/http/mcp.ts +4 −4
| @@ -37,7 +37,7 @@ import { | ||
| 37 | 37 | import { ITEM_ID } from "../core/interactions.ts"; |
| 38 | 38 | import { share } from "../feed/share.ts"; |
| 39 | 39 | import { parseTargets } from "./feed.ts"; |
| 40 | import { appsRuntime } from "../apps/runtime.ts"; | |
| 40 | import { appsRuntime, envsFor } from "../apps/runtime.ts"; | |
| 41 | 41 | import { getIssue, getIssueById } from "../core/issues.ts"; |
| 42 | 42 | import { repoGraph } from "../core/dependencies.ts"; |
| 43 | 43 | import { getAppByRepo } from "../core/apps.ts"; |
| @@ -156,9 +156,9 @@ const TOOLS: readonly Tool[] = [ | ||
| 156 | 156 | return good( |
| 157 | 157 | repos.map((repo) => { |
| 158 | 158 | 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 | ); | |
| 162 | 162 | return { |
| 163 | 163 | app: `${repo.ownerName}/${repo.name}`, |
| 164 | 164 | description: repo.description, |
src/tui/screens/pull.ts +4 −3
| @@ -43,8 +43,7 @@ import { previewEnv } from "../../apps/data.ts"; | ||
| 43 | 43 | import { |
| 44 | 44 | appsRuntime, |
| 45 | 45 | type EnvState, |
| 46 | type EnvStatus, | |
| 47 | } from "../../apps/runtime.ts"; | |
| 46 | type EnvStatus, envsFor } from "../../apps/runtime.ts"; | |
| 48 | 47 | import { getAppByRepo } from "../../core/apps.ts"; |
| 49 | 48 | import { getIssue, type Issue } from "../../core/issues.ts"; |
| 50 | 49 | import { getPull, type Pull } from "../../core/pulls.ts"; |
| @@ -252,7 +251,9 @@ export function pullScreen( | ||
| 252 | 251 | preview = |
| 253 | 252 | record === undefined |
| 254 | 253 | ? undefined |
| 255 | : appsRuntime(f.app)?.envStatus(record, previewEnv(number)); | |
| 254 | : envsFor(f.app, f.user, record).find( | |
| 255 | (e) => e.env === previewEnv(number), | |
| 256 | ); | |
| 256 | 257 | check = |
| 257 | 258 | row === undefined |
| 258 | 259 | ? NO_CHECK |
src/web/pages/apps.ts +2 −2
| @@ -24,7 +24,7 @@ import { listAllAgents } from "../../actions/crew.ts"; | ||
| 24 | 24 | import { planningAvailable } from "../../crew/plan.ts"; |
| 25 | 25 | import { appNameJs, deriveAppName } from "./apps-name.ts"; |
| 26 | 26 | 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"; | |
| 28 | 28 | import { appHostSuffix } from "../../apps/host.ts"; |
| 29 | 29 | import { appsForRepos, type AppRecord } from "../../core/apps.ts"; |
| 30 | 30 | import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts"; |
| @@ -99,7 +99,7 @@ function appsForViewer(ctx: Ctx): AppEntry[] { | ||
| 99 | 99 | for (const repo of repos) { |
| 100 | 100 | const record = records.get(repo.id); |
| 101 | 101 | if (record === undefined) continue; |
| 102 | const envs = runtime === undefined ? [] : runtime.status(record); | |
| 102 | const envs = envsFor(ctx.app, ctx.auth.user, record); | |
| 103 | 103 | out.push({ |
| 104 | 104 | record, |
| 105 | 105 | repo, |
src/web/pages/search.ts +2 −3
| @@ -12,7 +12,7 @@ | ||
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | 14 | import { search } from "../../actions/search.ts"; |
| 15 | import { appsRuntime } from "../../apps/runtime.ts"; | |
| 15 | import { appsRuntime, envsFor } from "../../apps/runtime.ts"; | |
| 16 | 16 | import type { AppRecord } from "../../core/apps.ts"; |
| 17 | 17 | import { getPull } from "../../core/pulls.ts"; |
| 18 | 18 | import { canWrite, getRepo } from "../../core/repos.ts"; |
| @@ -77,8 +77,7 @@ const privateBadge = (yes: boolean): Html => | ||
| 77 | 77 | /** Whether an app is live. No query — `runtime.status` reads what is |
| 78 | 78 | * already in memory. */ |
| 79 | 79 | 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); | |
| 82 | 81 | const state = envs.find((e) => e.env === "production")?.state ?? "stopped"; |
| 83 | 82 | const tone: Tone = |
| 84 | 83 | 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> { | ||
| 255 | 255 | ${selectField({ |
| 256 | 256 | label: "Visibility", |
| 257 | 257 | 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.", | |
| 259 | 262 | options: [ |
| 260 | 263 | { |
| 261 | 264 | value: "private", |
| @@ -425,11 +428,11 @@ function appSettingsView( | ||
| 425 | 428 | hint: |
| 426 | 429 | values.access === "public" |
| 427 | 430 | ? `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.`, | |
| 429 | 432 | options: [ |
| 430 | 433 | { |
| 431 | 434 | value: "private", |
| 432 | label: "Private — people who can see this repository", | |
| 435 | label: "Private — people who can write this repository", | |
| 433 | 436 | selected: values.access === "private", |
| 434 | 437 | }, |
| 435 | 438 | { |
src/web/studio/change.ts +4 −4
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | * `?mode=dev` reads the code, unchanged (`pages/pull.ts`). |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { appsRuntime } from "../../apps/runtime.ts"; | |
| 10 | import { appsRuntime, envsFor } from "../../apps/runtime.ts"; | |
| 11 | 11 | import type { AppRecord } from "../../core/apps.ts"; |
| 12 | 12 | import { pullStatus, revertOf, toldBy, type Pull } from "../../core/pulls.ts"; |
| 13 | 13 | import { canWrite, type Repo } from "../../core/repos.ts"; |
| @@ -102,9 +102,9 @@ export async function studioPull( | ||
| 102 | 102 | const run = checkSummary(db, pull.id, pull.headOid).run; |
| 103 | 103 | if (run) deploy = { id: run.id, commitOid: run.commitOid }; |
| 104 | 104 | } 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 | ); | |
| 108 | 108 | if (production?.runId) |
| 109 | 109 | deploy = { id: production.runId, commitOid: production.commitOid }; |
| 110 | 110 | } |
src/web/studio/now.ts +2 −2
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { envPull } from "../../apps/data.ts"; |
| 11 | import { appsRuntime } from "../../apps/runtime.ts"; | |
| 11 | import { appsRuntime, envsFor } from "../../apps/runtime.ts"; | |
| 12 | 12 | import { listCrewRoles } from "../../core/agents.ts"; |
| 13 | 13 | import { getAppByRepo, type AppRecord } from "../../core/apps.ts"; |
| 14 | 14 | import { getIssueById, listIssues, type Issue } from "../../core/issues.ts"; |
| @@ -207,7 +207,7 @@ export async function nowCard( | ||
| 207 | 207 | |
| 208 | 208 | // An environment the sleep sweep is sparing because it is mid-work: |
| 209 | 209 | // 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) => { | |
| 211 | 211 | if (env.busy === undefined) return []; |
| 212 | 212 | const number = envPull(env.env); |
| 213 | 213 | return [ |
src/web/studio/previews.ts +2 −3
| @@ -12,8 +12,7 @@ import { envPull, previewEnv } from "../../apps/data.ts"; | ||
| 12 | 12 | import { |
| 13 | 13 | appsRuntime, |
| 14 | 14 | type AppsRuntime, |
| 15 | type EnvStatus, | |
| 16 | } from "../../apps/runtime.ts"; | |
| 15 | type EnvStatus, envsFor } from "../../apps/runtime.ts"; | |
| 17 | 16 | import { getAppByRepo, type AppRecord } from "../../core/apps.ts"; |
| 18 | 17 | import { listPulls, type Pull } from "../../core/pulls.ts"; |
| 19 | 18 | import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| @@ -271,7 +270,7 @@ export function appPanels( | ||
| 271 | 270 | const db = ctx.app.db; |
| 272 | 271 | const base = repoBase(repo); |
| 273 | 272 | 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); | |
| 275 | 274 | const writer = canSeeOwnerFacts(db, repo, ctx.auth.user); |
| 276 | 275 | const previews = previewsPanel(base, runtime, record, openPulls, envs, writer); |
| 277 | 276 | const production = envs.find((e) => e.env === "production"); |
src/web/studio/shelf.ts +5 −5
| @@ -24,7 +24,7 @@ | ||
| 24 | 24 | * told-but-stuck when nobody on the crew can take it. |
| 25 | 25 | */ |
| 26 | 26 | |
| 27 | import { appsRuntime, type EnvStatus } from "../../apps/runtime.ts"; | |
| 27 | import { appsRuntime, type EnvStatus, envsFor } from "../../apps/runtime.ts"; | |
| 28 | 28 | import { appsForRepos } from "../../core/apps.ts"; |
| 29 | 29 | import { reposWithBuilder } from "../../core/agents.ts"; |
| 30 | 30 | import { reposWithUnclaimedTell } from "../../core/issues.ts"; |
| @@ -84,7 +84,9 @@ export function productionFactsForRepos( | ||
| 84 | 84 | for (const repo of repos) { |
| 85 | 85 | const record = records.get(repo.id); |
| 86 | 86 | 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 | ) | |
| 88 | 90 | : undefined; |
| 89 | 91 | productions.set(repo.id, production); |
| 90 | 92 | if (production?.runId !== undefined && production.createdAt !== undefined) |
| @@ -158,9 +160,7 @@ export async function shelfRows( | ||
| 158 | 160 | repos.map(async (repo) => { |
| 159 | 161 | const record = records.get(repo.id); |
| 160 | 162 | const envs = |
| 161 | runtime === undefined || record === undefined | |
| 162 | ? [] | |
| 163 | : runtime.status(record); | |
| 163 | record === undefined ? [] : envsFor(ctx.app, ctx.auth.user, record); | |
| 164 | 164 | const production = envs.find((e) => e.env === "production"); |
| 165 | 165 | const latest = latestRuns.get(repo.id); |
| 166 | 166 | // 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", () | ||
| 1501 | 1501 | // `unauthorized` is reachable only through the other combination: a |
| 1502 | 1502 | // *public* repository (so `canRead` already passes for anyone) whose |
| 1503 | 1503 | // 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). | |
| 1508 | 1507 | const overrideMade = await newApp(app, alice, { |
| 1509 | 1508 | name: "publicwithprivateapp", |
| 1510 | 1509 | visibility: "public", |
| @@ -1530,7 +1529,9 @@ describe("apps adversarial: teardown races, grants, the proxy, and rule 1/4", () | ||
| 1530 | 1529 | overrideMade.value.repo, |
| 1531 | 1530 | "production", |
| 1532 | 1531 | ); |
| 1533 | expect(strangerOnPublicRepo.ok).toBe(true); | |
| 1532 | expect(strangerOnPublicRepo.ok).toBe(false); | |
| 1533 | if (!strangerOnPublicRepo.ok) | |
| 1534 | expect(strangerOnPublicRepo.error).toBe("not_found"); | |
| 1534 | 1535 | }, 60_000); |
| 1535 | 1536 | |
| 1536 | 1537 | 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 | }); |
new above
offline