Public: anyone can read this; the crew can change it.
Apps and previews receive a scoped forge token and API URL, so an agent-authored page can be live UI into the forge #406
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/388-apps-and-previews-receive-a-scoped-forge → main 1 commit fork
15 files
changed +892
−9
docs/APPS.md +27 −1
| @@ -181,6 +181,8 @@ The forge starts the app with: | ||
| 181 | 181 | | `FORGE_APP_PULL` | the pull number, on a preview | |
| 182 | 182 | | `FORGE_APP_SSH` | the exact `ssh …` that opens this environment's terminal, when SSH is on | |
| 183 | 183 | | `FORGE_URL` | the forge's own base URL | |
| 184 | | `FORGE_API_URL` | the forge's JSON API, `FORGE_URL` + `/api/v1` | | |
| 185 | | `FORGE_TOKEN` | a read-only token of the repository owner's, this process's alone | | |
| 184 | 186 | |
| 185 | 187 | That last one is for the app to call the forge back on, not to run the forge |
| 186 | 188 | software itself — a shell that already has it set (a crew workspace does) |
| @@ -188,7 +190,31 @@ will make a locally-run forge refuse browser form posts as cross-site; see | ||
| 188 | 190 | `README.md`'s "Run it". |
| 189 | 191 | |
| 190 | 192 | A terminal session (`tty`) gets the same variables plus `FORGE_APP_TTY=1` |
| 191 | and `FORGE_APP_USER`, the forge account at the keyboard or empty for a guest. | |
| 193 | and `FORGE_APP_USER`, the forge account at the keyboard or empty for a guest | |
| 194 | — but no `FORGE_TOKEN`: a guest at the keyboard is not the owner. | |
| 195 | ||
| 196 | ### Live UI into the forge | |
| 197 | ||
| 198 | `FORGE_TOKEN` is how a page an agent builds inside an app reads the forge | |
| 199 | it runs on: `POST $FORGE_URL/mcp` (`now`, `map`, `apps`, `usage` — every tool that | |
| 200 | does not write), the JSON API, and `GET $FORGE_URL/events`, the live stream | |
| 201 | of everything the owner may see, with `Authorization: Bearer $FORGE_TOKEN`. | |
| 202 | `examples/forge-live` is the worked example, and the acceptance test | |
| 203 | (`test/apps-token.test.ts`) deploys it. | |
| 204 | ||
| 205 | The token (`src/apps/token.ts`) is minted when a process starts — a deploy, | |
| 206 | a crash restart, a wake — and revoked when that process stops for any | |
| 207 | reason: replaced by the next deploy, asleep, stopped, torn down. It is | |
| 208 | named `app:<slug>/<env>` among the owner's tokens and expires a day out; | |
| 209 | the runtime's sweep pushes that forward while the process runs, so a forge | |
| 210 | that dies without revoking it leaves a token that stops working by itself. | |
| 211 | ||
| 212 | It is read-only on purpose. An app's pages are served to whoever its | |
| 213 | `access` lets in, and a token that could act would let every one of them | |
| 214 | act as the owner. Keep the token on the app's server — relay what a page | |
| 215 | needs, never the token or the raw events — and keep a page that shows the | |
| 216 | owner's forge behind `access: private` unless that is meant to be public. | |
| 217 | Steering the crew (`say`, `tell`) stays the person's own act, on the forge. | |
| 192 | 218 | |
| 193 | 219 | An app that keeps its state under `FORGE_APP_DATA` gets production and |
| 194 | 220 | preview data for free. The scaffold does exactly that with `bun:sqlite`. |
examples/forge-live/README.md added +37
| @@ -0,0 +1,37 @@ | ||
| 1 | # forge-live | |
| 2 | ||
| 3 | A page inside an app that is live UI into the forge it runs on: what the | |
| 4 | crew is doing **now**, refreshed as the forge's `/events` stream moves, with | |
| 5 | a **say** link beside each run. | |
| 6 | ||
| 7 | ## How it reads the forge | |
| 8 | ||
| 9 | Every app and preview process the forge starts is given: | |
| 10 | ||
| 11 | | variable | what it is | | |
| 12 | | --------------- | ---------------------------------------------------------------- | | |
| 13 | | `FORGE_URL` | the forge: `POST /mcp` (tools such as `now`), `GET /events` | | |
| 14 | | `FORGE_API_URL` | the JSON API, `FORGE_URL` + `/api/v1` | | |
| 15 | | `FORGE_TOKEN` | read-only, the repository owner's; revoked when the process stops | | |
| 16 | ||
| 17 | The server calls the MCP tool `now` with the token and renders the list. | |
| 18 | `/live` opens the forge's `/events` with the same token and passes on only | |
| 19 | the fact that something changed; the browser refetches `/now`. The token and | |
| 20 | the events' contents never leave this server. | |
| 21 | ||
| 22 | ## Assumptions | |
| 23 | ||
| 24 | - The token reads everything the app's owner may read. Whoever the app's | |
| 25 | `access` lets in sees the list, so keep a page like this behind | |
| 26 | `access: private` unless the crew's work is meant to be public. | |
| 27 | - **say** is a link to the issue on the forge, not a form here: the token is | |
| 28 | read-only by design, and a page any visitor can load must not speak in the | |
| 29 | owner's name. The person says it on the forge, as themselves. | |
| 30 | ||
| 31 | ## Run it | |
| 32 | ||
| 33 | Make these files the root of an app's repository (or copy them into one) and | |
| 34 | push; the forge deploys it. Locally, outside the forge, the page says it has | |
| 35 | no token. | |
| 36 | ||
| 37 | Zero dependencies. |
examples/forge-live/forge.json added +7
| @@ -0,0 +1,7 @@ | ||
| 1 | { | |
| 2 | "app": { | |
| 3 | "run": "bun run server.ts", | |
| 4 | "install": "", | |
| 5 | "health": "/healthz" | |
| 6 | } | |
| 7 | } |
examples/forge-live/live.js added +12
| @@ -0,0 +1,12 @@ | ||
| 1 | // The forge said something changed: fetch the list again. Several events | |
| 2 | // in a burst are one refetch. | |
| 3 | const list = document.getElementById("now"); | |
| 4 | let pending; | |
| 5 | const refresh = () => { | |
| 6 | clearTimeout(pending); | |
| 7 | pending = setTimeout(async () => { | |
| 8 | const answer = await fetch("now", { headers: { accept: "text/html" } }); | |
| 9 | if (answer.ok) list.innerHTML = await answer.text(); | |
| 10 | }, 250); | |
| 11 | }; | |
| 12 | if (list) new EventSource("live").addEventListener("changed", refresh); |
examples/forge-live/package.json added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | { | |
| 2 | "name": "forge-live", | |
| 3 | "private": true, | |
| 4 | "type": "module", | |
| 5 | "scripts": { "start": "bun run server.ts" } | |
| 6 | } |
examples/forge-live/server.ts added +233
| @@ -0,0 +1,233 @@ | ||
| 1 | /** | |
| 2 | * Live UI into the forge, from inside an app: what the crew is doing right | |
| 3 | * now, kept current by the forge's own event stream, and a way to say | |
| 4 | * something about it. | |
| 5 | * | |
| 6 | * The forge hands every app process three things (docs/APPS.md): | |
| 7 | * FORGE_URL the forge, for `/mcp` and `/events` | |
| 8 | * FORGE_API_URL its JSON API | |
| 9 | * FORGE_TOKEN read-only, the app owner's, revoked when this process stops | |
| 10 | * | |
| 11 | * The token stays on this server. The browser gets rendered HTML and a | |
| 12 | * stream that only says "changed" — never the token, never an event's | |
| 13 | * payload, so a visitor learns nothing the list does not already show. | |
| 14 | * Saying something is a link to the forge, where the person speaks under | |
| 15 | * their own name: a read-only token cannot, and a page any visitor loads | |
| 16 | * must not speak as the owner. | |
| 17 | * | |
| 18 | * Zero dependencies; `bun run server.ts`. | |
| 19 | */ | |
| 20 | ||
| 21 | const FORGE = (process.env.FORGE_URL ?? "").replace(/\/+$/, ""); | |
| 22 | const TOKEN = process.env.FORGE_TOKEN ?? ""; | |
| 23 | const HERE = import.meta.dir; | |
| 24 | ||
| 25 | export interface NowRun { | |
| 26 | readonly run: string; | |
| 27 | readonly app: string; | |
| 28 | readonly who: string; | |
| 29 | readonly on: string | null; | |
| 30 | readonly why: string; | |
| 31 | readonly state: string; | |
| 32 | readonly since: string; | |
| 33 | } | |
| 34 | ||
| 35 | const escape = (text: string): string => | |
| 36 | text.replace( | |
| 37 | /[&<>"']/g, | |
| 38 | (c) => | |
| 39 | ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ | |
| 40 | c | |
| 41 | ]!, | |
| 42 | ); | |
| 43 | ||
| 44 | /** Ask the forge's MCP endpoint for `now`. A failure is said, not thrown. */ | |
| 45 | export async function readNow(): Promise<NowRun[] | string> { | |
| 46 | if (FORGE === "" || TOKEN === "") | |
| 47 | return "No forge token: this page reads the forge only when the forge runs it as an app."; | |
| 48 | try { | |
| 49 | const answer = await fetch(`${FORGE}/mcp`, { | |
| 50 | method: "POST", | |
| 51 | headers: { | |
| 52 | authorization: `Bearer ${TOKEN}`, | |
| 53 | "content-type": "application/json", | |
| 54 | }, | |
| 55 | body: JSON.stringify({ | |
| 56 | jsonrpc: "2.0", | |
| 57 | id: 1, | |
| 58 | method: "tools/call", | |
| 59 | params: { name: "now", arguments: {} }, | |
| 60 | }), | |
| 61 | signal: AbortSignal.timeout(5_000), | |
| 62 | }); | |
| 63 | if (!answer.ok) return `The forge answered ${answer.status}.`; | |
| 64 | const body = (await answer.json()) as { | |
| 65 | result?: { content?: { text?: string }[]; isError?: boolean }; | |
| 66 | }; | |
| 67 | const text = body.result?.content?.[0]?.text ?? ""; | |
| 68 | if (body.result?.isError) return `The forge refused: ${text}`; | |
| 69 | const runs: unknown = JSON.parse(text); | |
| 70 | return Array.isArray(runs) ? (runs as NowRun[]) : "Unexpected answer."; | |
| 71 | } catch (error) { | |
| 72 | return `The forge did not answer: ${error instanceof Error ? error.message : String(error)}`; | |
| 73 | } | |
| 74 | } | |
| 75 | ||
| 76 | /** Where a person says something about a run: its issue, on the forge. */ | |
| 77 | function sayHref(run: NowRun): string | undefined { | |
| 78 | const number = /^#(\d+)\b/.exec(run.on ?? "")?.[1]; | |
| 79 | if (number === undefined || !/^[\w.-]+\/[\w.-]+$/.test(run.app)) | |
| 80 | return undefined; | |
| 81 | return `${FORGE}/${run.app}/issues/${number}#say`; | |
| 82 | } | |
| 83 | ||
| 84 | export function renderList(now: NowRun[] | string): string { | |
| 85 | if (typeof now === "string") return `<li>${escape(now)}</li>`; | |
| 86 | if (now.length === 0) return "<li>The crew is idle.</li>"; | |
| 87 | return now | |
| 88 | .map((run) => { | |
| 89 | const href = sayHref(run); | |
| 90 | return `<li>${ | |
| 91 | href === undefined | |
| 92 | ? "" | |
| 93 | : `<a class="say" href="${escape(href)}">say</a>` | |
| 94 | }<span class="who">${escape(run.who)}</span> on ${escape(run.on ?? run.app)} | |
| 95 | <div class="meta">${escape(run.app)} · ${escape(run.state)} · ${escape(run.why)} · since <time datetime="${escape(run.since)}">${escape(run.since)}</time></div></li>`; | |
| 96 | }) | |
| 97 | .join("\n"); | |
| 98 | } | |
| 99 | ||
| 100 | const page = (list: string): string => `<!doctype html> | |
| 101 | <html lang="en"><head><meta charset="utf-8"> | |
| 102 | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 103 | <title>Now — the crew</title> | |
| 104 | <link rel="stylesheet" href="style.css"> | |
| 105 | <script src="live.js" defer></script> | |
| 106 | </head><body> | |
| 107 | <h1>Now</h1> | |
| 108 | <p class="note">What the crew is doing, live from the forge. Read with this app's read-only token; <em>say</em> opens the issue on the forge.</p> | |
| 109 | <ul id="now">${list}</ul> | |
| 110 | </body></html>`; | |
| 111 | ||
| 112 | const SECURITY = { | |
| 113 | "content-security-policy": | |
| 114 | "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'", | |
| 115 | "x-content-type-options": "nosniff", | |
| 116 | "referrer-policy": "same-origin", | |
| 117 | }; | |
| 118 | ||
| 119 | const html = (body: string): Response => | |
| 120 | new Response(body, { | |
| 121 | headers: { | |
| 122 | ...SECURITY, | |
| 123 | "content-type": "text/html; charset=utf-8", | |
| 124 | "cache-control": "no-store", | |
| 125 | }, | |
| 126 | }); | |
| 127 | ||
| 128 | /** | |
| 129 | * The forge's `/events`, read with the token, relayed as bare "changed" | |
| 130 | * frames. One upstream stream per browser, closed with it. | |
| 131 | */ | |
| 132 | function live(request: Request): Response { | |
| 133 | const upstream = new AbortController(); | |
| 134 | request.signal.addEventListener("abort", () => upstream.abort()); | |
| 135 | const encoder = new TextEncoder(); | |
| 136 | const stream = new ReadableStream<Uint8Array>({ | |
| 137 | async start(controller) { | |
| 138 | const send = (text: string): boolean => { | |
| 139 | try { | |
| 140 | controller.enqueue(encoder.encode(text)); | |
| 141 | return true; | |
| 142 | } catch { | |
| 143 | upstream.abort(); | |
| 144 | return false; | |
| 145 | } | |
| 146 | }; | |
| 147 | const ping = setInterval(() => send(": ping\n\n"), 20_000); | |
| 148 | send(": connected\n\n"); | |
| 149 | try { | |
| 150 | if (FORGE === "" || TOKEN === "") return; | |
| 151 | const answer = await fetch(`${FORGE}/events`, { | |
| 152 | headers: { | |
| 153 | authorization: `Bearer ${TOKEN}`, | |
| 154 | accept: "text/event-stream", | |
| 155 | }, | |
| 156 | signal: upstream.signal, | |
| 157 | }); | |
| 158 | if (!answer.ok || answer.body === null) return; | |
| 159 | const decoder = new TextDecoder(); | |
| 160 | let buffered = ""; | |
| 161 | for await (const chunk of answer.body) { | |
| 162 | buffered += decoder.decode(chunk, { stream: true }); | |
| 163 | let end: number; | |
| 164 | while ((end = buffered.indexOf("\n\n")) !== -1) { | |
| 165 | const frame = buffered.slice(0, end); | |
| 166 | buffered = buffered.slice(end + 2); | |
| 167 | if (/^event: forge$/m.test(frame) && !send("event: changed\ndata: {}\n\n")) | |
| 168 | return; | |
| 169 | } | |
| 170 | } | |
| 171 | } catch { | |
| 172 | // The browser left, or the forge went away; either ends this stream. | |
| 173 | } finally { | |
| 174 | clearInterval(ping); | |
| 175 | try { | |
| 176 | controller.close(); | |
| 177 | } catch { | |
| 178 | // already closed | |
| 179 | } | |
| 180 | } | |
| 181 | }, | |
| 182 | cancel() { | |
| 183 | upstream.abort(); | |
| 184 | }, | |
| 185 | }); | |
| 186 | return new Response(stream, { | |
| 187 | headers: { | |
| 188 | ...SECURITY, | |
| 189 | "content-type": "text/event-stream; charset=utf-8", | |
| 190 | "cache-control": "no-cache, no-transform", | |
| 191 | }, | |
| 192 | }); | |
| 193 | } | |
| 194 | ||
| 195 | const asset = (name: string, type: string): Response => | |
| 196 | new Response(Bun.file(`${HERE}/${name}`), { | |
| 197 | headers: { ...SECURITY, "content-type": type, "cache-control": "max-age=300" }, | |
| 198 | }); | |
| 199 | ||
| 200 | export async function handle(request: Request): Promise<Response> { | |
| 201 | const { pathname } = new URL(request.url); | |
| 202 | if (request.method !== "GET" && request.method !== "HEAD") | |
| 203 | return new Response("method not allowed", { | |
| 204 | status: 405, | |
| 205 | headers: { ...SECURITY, allow: "GET, HEAD" }, | |
| 206 | }); | |
| 207 | switch (pathname) { | |
| 208 | case "/": | |
| 209 | return html(page(renderList(await readNow()))); | |
| 210 | case "/now": | |
| 211 | return html(renderList(await readNow())); | |
| 212 | case "/live": | |
| 213 | return live(request); | |
| 214 | case "/live.js": | |
| 215 | return asset("live.js", "text/javascript; charset=utf-8"); | |
| 216 | case "/style.css": | |
| 217 | return asset("style.css", "text/css; charset=utf-8"); | |
| 218 | case "/healthz": | |
| 219 | return new Response("ok", { headers: SECURITY }); | |
| 220 | default: | |
| 221 | return new Response("not found", { status: 404, headers: SECURITY }); | |
| 222 | } | |
| 223 | } | |
| 224 | ||
| 225 | if (import.meta.main) { | |
| 226 | Bun.serve({ | |
| 227 | port: Number(process.env.PORT ?? 3000), | |
| 228 | hostname: process.env.HOST ?? "127.0.0.1", | |
| 229 | // Longer than the ping, so a quiet stream is never cut for idleness. | |
| 230 | idleTimeout: 60, | |
| 231 | fetch: handle, | |
| 232 | }); | |
| 233 | } |
examples/forge-live/style.css added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | body { font: 16px/1.5 system-ui, sans-serif; max-width: 42rem; margin: 2rem auto; padding: 0 1rem; color: #222; } | |
| 2 | h1 { font-size: 1.4rem; margin: 0 0 .25rem; } | |
| 3 | p.note { color: #666; margin: 0 0 1.5rem; } | |
| 4 | ul { list-style: none; padding: 0; } | |
| 5 | li { border-top: 1px solid #ddd; padding: .6rem 0; } | |
| 6 | .who { font-weight: 600; } | |
| 7 | .meta { color: #666; font-size: .9rem; } | |
| 8 | a.say { float: right; } | |
| 9 | @media print { a.say { display: none; } } |
forge.json +1 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | "ready": 90 |
| 7 | 7 | }, |
| 8 | 8 | "check": { |
| 9 | "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts test/asks.test.ts test/feed.test.ts test/feed-page.test.ts test/offline.test.ts test/share.test.ts test/cloud.test.ts test/run-secrets.test.ts test/forge-cloudflare.test.ts test/usage.test.ts test/standing.test.ts test/board-windows.test.ts", | |
| 9 | "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/apps-token.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts test/asks.test.ts test/feed.test.ts test/feed-page.test.ts test/offline.test.ts test/share.test.ts test/cloud.test.ts test/run-secrets.test.ts test/forge-cloudflare.test.ts test/usage.test.ts test/standing.test.ts test/board-windows.test.ts", | |
| 10 | 10 | "timeout": 1800 |
| 11 | 11 | } |
| 12 | 12 | } |
src/apps/process.ts +12
| @@ -13,6 +13,7 @@ | ||
| 13 | 13 | import type { AppManifest } from "./manifest.ts"; |
| 14 | 14 | import type { EnvName } from "./data.ts"; |
| 15 | 15 | import { envPull } from "./data.ts"; |
| 16 | import { forgeApiUrl } from "./token.ts"; | |
| 16 | 17 | import { isReservedAppEnvName } from "../core/app_env.ts"; |
| 17 | 18 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 18 | 19 | import { stripForgeDirs } from "../runs/paths.ts"; |
| @@ -40,6 +41,8 @@ export interface StartInput { | ||
| 40 | 41 | readonly ssh?: string | undefined; |
| 41 | 42 | /** The operator's variables for this app (`core/app_env.ts`). */ |
| 42 | 43 | readonly vars?: Record<string, string> | undefined; |
| 44 | /** The process's read-only credential into the forge (`apps/token.ts`). */ | |
| 45 | readonly forgeToken?: string | undefined; | |
| 43 | 46 | readonly log: (line: string) => void; |
| 44 | 47 | /** Aborting cancels whatever step is in flight and fails the start. */ |
| 45 | 48 | readonly signal: AbortSignal; |
| @@ -106,6 +109,8 @@ export function appEnv(input: { | ||
| 106 | 109 | ssh?: string | undefined; |
| 107 | 110 | /** The operator's variables: an admin password, an API key. */ |
| 108 | 111 | vars?: Record<string, string> | undefined; |
| 112 | /** A read-only forge token for this process (`apps/token.ts`). */ | |
| 113 | forgeToken?: string | undefined; | |
| 109 | 114 | }): Record<string, string> { |
| 110 | 115 | const pull = envPull(input.env); |
| 111 | 116 | // The operator's variables go underneath everything the forge names, so |
| @@ -123,7 +128,13 @@ export function appEnv(input: { | ||
| 123 | 128 | FORGE_APP_URL: input.url, |
| 124 | 129 | FORGE_APP_COMMIT: input.commitOid, |
| 125 | 130 | FORGE_URL: input.forgeUrl, |
| 131 | FORGE_API_URL: forgeApiUrl(input.forgeUrl), | |
| 126 | 132 | FORGE_APP_IDENTITY_KEY: input.identityKey, |
| 133 | // Read-only, the owner's, gone when this process is: what a page | |
| 134 | // inside the app reads the forge with. Never sent to a browser. | |
| 135 | ...(input.forgeToken === undefined | |
| 136 | ? {} | |
| 137 | : { FORGE_TOKEN: input.forgeToken }), | |
| 127 | 138 | ...(pull === undefined ? {} : { FORGE_APP_PULL: String(pull) }), |
| 128 | 139 | // Printed verbatim by an app that tells people how to reach its |
| 129 | 140 | // terminal; a guessed form (`[email protected]`) reaches the forge instead. |
| @@ -315,6 +326,7 @@ export async function startProcess( | ||
| 315 | 326 | identityKey: input.identityKey, |
| 316 | 327 | ssh: input.ssh, |
| 317 | 328 | vars: input.vars, |
| 329 | forgeToken: input.forgeToken, | |
| 318 | 330 | }), |
| 319 | 331 | stdin: "ignore", |
| 320 | 332 | stdout: "pipe", |
src/apps/proxy.ts +11 −3
| @@ -485,7 +485,7 @@ export async function proxyApp( | ||
| 485 | 485 | * busy: held from its first byte until it ends, fails, or the browser |
| 486 | 486 | * goes away. |
| 487 | 487 | */ |
| 488 | function held( | |
| 488 | export function held( | |
| 489 | 489 | runtime: AppsRuntime, |
| 490 | 490 | target: AppTarget, |
| 491 | 491 | body: ReadableStream<Uint8Array>, |
| @@ -500,9 +500,17 @@ function held( | ||
| 500 | 500 | release(); |
| 501 | 501 | controller.close(); |
| 502 | 502 | } else controller.enqueue(value); |
| 503 | } catch (error) { | |
| 503 | } catch { | |
| 504 | // The app went away under it — replaced by a deploy, asleep, | |
| 505 | // stopped. For an event stream that is its end, not an error to | |
| 506 | // raise into a connection nobody may be reading: the browser's | |
| 507 | // EventSource reconnects to whatever serves now. | |
| 504 | 508 | release(); |
| 505 | controller.error(error); | |
| 509 | try { | |
| 510 | controller.close(); | |
| 511 | } catch { | |
| 512 | // already closed | |
| 513 | } | |
| 506 | 514 | } |
| 507 | 515 | }, |
| 508 | 516 | cancel(reason) { |
src/apps/runtime.ts +45 −3
| @@ -20,6 +20,7 @@ import { join } from "node:path"; | ||
| 20 | 20 | import type { App } from "../app.ts"; |
| 21 | 21 | import { getAppByRepo, listApps, type AppRecord } from "../core/apps.ts"; |
| 22 | 22 | import { resolveAppEnv } from "../core/app_env.ts"; |
| 23 | import { mintAppToken, renewAppToken, revokeAppToken } from "./token.ts"; | |
| 23 | 24 | import type { ForgeEvent } from "../core/events.ts"; |
| 24 | 25 | import { announceRun } from "../actions/runs.ts"; |
| 25 | 26 | import { getPull, listPulls, type Pull } from "../core/pulls.ts"; |
| @@ -197,6 +198,8 @@ interface Live { | ||
| 197 | 198 | readonly createdAt?: string | undefined; |
| 198 | 199 | /** The deploy run's own start — the reference the deploy log measures from. */ |
| 199 | 200 | readonly deployStartedAt?: string | null | undefined; |
| 201 | /** This process's forge token (`apps/token.ts`), revoked when it stops. */ | |
| 202 | readonly tokenId?: string | undefined; | |
| 200 | 203 | } |
| 201 | 204 | |
| 202 | 205 | /** |
| @@ -516,7 +519,10 @@ export class AppsRuntime { | ||
| 516 | 519 | state.deploying?.abort.abort(); |
| 517 | 520 | if (state.queued) |
| 518 | 521 | cancelRun(this.app.db, state.queued.id, STOPPED_SUMMARY); |
| 519 | if (state.live) stops.push(state.live.handle.stop()); | |
| 522 | if (state.live) { | |
| 523 | revokeAppToken(this.app.db, state.live.tokenId); | |
| 524 | stops.push(state.live.handle.stop()); | |
| 525 | } | |
| 520 | 526 | // Abort only cancels the step in flight; the `execute()` continuation |
| 521 | 527 | // chained onto `state.chain` still runs afterwards to record the |
| 522 | 528 | // outcome (`finishRun`, `announceRun`). Wait for it too, or it can |
| @@ -880,6 +886,7 @@ export class AppsRuntime { | ||
| 880 | 886 | write( |
| 881 | 887 | `stopping the previous deployment (${previous.commitOid.slice(0, 7)})`, |
| 882 | 888 | ); |
| 889 | revokeAppToken(this.app.db, previous.tokenId); | |
| 883 | 890 | await previous.handle.stop(); |
| 884 | 891 | if (previous.dir !== live.dir) |
| 885 | 892 | rmSync(previous.dir, { recursive: true, force: true }); |
| @@ -992,6 +999,14 @@ export class AppsRuntime { | ||
| 992 | 999 | else if (seeded === "empty" && state.env !== "production") |
| 993 | 1000 | write("data: starting empty"); |
| 994 | 1001 | |
| 1002 | // The process's own credential; every way out of here without a | |
| 1003 | // process to hold it revokes it. | |
| 1004 | const token = mintAppToken( | |
| 1005 | this.app.db, | |
| 1006 | repo.ownerId, | |
| 1007 | state.app.slug, | |
| 1008 | state.env, | |
| 1009 | ); | |
| 995 | 1010 | const startInput: Omit<StartInput, "signal"> = { |
| 996 | 1011 | dir, |
| 997 | 1012 | dataDir, |
| @@ -1010,10 +1025,19 @@ export class AppsRuntime { | ||
| 1010 | 1025 | state.app.id, |
| 1011 | 1026 | state.env, |
| 1012 | 1027 | ), |
| 1028 | forgeToken: token.token, | |
| 1013 | 1029 | log: write, |
| 1014 | 1030 | warm, |
| 1015 | 1031 | }; |
| 1016 | let started = await startProcess({ ...startInput, signal }); | |
| 1032 | const start = async (): ReturnType<typeof startProcess> => { | |
| 1033 | try { | |
| 1034 | return await startProcess({ ...startInput, signal }); | |
| 1035 | } catch (error) { | |
| 1036 | revokeAppToken(this.app.db, token.id); | |
| 1037 | throw error; | |
| 1038 | } | |
| 1039 | }; | |
| 1040 | let started = await start(); | |
| 1017 | 1041 | |
| 1018 | 1042 | // The one retry this deploy gets: a preview seeded from production whose |
| 1019 | 1043 | // process died before it ever answered — schema ahead of what this build |
| @@ -1030,8 +1054,9 @@ export class AppsRuntime { | ||
| 1030 | 1054 | "the copied data was not usable by this build; the preview is starting on empty data", |
| 1031 | 1055 | ); |
| 1032 | 1056 | reseedEmpty(this.app.config.dataDir, state.app.id, state.env); |
| 1033 | started = await startProcess({ ...startInput, signal }); | |
| 1057 | started = await start(); | |
| 1034 | 1058 | if (!started.ok) { |
| 1059 | revokeAppToken(this.app.db, token.id); | |
| 1035 | 1060 | rmSync(dir, { recursive: true, force: true }); |
| 1036 | 1061 | return { |
| 1037 | 1062 | ok: false, |
| @@ -1039,6 +1064,7 @@ export class AppsRuntime { | ||
| 1039 | 1064 | }; |
| 1040 | 1065 | } |
| 1041 | 1066 | } else if (!started.ok) { |
| 1067 | revokeAppToken(this.app.db, token.id); | |
| 1042 | 1068 | rmSync(dir, { recursive: true, force: true }); |
| 1043 | 1069 | return { |
| 1044 | 1070 | ok: false, |
| @@ -1054,6 +1080,7 @@ export class AppsRuntime { | ||
| 1054 | 1080 | runId: run.id, |
| 1055 | 1081 | since: new Date().toISOString(), |
| 1056 | 1082 | manifest: manifest.value, |
| 1083 | tokenId: token.id, | |
| 1057 | 1084 | }, |
| 1058 | 1085 | tested: started.value.tested, |
| 1059 | 1086 | }; |
| @@ -1081,6 +1108,7 @@ export class AppsRuntime { | ||
| 1081 | 1108 | .then((code) => { |
| 1082 | 1109 | if (this.stopped || state.live !== live) return; // replaced or stopped on purpose |
| 1083 | 1110 | state.live = undefined; |
| 1111 | revokeAppToken(this.app.db, live.tokenId); | |
| 1084 | 1112 | state.lastError = `exited with code ${code}`; |
| 1085 | 1113 | appendRunLog( |
| 1086 | 1114 | this.app.config.dataDir, |
| @@ -1162,6 +1190,7 @@ export class AppsRuntime { | ||
| 1162 | 1190 | appendRunLog(this.app.config.dataDir, asleep.runId, line); |
| 1163 | 1191 | }; |
| 1164 | 1192 | write("waking: a request came"); |
| 1193 | const token = mintAppToken(this.app.db, repo.ownerId, record.slug, env); | |
| 1165 | 1194 | const started = await startProcess({ |
| 1166 | 1195 | dir: asleep.dir, |
| 1167 | 1196 | dataDir: envDataDir(this.app.config.dataDir, record.id, env), |
| @@ -1178,10 +1207,16 @@ export class AppsRuntime { | ||
| 1178 | 1207 | record.id, |
| 1179 | 1208 | env, |
| 1180 | 1209 | ), |
| 1210 | forgeToken: token.token, | |
| 1181 | 1211 | log: write, |
| 1182 | 1212 | signal: new AbortController().signal, |
| 1183 | 1213 | warm: true, |
| 1214 | }).catch((error: unknown) => { | |
| 1215 | revokeAppToken(this.app.db, token.id); | |
| 1216 | throw error; | |
| 1184 | 1217 | }); |
| 1218 | if (state.asleep !== asleep || !started.ok) | |
| 1219 | revokeAppToken(this.app.db, token.id); | |
| 1185 | 1220 | if (state.asleep !== asleep) { |
| 1186 | 1221 | // Replaced while waking — a deploy landed. What we started is spare. |
| 1187 | 1222 | if (started.ok) await started.value.handle.stop(); |
| @@ -1202,6 +1237,7 @@ export class AppsRuntime { | ||
| 1202 | 1237 | since: now, |
| 1203 | 1238 | manifest: asleep.manifest, |
| 1204 | 1239 | servingAt: now, |
| 1240 | tokenId: token.id, | |
| 1205 | 1241 | }; |
| 1206 | 1242 | state.asleep = undefined; |
| 1207 | 1243 | state.live = live; |
| @@ -1272,6 +1308,9 @@ export class AppsRuntime { | ||
| 1272 | 1308 | const now = Date.now(); |
| 1273 | 1309 | for (const state of this.envs.values()) { |
| 1274 | 1310 | const live = state.live; |
| 1311 | // Still running: its credential is still wanted for another window. | |
| 1312 | if (live?.tokenId !== undefined) | |
| 1313 | renewAppToken(this.app.db, live.tokenId, now); | |
| 1275 | 1314 | const spared = state.keptAwake; |
| 1276 | 1315 | state.keptAwake = undefined; |
| 1277 | 1316 | if ( |
| @@ -1316,6 +1355,7 @@ export class AppsRuntime { | ||
| 1316 | 1355 | : `asleep: still busy ${Math.round(grace / 60_000)} minutes past its idle window, which is as long as busy keeps an app awake; the next request wakes it`, |
| 1317 | 1356 | ); |
| 1318 | 1357 | log.info("asleep", { app: state.app.slug, env: state.env }); |
| 1358 | revokeAppToken(this.app.db, live.tokenId); | |
| 1319 | 1359 | await live.handle.stop(); |
| 1320 | 1360 | } |
| 1321 | 1361 | } |
| @@ -1337,6 +1377,7 @@ export class AppsRuntime { | ||
| 1337 | 1377 | // after its pull closed, checkout, cloned data and all. |
| 1338 | 1378 | const live = state.live; |
| 1339 | 1379 | state.live = undefined; |
| 1380 | revokeAppToken(this.app.db, live?.tokenId); | |
| 1340 | 1381 | if (live) await live.handle.stop(); |
| 1341 | 1382 | } |
| 1342 | 1383 | removeEnv(this.app.config.dataDir, record.id, env); |
| @@ -1361,6 +1402,7 @@ export class AppsRuntime { | ||
| 1361 | 1402 | const live = state.live; |
| 1362 | 1403 | state.live = undefined; |
| 1363 | 1404 | state.asleep = undefined; |
| 1405 | revokeAppToken(this.app.db, live?.tokenId); | |
| 1364 | 1406 | if (live) await live.handle.stop(); |
| 1365 | 1407 | } |
| 1366 | 1408 | } |
src/apps/token.ts added +63
| @@ -0,0 +1,63 @@ | ||
| 1 | /** | |
| 2 | * An environment's credential into the forge: what lets a page an agent | |
| 3 | * built inside an app read the forge it runs on — `now`, the map, `/events`. | |
| 4 | * | |
| 5 | * One `ro` token per running process, held by the repository's owner, so | |
| 6 | * the app reads exactly what its owner may read and can change nothing. It | |
| 7 | * is minted when the process starts, revoked when that process stops for | |
| 8 | * any reason (replaced by a deploy, asleep, crashed, stopped, torn down), | |
| 9 | * and kept short-lived while it runs: `renewAppToken` slides the expiry on | |
| 10 | * every sweep, so a forge that dies without revoking it leaves a token that | |
| 11 | * stops working by itself within `APP_TOKEN_TTL_MS`. | |
| 12 | * | |
| 13 | * Read-only on purpose. An app's pages are served to whoever its `access` | |
| 14 | * lets in; a token that could act would let any of those visitors act as | |
| 15 | * the owner. Saying something to the crew stays the person's own act, on | |
| 16 | * the forge, under their own session. | |
| 17 | */ | |
| 18 | ||
| 19 | import type { Database } from "bun:sqlite"; | |
| 20 | import { createToken, renewToken, revokeToken } from "../core/tokens.ts"; | |
| 21 | ||
| 22 | /** How long an app token outlives the last sweep that saw its process. */ | |
| 23 | export const APP_TOKEN_TTL_MS = 24 * 60 * 60_000; | |
| 24 | ||
| 25 | export interface AppToken { | |
| 26 | readonly id: string; | |
| 27 | readonly token: string; | |
| 28 | } | |
| 29 | ||
| 30 | /** The name a person sees for it among their tokens: which app, which env. */ | |
| 31 | export const appTokenName = (slug: string, env: string): string => | |
| 32 | `app:${slug}/${env}`; | |
| 33 | ||
| 34 | const until = (now: number): string => | |
| 35 | new Date(now + APP_TOKEN_TTL_MS).toISOString(); | |
| 36 | ||
| 37 | export function mintAppToken( | |
| 38 | db: Database, | |
| 39 | ownerId: string, | |
| 40 | slug: string, | |
| 41 | env: string, | |
| 42 | now: number = Date.now(), | |
| 43 | ): AppToken { | |
| 44 | return createToken(db, ownerId, appTokenName(slug, env), "ro", { | |
| 45 | expiresAt: until(now), | |
| 46 | }); | |
| 47 | } | |
| 48 | ||
| 49 | export function renewAppToken( | |
| 50 | db: Database, | |
| 51 | tokenId: string, | |
| 52 | now: number = Date.now(), | |
| 53 | ): void { | |
| 54 | renewToken(db, tokenId, until(now)); | |
| 55 | } | |
| 56 | ||
| 57 | export function revokeAppToken(db: Database, tokenId: string | undefined): void { | |
| 58 | if (tokenId !== undefined) revokeToken(db, tokenId); | |
| 59 | } | |
| 60 | ||
| 61 | /** `FORGE_API_URL`: the forge's JSON API, from its public URL. */ | |
| 62 | export const forgeApiUrl = (forgeUrl: string): string => | |
| 63 | `${forgeUrl.replace(/\/+$/, "")}/api/v1`; |
src/core/tokens.ts +29
| @@ -145,3 +145,32 @@ export function deleteToken( | ||
| 145 | 145 | .run(tokenId, userId).changes > 0 |
| 146 | 146 | ); |
| 147 | 147 | } |
| 148 | ||
| 149 | /** | |
| 150 | * Remove one token by its id, whoever holds it. For credentials the forge | |
| 151 | * minted on someone's behalf (an app's, a run's) and so revokes itself; | |
| 152 | * a person revoking their own goes through `deleteToken`. | |
| 153 | */ | |
| 154 | export function revokeToken(db: Database, tokenId: string): boolean { | |
| 155 | return db.query("DELETE FROM token WHERE id = ?1").run(tokenId).changes > 0; | |
| 156 | } | |
| 157 | ||
| 158 | /** | |
| 159 | * Move an expiring token's expiry. A credential held by something that | |
| 160 | * lives as long as it is wanted — a running app — is kept short and | |
| 161 | * renewed while it is, so a forge that dies without revoking it leaves a | |
| 162 | * token that stops working on its own. A token with no expiry is left be. | |
| 163 | */ | |
| 164 | export function renewToken( | |
| 165 | db: Database, | |
| 166 | tokenId: string, | |
| 167 | expiresAt: string, | |
| 168 | ): boolean { | |
| 169 | return ( | |
| 170 | db | |
| 171 | .query( | |
| 172 | "UPDATE token SET expires_at = ?1 WHERE id = ?2 AND expires_at IS NOT NULL", | |
| 173 | ) | |
| 174 | .run(expiresAt, tokenId).changes > 0 | |
| 175 | ); | |
| 176 | } |
test/apps-token.test.ts added +399
| @@ -0,0 +1,399 @@ | ||
| 1 | /** | |
| 2 | * An app's credential into the forge (issue #388): every app and preview | |
| 3 | * process starts with `FORGE_API_URL` and a read-only token of its owner's, | |
| 4 | * minted at the start, renewed while it runs, revoked when it stops; the | |
| 5 | * token reads `/events`; and the sample page in `examples/forge-live`, | |
| 6 | * deployed as an app, lists `now` live and offers `say`. | |
| 7 | */ | |
| 8 | ||
| 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 10 | import { readFileSync, readdirSync } from "node:fs"; | |
| 11 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 12 | import { tmpdir } from "node:os"; | |
| 13 | import { join } from "node:path"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 16 | import { appEnv } from "../src/apps/process.ts"; | |
| 17 | import { held, type AppTarget } from "../src/apps/proxy.ts"; | |
| 18 | import { | |
| 19 | APP_TOKEN_TTL_MS, | |
| 20 | appTokenName, | |
| 21 | forgeApiUrl, | |
| 22 | mintAppToken, | |
| 23 | renewAppToken, | |
| 24 | revokeAppToken, | |
| 25 | } from "../src/apps/token.ts"; | |
| 26 | import { scaffoldFiles } from "../src/apps/scaffold.ts"; | |
| 27 | import { newApp } from "../src/actions/apps.ts"; | |
| 28 | import { openIssue } from "../src/actions/issues.ts"; | |
| 29 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 30 | import type { AppRecord } from "../src/core/apps.ts"; | |
| 31 | import type { Repo } from "../src/core/repos.ts"; | |
| 32 | import { createRun, listRuns, type Run } from "../src/core/runs.ts"; | |
| 33 | import { | |
| 34 | createToken, | |
| 35 | listTokens, | |
| 36 | renewToken, | |
| 37 | verifyToken, | |
| 38 | type TokenInfo, | |
| 39 | } from "../src/core/tokens.ts"; | |
| 40 | import { createUser, type User } from "../src/core/users.ts"; | |
| 41 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 42 | ||
| 43 | const EXAMPLE = join(import.meta.dir, "..", "examples", "forge-live"); | |
| 44 | ||
| 45 | async function user(app: App, username: string): Promise<User> { | |
| 46 | const made = await createUser(app.db, { | |
| 47 | username, | |
| 48 | email: `${username}@forge.local`, | |
| 49 | password: "a-good-passphrase", | |
| 50 | adminIfFirst: true, | |
| 51 | }); | |
| 52 | if (!made.ok) throw new Error(made.error); | |
| 53 | return made.value; | |
| 54 | } | |
| 55 | ||
| 56 | /** Read an event stream until `seen` says yes, or fail after `ms`. */ | |
| 57 | async function readUntil( | |
| 58 | response: Response, | |
| 59 | seen: (text: string) => boolean, | |
| 60 | ms = 10_000, | |
| 61 | ): Promise<string> { | |
| 62 | const reader = response.body!.getReader(); | |
| 63 | const decoder = new TextDecoder(); | |
| 64 | let text = ""; | |
| 65 | const deadline = Date.now() + ms; | |
| 66 | try { | |
| 67 | while (Date.now() < deadline) { | |
| 68 | const next = await Promise.race([ | |
| 69 | reader.read(), | |
| 70 | Bun.sleep(deadline - Date.now()).then(() => undefined), | |
| 71 | ]); | |
| 72 | if (next === undefined || next.done) break; | |
| 73 | text += decoder.decode(next.value, { stream: true }); | |
| 74 | if (seen(text)) return text; | |
| 75 | } | |
| 76 | } finally { | |
| 77 | await reader.cancel().catch(() => {}); | |
| 78 | } | |
| 79 | throw new Error(`stream did not say it in time; heard:\n${text}`); | |
| 80 | } | |
| 81 | ||
| 82 | describe("app tokens: the record", () => { | |
| 83 | let dataDir: string; | |
| 84 | let app: App; | |
| 85 | let alice: User; | |
| 86 | ||
| 87 | beforeAll(async () => { | |
| 88 | dataDir = await mkdtemp(join(tmpdir(), "forge-apptoken-unit-")); | |
| 89 | app = createApp({ dataDir, port: 0 }); | |
| 90 | alice = await user(app, "alice"); | |
| 91 | }); | |
| 92 | afterAll(async () => { | |
| 93 | app.close(); | |
| 94 | await rm(dataDir, { recursive: true, force: true }); | |
| 95 | }); | |
| 96 | ||
| 97 | test("minted read-only, the owner's, named for the app, expiring a day out", () => { | |
| 98 | const now = Date.parse("2026-10-02T12:00:00Z"); | |
| 99 | const minted = mintAppToken(app.db, alice.id, "notes", "pull-3", now); | |
| 100 | const info = listTokens(app.db, alice.id).find((t) => t.id === minted.id)!; | |
| 101 | expect(info.name).toBe(appTokenName("notes", "pull-3")); | |
| 102 | expect(info.name).toBe("app:notes/pull-3"); | |
| 103 | expect(info.scope).toBe("ro"); | |
| 104 | expect(Date.parse(info.expiresAt!)).toBe(now + APP_TOKEN_TTL_MS); | |
| 105 | // The expiry is the real clock's here, so the token works now. | |
| 106 | const fresh = mintAppToken(app.db, alice.id, "notes", "production"); | |
| 107 | const verified = verifyToken(app.db, fresh.token); | |
| 108 | expect(verified.ok && verified.value.scope).toBe("ro"); | |
| 109 | expect(verified.ok && verified.value.user.id).toBe(alice.id); | |
| 110 | }); | |
| 111 | ||
| 112 | test("renewing slides the expiry; revoking ends it; a token with no expiry is never given one", () => { | |
| 113 | const minted = mintAppToken(app.db, alice.id, "notes", "production", 0); | |
| 114 | // Minted at the epoch: already expired, refused. | |
| 115 | expect(verifyToken(app.db, minted.token).ok).toBe(false); | |
| 116 | renewAppToken(app.db, minted.id); | |
| 117 | expect(verifyToken(app.db, minted.token).ok).toBe(true); | |
| 118 | revokeAppToken(app.db, minted.id); | |
| 119 | expect(verifyToken(app.db, minted.token).ok).toBe(false); | |
| 120 | revokeAppToken(app.db, undefined); // nothing to revoke is not an error | |
| 121 | ||
| 122 | const personal = createToken(app.db, alice.id, "laptop", "rw"); | |
| 123 | expect(renewToken(app.db, personal.id, new Date().toISOString())).toBe( | |
| 124 | false, | |
| 125 | ); | |
| 126 | const info = listTokens(app.db, alice.id).find( | |
| 127 | (t) => t.id === personal.id, | |
| 128 | )!; | |
| 129 | expect(info.expiresAt).toBeNull(); | |
| 130 | }); | |
| 131 | ||
| 132 | test("the process is told the API's URL and its token; no token, no variable", () => { | |
| 133 | const base = { | |
| 134 | port: 4100, | |
| 135 | dataDir: "/data/x", | |
| 136 | env: "production" as const, | |
| 137 | url: "http://notes.apps.test", | |
| 138 | commitOid: "a".repeat(40), | |
| 139 | forgeUrl: "https://forge.test/", | |
| 140 | identityKey: "k", | |
| 141 | }; | |
| 142 | const told = appEnv({ ...base, forgeToken: "forge_secret" }); | |
| 143 | expect(told["FORGE_API_URL"]).toBe("https://forge.test/api/v1"); | |
| 144 | expect(told["FORGE_TOKEN"]).toBe("forge_secret"); | |
| 145 | expect(forgeApiUrl("http://localhost:3000")).toBe( | |
| 146 | "http://localhost:3000/api/v1", | |
| 147 | ); | |
| 148 | const untold = appEnv(base); | |
| 149 | expect(untold["FORGE_TOKEN"]).toBeUndefined(); | |
| 150 | expect(untold["FORGE_API_URL"]).toBe("https://forge.test/api/v1"); | |
| 151 | // An operator's variable cannot stand in for the forge's own. | |
| 152 | const shadowed = appEnv({ | |
| 153 | ...base, | |
| 154 | forgeToken: "forge_real", | |
| 155 | vars: { FORGE_TOKEN: "forge_fake", FORGE_API_URL: "https://evil" }, | |
| 156 | }); | |
| 157 | expect(shadowed["FORGE_TOKEN"]).toBe("forge_real"); | |
| 158 | expect(shadowed["FORGE_API_URL"]).toBe("https://forge.test/api/v1"); | |
| 159 | }); | |
| 160 | }); | |
| 161 | ||
| 162 | describe("a proxied event stream whose app goes away", () => { | |
| 163 | test("ends, released, rather than erroring into the browser's connection", async () => { | |
| 164 | let released = 0; | |
| 165 | const runtime = { | |
| 166 | holdStream: () => () => void released++, | |
| 167 | } as unknown as AppsRuntime; | |
| 168 | const upstream = new ReadableStream<Uint8Array>({ | |
| 169 | pull(controller) { | |
| 170 | controller.error(new Error("ECONNRESET")); | |
| 171 | }, | |
| 172 | }); | |
| 173 | const reader = held(runtime, {} as AppTarget, upstream).getReader(); | |
| 174 | expect((await reader.read()).done).toBe(true); | |
| 175 | expect(released).toBe(1); | |
| 176 | }); | |
| 177 | }); | |
| 178 | ||
| 179 | describe("app tokens: live, end to end", () => { | |
| 180 | let dataDir: string; | |
| 181 | let app: App; | |
| 182 | let servers: Servers; | |
| 183 | let runtime: AppsRuntime; | |
| 184 | let alice: User; | |
| 185 | let bob: User; | |
| 186 | let repo: Repo; | |
| 187 | let record: AppRecord; | |
| 188 | const DOMAIN = "apps.test"; | |
| 189 | const HOST = `live.${DOMAIN}`; | |
| 190 | ||
| 191 | const viaHost = (path = "/", init: RequestInit = {}) => | |
| 192 | fetch(`${servers.url}${path}`, { | |
| 193 | ...init, | |
| 194 | headers: { ...(init.headers as Record<string, string>), host: HOST }, | |
| 195 | redirect: "manual", | |
| 196 | }); | |
| 197 | ||
| 198 | async function settled(where: (run: Run) => boolean): Promise<Run> { | |
| 199 | const deadline = Date.now() + 90_000; | |
| 200 | while (Date.now() < deadline) { | |
| 201 | const run = listRuns(app.db, repo.id, { kind: "deploy", limit: 50 }).find( | |
| 202 | (r) => where(r) && (r.state === "ok" || r.state === "failed"), | |
| 203 | ); | |
| 204 | if (run) return run; | |
| 205 | await Bun.sleep(100); | |
| 206 | } | |
| 207 | throw new Error("run did not settle in time"); | |
| 208 | } | |
| 209 | ||
| 210 | const appTokens = (env: string): TokenInfo[] => | |
| 211 | listTokens(app.db, alice.id).filter( | |
| 212 | (t) => t.name === appTokenName("live", env), | |
| 213 | ); | |
| 214 | ||
| 215 | /** The sample page's files, as the commit that makes this app it. */ | |
| 216 | const exampleFiles = (stamp: string) => | |
| 217 | readdirSync(EXAMPLE).map((name) => ({ | |
| 218 | path: name, | |
| 219 | content: | |
| 220 | name === "README.md" | |
| 221 | ? `${readFileSync(join(EXAMPLE, name), "utf8")}\n<!-- ${stamp} -->\n` | |
| 222 | : readFileSync(join(EXAMPLE, name), "utf8"), | |
| 223 | })); | |
| 224 | ||
| 225 | beforeAll(async () => { | |
| 226 | dataDir = await mkdtemp(join(tmpdir(), "forge-apptoken-")); | |
| 227 | app = createApp({ dataDir, port: 0 }); | |
| 228 | servers = await serve(app); | |
| 229 | runtime = new AppsRuntime(app, { | |
| 230 | domain: DOMAIN, | |
| 231 | scheme: "http", | |
| 232 | publicPort: servers.port, | |
| 233 | forgeUrl: servers.url, | |
| 234 | sweepEveryMs: 200, | |
| 235 | }); | |
| 236 | await runtime.start(); | |
| 237 | alice = await user(app, "alice"); | |
| 238 | bob = await user(app, "bob"); | |
| 239 | const made = await newApp(app, alice, { | |
| 240 | name: "live", | |
| 241 | description: "Now, live", | |
| 242 | visibility: "public", | |
| 243 | }); | |
| 244 | if (!made.ok) throw new Error(String(made.error)); | |
| 245 | repo = made.value.repo; | |
| 246 | record = made.value.app; | |
| 247 | await settled((r) => r.env === "production"); | |
| 248 | // Make it the sample page: the scaffold out, the example in. | |
| 249 | const scaffold = scaffoldFiles({ name: "live", description: "Now, live" }); | |
| 250 | const ours = new Set(readdirSync(EXAMPLE)); | |
| 251 | const committed = await commitToBranch(app, alice, repo, { | |
| 252 | message: "The forge, live", | |
| 253 | files: [ | |
| 254 | ...scaffold | |
| 255 | .filter((f) => !ours.has(f.path)) | |
| 256 | .map((f) => ({ path: f.path, content: null })), | |
| 257 | ...exampleFiles("first"), | |
| 258 | ], | |
| 259 | }); | |
| 260 | if (!committed.ok) throw new Error(JSON.stringify(committed.error)); | |
| 261 | const run = await settled( | |
| 262 | (r) => | |
| 263 | r.env === "production" && r.commitOid === committed.value.commitOid, | |
| 264 | ); | |
| 265 | expect(run.state).toBe("ok"); | |
| 266 | }, 180_000); | |
| 267 | ||
| 268 | afterAll(async () => { | |
| 269 | await runtime.stop(); | |
| 270 | servers.stop(); | |
| 271 | app.close(); | |
| 272 | await rm(dataDir, { recursive: true, force: true }); | |
| 273 | }); | |
| 274 | ||
| 275 | test("a running environment holds exactly one token: its owner's, read-only, expiring", () => { | |
| 276 | const held = appTokens("production"); | |
| 277 | expect(held).toHaveLength(1); | |
| 278 | expect(held[0]!.scope).toBe("ro"); | |
| 279 | expect(held[0]!.expiresAt).not.toBeNull(); | |
| 280 | // The previous deploy's process is gone, and its token with it. | |
| 281 | expect( | |
| 282 | listTokens(app.db, alice.id).filter((t) => t.name.startsWith("app:")), | |
| 283 | ).toHaveLength(1); | |
| 284 | }); | |
| 285 | ||
| 286 | test("/events streams with a Bearer token, and only what its holder may read", async () => { | |
| 287 | const secret = await createRepo(app, alice, { | |
| 288 | name: "secret", | |
| 289 | visibility: "private", | |
| 290 | }); | |
| 291 | if (!secret.ok) throw new Error(String(secret.error)); | |
| 292 | const aliceRo = createToken(app.db, alice.id, "ro", "ro").token; | |
| 293 | const bobRo = createToken(app.db, bob.id, "ro", "ro").token; | |
| 294 | const listen = (token: string) => | |
| 295 | fetch(`${servers.url}/events`, { | |
| 296 | headers: { authorization: `Bearer ${token}` }, | |
| 297 | }); | |
| 298 | const [hers, his] = await Promise.all([listen(aliceRo), listen(bobRo)]); | |
| 299 | expect(hers.status).toBe(200); | |
| 300 | expect(hers.headers.get("content-type")).toContain("text/event-stream"); | |
| 301 | const opened = openIssue(app, alice, secret.value, { | |
| 302 | title: "the private plan", | |
| 303 | }); | |
| 304 | expect(opened.ok).toBe(true); | |
| 305 | const heard = await readUntil(hers, (t) => t.includes("alice/secret")); | |
| 306 | expect(heard).toContain("event: forge"); | |
| 307 | // Bob cannot read alice/secret: his stream stays quiet about it. | |
| 308 | await expect( | |
| 309 | readUntil(his, (t) => t.includes("alice/secret"), 1_000), | |
| 310 | ).rejects.toThrow("did not say it"); | |
| 311 | }); | |
| 312 | ||
| 313 | test("acceptance: the sample page lists `now`, offers `say`, and goes live on /events", async () => { | |
| 314 | const issue = openIssue(app, alice, repo, { title: "Make it <b>bold</b>" }); | |
| 315 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 316 | createRun(app.db, { | |
| 317 | repoId: repo.id, | |
| 318 | kind: "agent", | |
| 319 | issueId: issue.value.id, | |
| 320 | actorId: bob.id, | |
| 321 | reason: "assigned", | |
| 322 | }); | |
| 323 | ||
| 324 | const page = await viaHost("/"); | |
| 325 | expect(page.status).toBe(200); | |
| 326 | expect(page.headers.get("content-security-policy")).toContain( | |
| 327 | "default-src 'none'", | |
| 328 | ); | |
| 329 | const html = await page.text(); | |
| 330 | expect(html).toContain('<ul id="now">'); | |
| 331 | expect(html).toContain( | |
| 332 | `#${issue.value.number} Make it <b>bold</b>`, | |
| 333 | ); | |
| 334 | expect(html).not.toContain("<b>bold</b>"); | |
| 335 | expect(html).toContain('<span class="who">bob</span>'); | |
| 336 | // `say` goes to the forge, where the person speaks as themselves. | |
| 337 | expect(html).toContain( | |
| 338 | `<a class="say" href="${servers.url}/alice/live/issues/${issue.value.number}#say">say</a>`, | |
| 339 | ); | |
| 340 | // The token never reaches the browser. | |
| 341 | const held = appTokens("production")[0]!; | |
| 342 | expect(html).not.toContain("forge_"); | |
| 343 | expect(held).toBeDefined(); | |
| 344 | ||
| 345 | // Live: the page's stream says "changed" when the forge says anything. | |
| 346 | const stream = await viaHost("/live", { | |
| 347 | headers: { accept: "text/event-stream" }, | |
| 348 | }); | |
| 349 | expect(stream.status).toBe(200); | |
| 350 | const said = readUntil(stream, (t) => t.includes("event: changed")); | |
| 351 | await Bun.sleep(300); // let the relay open its upstream | |
| 352 | openIssue(app, alice, repo, { title: "Another thing" }); | |
| 353 | expect(await said).toContain("data: {}"); | |
| 354 | // And the fragment the page refetches lists the same runs. | |
| 355 | expect(await (await viaHost("/now")).text()).toContain("bob"); | |
| 356 | }, 30_000); | |
| 357 | ||
| 358 | test("the sweep renews a running environment's token", async () => { | |
| 359 | const before = appTokens("production")[0]!; | |
| 360 | await Bun.sleep(1_100); | |
| 361 | const after = appTokens("production")[0]!; | |
| 362 | expect(after.id).toBe(before.id); | |
| 363 | expect(Date.parse(after.expiresAt!)).toBeGreaterThan( | |
| 364 | Date.parse(before.expiresAt!), | |
| 365 | ); | |
| 366 | }); | |
| 367 | ||
| 368 | test("a redeploy rotates the token; stopping the environment deletes it", async () => { | |
| 369 | const before = appTokens("production")[0]!; | |
| 370 | // A page left open across the deploy: its stream through the proxy | |
| 371 | // ends cleanly when the old process goes, for EventSource to reconnect. | |
| 372 | const open = await viaHost("/live", { | |
| 373 | headers: { accept: "text/event-stream" }, | |
| 374 | }); | |
| 375 | const ended = (async () => { | |
| 376 | const reader = open.body!.getReader(); | |
| 377 | for (;;) if ((await reader.read()).done) return "ended"; | |
| 378 | })(); | |
| 379 | const committed = await commitToBranch(app, alice, repo, { | |
| 380 | message: "Again", | |
| 381 | files: exampleFiles("second"), | |
| 382 | }); | |
| 383 | if (!committed.ok) throw new Error(JSON.stringify(committed.error)); | |
| 384 | const run = await settled( | |
| 385 | (r) => | |
| 386 | r.env === "production" && r.commitOid === committed.value.commitOid, | |
| 387 | ); | |
| 388 | expect(run.state).toBe("ok"); | |
| 389 | expect(await ended).toBe("ended"); | |
| 390 | const after = appTokens("production"); | |
| 391 | expect(after).toHaveLength(1); | |
| 392 | expect(after[0]!.id).not.toBe(before.id); | |
| 393 | // The new process still reads the forge with it. | |
| 394 | expect(await (await viaHost("/now")).text()).toContain("bob"); | |
| 395 | ||
| 396 | await runtime.stopEnv(record, "production"); | |
| 397 | expect(appTokens("production")).toHaveLength(0); | |
| 398 | }, 120_000); | |
| 399 | }); |
tsconfig.json +1 −1
| @@ -16,5 +16,5 @@ | ||
| 16 | 16 | "skipLibCheck": true, |
| 17 | 17 | "noEmit": true |
| 18 | 18 | }, |
| 19 | "include": ["src", "test", "scripts"] | |
| 19 | "include": ["src", "test", "scripts", "examples"] | |
| 20 | 20 | } |
new above
offline