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

Merged opened this in answer to

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

Merged

torque/forge:torque/388-apps-and-previews-receive-a-scoped-forge → main 1 commit fork

  • Merged. torque/388-apps-and-previews-receive-a-scoped-forge was merged into main as 654e6d1.

Review

Sign in to review this change.
15 files changed +892 −9
docs/APPS.md +27 −1
@@ -181,6 +181,8 @@ The forge starts the app with:
181181 | `FORGE_APP_PULL` | the pull number, on a preview |
182182 | `FORGE_APP_SSH` | the exact `ssh …` that opens this environment's terminal, when SSH is on |
183183 | `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 |
184186
185187 That last one is for the app to call the forge back on, not to run the forge
186188 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
188190 `README.md`'s "Run it".
189191
190192 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.
192218
193219 An app that keeps its state under `FORGE_APP_DATA` gets production and
194220 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 ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#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 @@
66 "ready": 90
77 },
88 "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",
1010 "timeout": 1800
1111 }
1212 }
src/apps/process.ts +12
@@ -13,6 +13,7 @@
1313 import type { AppManifest } from "./manifest.ts";
1414 import type { EnvName } from "./data.ts";
1515 import { envPull } from "./data.ts";
16 import { forgeApiUrl } from "./token.ts";
1617 import { isReservedAppEnvName } from "../core/app_env.ts";
1718 import { err, ok, type Result } from "../kernel/result.ts";
1819 import { stripForgeDirs } from "../runs/paths.ts";
@@ -40,6 +41,8 @@ export interface StartInput {
4041 readonly ssh?: string | undefined;
4142 /** The operator's variables for this app (`core/app_env.ts`). */
4243 readonly vars?: Record<string, string> | undefined;
44 /** The process's read-only credential into the forge (`apps/token.ts`). */
45 readonly forgeToken?: string | undefined;
4346 readonly log: (line: string) => void;
4447 /** Aborting cancels whatever step is in flight and fails the start. */
4548 readonly signal: AbortSignal;
@@ -106,6 +109,8 @@ export function appEnv(input: {
106109 ssh?: string | undefined;
107110 /** The operator's variables: an admin password, an API key. */
108111 vars?: Record<string, string> | undefined;
112 /** A read-only forge token for this process (`apps/token.ts`). */
113 forgeToken?: string | undefined;
109114 }): Record<string, string> {
110115 const pull = envPull(input.env);
111116 // The operator's variables go underneath everything the forge names, so
@@ -123,7 +128,13 @@ export function appEnv(input: {
123128 FORGE_APP_URL: input.url,
124129 FORGE_APP_COMMIT: input.commitOid,
125130 FORGE_URL: input.forgeUrl,
131 FORGE_API_URL: forgeApiUrl(input.forgeUrl),
126132 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 }),
127138 ...(pull === undefined ? {} : { FORGE_APP_PULL: String(pull) }),
128139 // Printed verbatim by an app that tells people how to reach its
129140 // terminal; a guessed form (`[email protected]`) reaches the forge instead.
@@ -315,6 +326,7 @@ export async function startProcess(
315326 identityKey: input.identityKey,
316327 ssh: input.ssh,
317328 vars: input.vars,
329 forgeToken: input.forgeToken,
318330 }),
319331 stdin: "ignore",
320332 stdout: "pipe",
src/apps/proxy.ts +11 −3
@@ -485,7 +485,7 @@ export async function proxyApp(
485485 * busy: held from its first byte until it ends, fails, or the browser
486486 * goes away.
487487 */
488 function held(
488 export function held(
489489 runtime: AppsRuntime,
490490 target: AppTarget,
491491 body: ReadableStream<Uint8Array>,
@@ -500,9 +500,17 @@ function held(
500500 release();
501501 controller.close();
502502 } 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.
504508 release();
505 controller.error(error);
509 try {
510 controller.close();
511 } catch {
512 // already closed
513 }
506514 }
507515 },
508516 cancel(reason) {
src/apps/runtime.ts +45 −3
@@ -20,6 +20,7 @@ import { join } from "node:path";
2020 import type { App } from "../app.ts";
2121 import { getAppByRepo, listApps, type AppRecord } from "../core/apps.ts";
2222 import { resolveAppEnv } from "../core/app_env.ts";
23 import { mintAppToken, renewAppToken, revokeAppToken } from "./token.ts";
2324 import type { ForgeEvent } from "../core/events.ts";
2425 import { announceRun } from "../actions/runs.ts";
2526 import { getPull, listPulls, type Pull } from "../core/pulls.ts";
@@ -197,6 +198,8 @@ interface Live {
197198 readonly createdAt?: string | undefined;
198199 /** The deploy run's own start — the reference the deploy log measures from. */
199200 readonly deployStartedAt?: string | null | undefined;
201 /** This process's forge token (`apps/token.ts`), revoked when it stops. */
202 readonly tokenId?: string | undefined;
200203 }
201204
202205 /**
@@ -516,7 +519,10 @@ export class AppsRuntime {
516519 state.deploying?.abort.abort();
517520 if (state.queued)
518521 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 }
520526 // Abort only cancels the step in flight; the `execute()` continuation
521527 // chained onto `state.chain` still runs afterwards to record the
522528 // outcome (`finishRun`, `announceRun`). Wait for it too, or it can
@@ -880,6 +886,7 @@ export class AppsRuntime {
880886 write(
881887 `stopping the previous deployment (${previous.commitOid.slice(0, 7)})`,
882888 );
889 revokeAppToken(this.app.db, previous.tokenId);
883890 await previous.handle.stop();
884891 if (previous.dir !== live.dir)
885892 rmSync(previous.dir, { recursive: true, force: true });
@@ -992,6 +999,14 @@ export class AppsRuntime {
992999 else if (seeded === "empty" && state.env !== "production")
9931000 write("data: starting empty");
9941001
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 );
9951010 const startInput: Omit<StartInput, "signal"> = {
9961011 dir,
9971012 dataDir,
@@ -1010,10 +1025,19 @@ export class AppsRuntime {
10101025 state.app.id,
10111026 state.env,
10121027 ),
1028 forgeToken: token.token,
10131029 log: write,
10141030 warm,
10151031 };
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();
10171041
10181042 // The one retry this deploy gets: a preview seeded from production whose
10191043 // process died before it ever answered — schema ahead of what this build
@@ -1030,8 +1054,9 @@ export class AppsRuntime {
10301054 "the copied data was not usable by this build; the preview is starting on empty data",
10311055 );
10321056 reseedEmpty(this.app.config.dataDir, state.app.id, state.env);
1033 started = await startProcess({ ...startInput, signal });
1057 started = await start();
10341058 if (!started.ok) {
1059 revokeAppToken(this.app.db, token.id);
10351060 rmSync(dir, { recursive: true, force: true });
10361061 return {
10371062 ok: false,
@@ -1039,6 +1064,7 @@ export class AppsRuntime {
10391064 };
10401065 }
10411066 } else if (!started.ok) {
1067 revokeAppToken(this.app.db, token.id);
10421068 rmSync(dir, { recursive: true, force: true });
10431069 return {
10441070 ok: false,
@@ -1054,6 +1080,7 @@ export class AppsRuntime {
10541080 runId: run.id,
10551081 since: new Date().toISOString(),
10561082 manifest: manifest.value,
1083 tokenId: token.id,
10571084 },
10581085 tested: started.value.tested,
10591086 };
@@ -1081,6 +1108,7 @@ export class AppsRuntime {
10811108 .then((code) => {
10821109 if (this.stopped || state.live !== live) return; // replaced or stopped on purpose
10831110 state.live = undefined;
1111 revokeAppToken(this.app.db, live.tokenId);
10841112 state.lastError = `exited with code ${code}`;
10851113 appendRunLog(
10861114 this.app.config.dataDir,
@@ -1162,6 +1190,7 @@ export class AppsRuntime {
11621190 appendRunLog(this.app.config.dataDir, asleep.runId, line);
11631191 };
11641192 write("waking: a request came");
1193 const token = mintAppToken(this.app.db, repo.ownerId, record.slug, env);
11651194 const started = await startProcess({
11661195 dir: asleep.dir,
11671196 dataDir: envDataDir(this.app.config.dataDir, record.id, env),
@@ -1178,10 +1207,16 @@ export class AppsRuntime {
11781207 record.id,
11791208 env,
11801209 ),
1210 forgeToken: token.token,
11811211 log: write,
11821212 signal: new AbortController().signal,
11831213 warm: true,
1214 }).catch((error: unknown) => {
1215 revokeAppToken(this.app.db, token.id);
1216 throw error;
11841217 });
1218 if (state.asleep !== asleep || !started.ok)
1219 revokeAppToken(this.app.db, token.id);
11851220 if (state.asleep !== asleep) {
11861221 // Replaced while waking — a deploy landed. What we started is spare.
11871222 if (started.ok) await started.value.handle.stop();
@@ -1202,6 +1237,7 @@ export class AppsRuntime {
12021237 since: now,
12031238 manifest: asleep.manifest,
12041239 servingAt: now,
1240 tokenId: token.id,
12051241 };
12061242 state.asleep = undefined;
12071243 state.live = live;
@@ -1272,6 +1308,9 @@ export class AppsRuntime {
12721308 const now = Date.now();
12731309 for (const state of this.envs.values()) {
12741310 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);
12751314 const spared = state.keptAwake;
12761315 state.keptAwake = undefined;
12771316 if (
@@ -1316,6 +1355,7 @@ export class AppsRuntime {
13161355 : `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`,
13171356 );
13181357 log.info("asleep", { app: state.app.slug, env: state.env });
1358 revokeAppToken(this.app.db, live.tokenId);
13191359 await live.handle.stop();
13201360 }
13211361 }
@@ -1337,6 +1377,7 @@ export class AppsRuntime {
13371377 // after its pull closed, checkout, cloned data and all.
13381378 const live = state.live;
13391379 state.live = undefined;
1380 revokeAppToken(this.app.db, live?.tokenId);
13401381 if (live) await live.handle.stop();
13411382 }
13421383 removeEnv(this.app.config.dataDir, record.id, env);
@@ -1361,6 +1402,7 @@ export class AppsRuntime {
13611402 const live = state.live;
13621403 state.live = undefined;
13631404 state.asleep = undefined;
1405 revokeAppToken(this.app.db, live?.tokenId);
13641406 if (live) await live.handle.stop();
13651407 }
13661408 }
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(
145145 .run(tokenId, userId).changes > 0
146146 );
147147 }
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 &lt;b&gt;bold&lt;/b&gt;`,
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 @@
1616 "skipLibCheck": true,
1717 "noEmit": true
1818 },
19 "include": ["src", "test", "scripts"]
19 "include": ["src", "test", "scripts", "examples"]
2020 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs