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

A child forge reads its parent's releases over HTTPS and says how many it is behind #1741

Merged opened this

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

Merged

scout/forge:scout/1569-a-child-forge-reads-its-parent-s-release → main 8 commits fork 3 threads · 3 unresolved

  • Merged. scout/1569-a-child-forge-reads-its-parent-s-release was merged into main as 8cdd318.

Review

Sign in to review this change.
14 files changed +638 −1
README.md +15
@@ -151,6 +151,21 @@ Unset, only this machine may say so, and the named header is never read. Everyth
151151 instance _is_ lives under the data directory — the SQLite index, the bare
152152 repos, and the SSH host key, in plain layout. Tar it up and it moves.
153153
154 `FORGE_PARENT_URL` names the forge this one was spawned from. It must be
155 `https://` with no credentials or query (the forge refuses to boot otherwise).
156 The parent serves `/releases.json` as `{"releases":[{"name","url"}]}`, newest
157 first; the child reads it with a 3 s timeout, no redirects and a 256 KB cap,
158 caches it under `FORGE_APP_DATA` for 10 minutes, and says "N releases behind
159 <parent>" on a fork's following card, in the terminal's About card (`I`), and as `parentForge`
160 (`{host, behind, releases, fetchedAt, down}`, null when unset) in
161 `GET /api/v1/repos/:owner/:name/following` and the MCP `following` tool. A
162 page never waits for the parent: it shows the cached copy and refreshes in the
163 background, and a parent that is down or answering garbage leaves a "could not
164 be reached" with the age of the last copy. Unset, nothing is shown. Read only:
165 taking a release is not done here. This forge publishes its own
166 `/releases.json` (the version names, each linking to that document), so any
167 forge can be a parent. The terminal shows the count, not the release names.
168
154169 ## Health
155170
156171 `GET /api/v1/health` (and the MCP `health` tool) answer a site admin with
docs/FOLLOWING.md +15
@@ -44,6 +44,21 @@ src/weapons.ts" — and offers exactly one thing to do. The forks page shows
4444 each fork's standing as a badge. The JSON is
4545 `GET /api/v1/repos/:owner/:name/following`.
4646
47 ## Releases behind a parent forge
48
49 `FORGE_PARENT_URL` names the forge this one was spawned from. It must be
50 `https://` with no credentials or query (the forge refuses to boot otherwise).
51 The parent serves `/releases.json` as `{"releases":[{"name","url"}]}`, newest
52 first; the child reads it with a 3 s timeout, no redirects and a 256 KB cap,
53 caches it under `FORGE_APP_DATA` for 10 minutes, and says "N releases behind
54 <parent>" on a fork's following card and in the terminal's About card (`I`). A
55 page never waits for the parent: it shows the cached copy and refreshes in the
56 background, and a parent that is down or answering garbage leaves a "could not
57 be reached" with the age of the last copy. Unset, nothing is shown. Read only:
58 taking a release is not done here. This forge publishes its own
59 `/releases.json` (the version names, each linking to that document), so any
60 forge can be a parent. The terminal shows the count, not the release names.
61
4762 ## A frozen parent
4863
4964 A parent can itself be a mirror whose origin was disconnected — a detached
src/app.ts +7
@@ -152,6 +152,13 @@ export interface ForgeConfig {
152152 * `releases/<sha>/` and preflighted (`release/`, #1662).
153153 */
154154 forgeRepo?: string;
155 /**
156 * The forge this one was spawned from (`FORGE_PARENT_URL`): https, no
157 * credentials. Its `/releases.json` is read for the following page.
158 */
159 parentUrl?: string;
160 /** Lets `parentUrl` be plain http. Tests only; boot never sets it. */
161 parentLoopback?: boolean;
155162 /** How many `releases/<sha>/` checkouts to keep (`FORGE_RELEASES_KEEP`). @default 5 */
156163 releasesKeep?: number;
157164 }
src/core/parent_releases.ts added +293
@@ -0,0 +1,293 @@
1 /**
2 * The releases of the forge this one was spawned from, read over HTTPS.
3 *
4 * A child names its parent's URL (`FORGE_PARENT_URL`); the parent answers
5 * `GET <url>/releases.json` with `{ "releases": [{ "name", "url" }] }`,
6 * newest first. Read only: nothing here takes an update.
7 *
8 * The read is bounded (a timeout and a size cap), cached under the data
9 * directory for ten minutes, and never throws. A page never waits for the
10 * parent: it renders what the cache holds at once, and a stale or missing
11 * cache starts a refresh in the background for the next view. A parent that
12 * is down, slow, or answering garbage costs a page nothing: it gets the last
13 * good copy and that copy's age, or an honest "unreachable" when there is
14 * none. A failed try is remembered for a minute so a dead parent is not
15 * asked on every view.
16 */
17
18 import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
19 import { join } from "node:path";
20 import { running } from "../kernel/version.ts";
21
22 export const FRESH_MS = 10 * 60_000;
23 export const RETRY_MS = 60_000;
24 const TIMEOUT_MS = 3_000;
25 const MAX_BYTES = 256 * 1024;
26 const MAX_RELEASES = 100;
27
28 export interface ParentRelease {
29 readonly name: string;
30 readonly url: string;
31 }
32
33 export interface ParentReleases {
34 readonly releases: readonly ParentRelease[];
35 /** When the copy shown was fetched, or null when there is none. */
36 readonly fetchedAt: number | null;
37 /** True when the last try failed (a copy may still be shown). */
38 readonly down: boolean;
39 /** True when a refresh has been started and nothing has answered yet. */
40 readonly checking: boolean;
41 }
42
43 /** Why `raw` cannot name a parent, or null. `loopback` allows plain http (tests). */
44 export function parentUrlProblem(raw: string, loopback = false): string | null {
45 let u: URL;
46 try {
47 u = new URL(raw);
48 } catch {
49 return "not a URL";
50 }
51 if (u.protocol !== "https:" && !(loopback && u.protocol === "http:"))
52 return "must be https";
53 if (u.username !== "" || u.password !== "")
54 return "must not carry credentials";
55 if (u.search !== "" || u.hash !== "") return "must not carry a query";
56 return null;
57 }
58
59 /** A release's link: http(s) only (http for loopback tests), no credentials; a fragment is fine. */
60 function link(raw: unknown, loopback: boolean): string | null {
61 if (typeof raw !== "string" || raw.length > 500) return null;
62 let u: URL;
63 try {
64 u = new URL(raw);
65 } catch {
66 return null;
67 }
68 const ok = u.protocol === "https:" || (loopback && u.protocol === "http:");
69 return ok && u.username === "" && u.password === "" ? raw : null;
70 }
71
72 /** The entries of a releases document that are well formed; undefined when it is not one. */
73 export function parseReleases(
74 doc: unknown,
75 loopback = false,
76 ): ParentRelease[] | undefined {
77 if (typeof doc !== "object" || doc === null) return undefined;
78 const list = (doc as { releases?: unknown }).releases;
79 if (!Array.isArray(list)) return undefined;
80 const out: ParentRelease[] = [];
81 for (const e of list.slice(0, MAX_RELEASES)) {
82 if (typeof e !== "object" || e === null) continue;
83 const name = (e as { name?: unknown }).name;
84 const url = link((e as { url?: unknown }).url, loopback);
85 if (typeof name !== "string" || url === null) continue;
86 const n = name.trim();
87 if (n === "" || n.length > 80) continue;
88 out.push({ name: n, url });
89 }
90 return out;
91 }
92
93 /**
94 * How many of the parent's releases (newest first) come after `version`;
95 * undefined when `version` is not in the list (an unknown or newer build),
96 * where a count would be a guess.
97 */
98 export function releasesBehind(
99 releases: readonly ParentRelease[],
100 version: string,
101 ): number | undefined {
102 const at = releases.findIndex((r) => r.name === version);
103 return at === -1 ? undefined : at;
104 }
105
106 interface Stored {
107 url: string;
108 fetchedAt: number | null;
109 triedAt: number;
110 ok: boolean;
111 releases: ParentRelease[];
112 }
113
114 function readStored(file: string, url: string): Stored | undefined {
115 try {
116 const s = JSON.parse(readFileSync(file, "utf8")) as Stored;
117 if (s.url !== url || !Array.isArray(s.releases)) return undefined;
118 return s;
119 } catch {
120 return undefined;
121 }
122 }
123
124 async function fetchDoc(url: string, f: typeof fetch): Promise<unknown> {
125 const res = await f(`${url.replace(/\/+$/, "")}/releases.json`, {
126 signal: AbortSignal.timeout(TIMEOUT_MS),
127 redirect: "error",
128 headers: { accept: "application/json" },
129 });
130 if (!res.ok || res.body === null) throw new Error(`status ${res.status}`);
131 const reader = res.body.getReader();
132 const chunks: Uint8Array[] = [];
133 let size = 0;
134 for (;;) {
135 const { done, value } = await reader.read();
136 if (done) break;
137 size += value.byteLength;
138 if (size > MAX_BYTES) {
139 await reader.cancel();
140 throw new Error("too large");
141 }
142 chunks.push(value);
143 }
144 return JSON.parse(Buffer.concat(chunks).toString("utf8"));
145 }
146
147 const refreshing = new Map<string, Promise<void>>();
148
149 /** Reads the parent now and stores the answer; one refresh at a time per cache. */
150 export function refreshParentReleases(
151 dataDir: string,
152 url: string,
153 opts: { now?: number; loopback?: boolean; fetch?: typeof fetch } = {},
154 ): Promise<void> {
155 const file = join(dataDir, "parent-releases.json");
156 const running = refreshing.get(file);

Nit: this local running shadows the running imported from kernel/version.ts on line 20. Renaming it to inFlight would remove the trap for the next edit.

157 if (running !== undefined) return running;
158 const job = (async () => {
159 const now = opts.now ?? Date.now();
160 const kept = readStored(file, url);
161 const copy = kept?.fetchedAt ?? null;
162 let next: Stored;
163 try {
164 const releases = parseReleases(
165 await fetchDoc(url, opts.fetch ?? fetch),
166 opts.loopback ?? false,
167 );
168 if (releases === undefined) throw new Error("not a releases document");
169 next = { url, fetchedAt: now, triedAt: now, ok: true, releases };
170 } catch {
171 next = {
172 url,
173 fetchedAt: copy,
174 triedAt: now,
175 ok: false,
176 releases: copy === null ? [] : (kept?.releases ?? []),
177 };
178 }
179 try {
180 mkdirSync(dataDir, { recursive: true });
181 const tmp = `${file}.${process.pid}.tmp`;
182 writeFileSync(tmp, JSON.stringify(next));
183 renameSync(tmp, file);
184 } catch {
185 // A cache that cannot be written is a cache miss next time, not a failure.
186 }
187 })().finally(() => refreshing.delete(file));
188 refreshing.set(file, job);
189 return job;
190 }
191
192 /**
193 * What the cache holds, at once. A copy older than ten minutes (or none, and
194 * no try in the last minute) starts a refresh in the background; the caller
195 * does not wait for it. The returned promise settles when it has (for tests).
196 */
197 export function parentReleases(
198 dataDir: string,
199 url: string,
200 opts: { now?: number; loopback?: boolean; fetch?: typeof fetch } = {},
201 ): ParentReleases & { refresh: Promise<void> | undefined } {
202 const now = opts.now ?? Date.now();
203 const kept = readStored(join(dataDir, "parent-releases.json"), url);
204 const fetchedAt = kept?.fetchedAt ?? null;
205 const fresh = fetchedAt !== null && now - fetchedAt < FRESH_MS;
206 const tried = kept !== undefined && now - kept.triedAt < RETRY_MS;
207 const refresh =
208 fresh || tried ? undefined : refreshParentReleases(dataDir, url, opts);
209 return {
210 releases: kept?.releases ?? [],
211 fetchedAt,
212 down: kept !== undefined && !kept.ok,
213 checking: refresh !== undefined && kept === undefined,
214 refresh,
215 };
216 }
217
218 /**
219 * The sentence both doors say: how far behind, or why not, and how old the
220 * copy is when the parent cannot be reached.
221 */
222 export function releasesSentence(
223 r: ParentReleases,
224 version: string,
225 host: string,
226 now = Date.now(),
227 ): string {
228 if (r.fetchedAt === null)
229 return r.checking || !r.down
230 ? `Checking ${host} for releases…`
231 : `${host} could not be reached.`;
232 const behind = releasesBehind(r.releases, version);
233 const count =
234 behind === undefined
235 ? `This version (${version}) is not in ${host}'s list of releases.`
236 : behind === 0
237 ? `No releases behind ${host}.`
238 : `${behind} ${behind === 1 ? "release" : "releases"} behind ${host}.`;
239 return r.down
240 ? `${count} ${host} could not be reached; this is a copy from ${ageWords(now - r.fetchedAt)} ago.`
241 : count;
242 }
243
244 /** "3 minutes", "2 hours": the age of a copy, in one phrase. */
245 export function ageWords(ms: number): string {
246 const m = Math.max(0, Math.floor(ms / 60_000));
247 if (m < 1) return "under a minute";
248 if (m < 60) return `${m} minute${m === 1 ? "" : "s"}`;
249 const h = Math.floor(m / 60);
250 if (h < 48) return `${h} hour${h === 1 ? "" : "s"}`;
251 const d = Math.floor(h / 24);
252 return `${d} days`;
253 }
254
255 /** The parent forge as plain data, for the JSON and MCP doors. */
256 export interface ParentForge {
257 readonly host: string;
258 /** Releases behind, or null when unknown (nothing fetched, or not listed). */
259 readonly behind: number | null;
260 /** The releases behind, newest first. */
261 readonly releases: readonly ParentRelease[];
262 readonly fetchedAt: number | null;
263 readonly down: boolean;
264 }
265
266 /** `{host, behind, releases, fetchedAt, down}` from a cache view. */
267 export function parentForgeOf(
268 parentUrl: string,
269 view: ParentReleases,
270 version: string,
271 ): ParentForge {
272 const behind = releasesBehind(view.releases, version) ?? null;
273 return {
274 host: new URL(parentUrl).host,
275 behind: view.fetchedAt === null ? null : behind,
276 releases: view.releases.slice(0, behind ?? 0),
277 fetchedAt: view.fetchedAt,
278 down: view.down,
279 };
280 }
281
282 /** The parent forge for this forge's config, or null when none is named. */
283 export function parentForgeFor(config: {
284 parentUrl?: string;
285 parentLoopback?: boolean;
286 dataDir: string;
287 }): ParentForge | null {
288 if (config.parentUrl === undefined) return null;
289 const view = parentReleases(config.dataDir, config.parentUrl, {
290 loopback: config.parentLoopback === true,
291 });
292 return parentForgeOf(config.parentUrl, view, running().version);
293 }
src/core/reserved.ts +1
@@ -91,6 +91,7 @@ export const SYSTEM_NAMES: readonly string[] = [
9191 "signin",
9292 "signup",
9393 "sitemap.xml",
94 "releases.json",
9495 "sw.js",
9596 "tell",
9697 "theme",
src/http/api.ts +2
@@ -9,6 +9,7 @@
99 */
1010
1111 import { voteVariant } from "../actions/variants.ts";
12 import { parentForgeFor } from "../core/parent_releases.ts";
1213 import { readFlow } from "../flow/read.ts";
1314 import { runQuery } from "../boards/query.ts";
1415 import { SOURCES } from "../boards/sources.ts";
@@ -1838,6 +1839,7 @@ export function apiRoutes(router: Router<Handler>): void {
18381839 conflicts: relation.conflicts,
18391840 forkTip: relation.forkTip,
18401841 parentTip: relation.parentTip,
1842 parentForge: parentForgeFor(ctx.app.config),
18411843 converge:
18421844 converge === undefined
18431845 ? null
src/http/mcp.ts +2
@@ -129,6 +129,7 @@ import {
129129 import { frontInput, frontJson, setFront } from "../actions/front.ts";
130130 import { readHealth } from "../actions/health.ts";
131131 import { running } from "../kernel/version.ts";
132 import { parentForgeFor } from "../core/parent_releases.ts";
132133 import { COPIES_PER_DAY, makeItYours } from "../actions/make_it_yours.ts";
133134 import { resourceMetadataUrl } from "./oauth.ts";
134135 import { body, boundedBody, json } from "./respond.ts";
@@ -915,6 +916,7 @@ const TOOLS: readonly Tool[] = [
915916 ahead: relation.ahead,
916917 clean: relation.clean,
917918 conflicts: relation.conflicts,
919 parentForge: parentForgeFor(ctx.app.config),
918920 converge: converge?.kind ?? null,
919921 });
920922 },
src/http/server.ts +2
@@ -131,6 +131,7 @@ import {
131131 changePassword,
132132 iconPng,
133133 llmsTxt,
134 releasesJson,
134135 robotsTxt,
135136 securityTxt,
136137 sitemapXml,
@@ -547,6 +548,7 @@ export function buildRouter(): Router<Handler> {
547548 router.add("GET", "/llms.txt", llmsTxt);
548549 router.add("GET", "/.well-known/change-password", changePassword);
549550 router.add("GET", "/robots.txt", robotsTxt);
551 router.add("GET", "/releases.json", releasesJson);
550552 router.add("GET", "/sitemap.xml", sitemapXml);
551553 router.add("GET", "/manifest.webmanifest", webManifest);
552554 router.add("GET", "/icons/:spec.png", iconPng);
src/main.ts +9
@@ -4,6 +4,7 @@
44 * environment-only — a handful of variables, all with sane defaults.
55 */
66
7 import { parentUrlProblem } from "./core/parent_releases.ts";
78 import { releaseEnv } from "./release/env.ts";
89 import { createApp } from "./app.ts";
910 import { reconcile } from "./core/reconcile.ts";
@@ -121,8 +122,16 @@ if (turnstileSecret !== "" && turnstileSiteKey === "") {
121122 process.exit(1);
122123 }
123124
125 const parentUrl = (process.env.FORGE_PARENT_URL ?? "").trim();
126 const parentProblem = parentUrl === "" ? null : parentUrlProblem(parentUrl);
127 if (parentProblem !== null) {
128 console.error(`FORGE_PARENT_URL: ${parentProblem}`);
129 process.exit(1);
130 }
131
124132 const app = createApp({
125133 dataDir,
134 ...(parentUrl === "" ? {} : { parentUrl }),
126135 port: Number(process.env.PORT ?? 3000),
127136 ...(process.env.FORGE_HOST === undefined
128137 ? {}
src/tui/screens/about.ts +26
@@ -16,6 +16,8 @@
1616 */
1717
1818 import { isPinned } from "../../core/pins.ts";
19 import { parentReleases, releasesSentence } from "../../core/parent_releases.ts";
20 import { running } from "../../kernel/version.ts";
1921 import type { Repo } from "../../core/repos.ts";
2022 import type { RepoFacts, RepoSource } from "../../view/source.ts";
2123 import type { Ctx, Screen } from "../app/app.ts";
@@ -135,6 +137,28 @@ export function aboutScreen(
135137 };
136138 }
137139
140 /**
141 * The web following card's releases sentence, for a fork on a forge that
142 * names a parent forge: the same words, from the same cache.
143 */
144 export function releasesRow(
145 f: Session,
146 repo: Repo | undefined,
147 ): [string, string][] {
148 const { parentUrl, dataDir, parentLoopback } = f.app.config;
149 if (repo === undefined || repo.forkParentId === null) return [];
150 if (parentUrl === undefined) return [];
151 const view = parentReleases(dataDir, parentUrl, {
152 loopback: parentLoopback === true,
153 });
154 return [
155 [
156 "releases",
157 releasesSentence(view, running().version, new URL(parentUrl).host),
158 ],
159 ];
160 }
161
138162 /**
139163 * The label/value block. A repository we hold gets the clone URLs (the single
140164 * most-copied string on any forge, and the one thing a terminal can hand over
@@ -171,6 +195,7 @@ function aboutFacts(
171195 const upstream = forkOf(f, src);
172196 const fork: [string, string][] =
173197 upstream === "" ? [] : [["fork of", upstream]];
198 const releases = releasesRow(f, repo);
174199 // "browsing" only when it is not the obvious answer: a row that repeats
175200 // the line above it is a row nobody reads.
176201 const browsing: [string, string][] =
@@ -181,6 +206,7 @@ function aboutFacts(
181206 ...browsing,
182207 ...counts,
183208 ...fork,
209 ...releases,
184210 ...originFacts(repo),
185211 ...cloneFacts(f, repo),
186212 ];
src/web/pages/following.ts +49 −1
@@ -31,6 +31,13 @@ import {
3131 type Converge,
3232 } from "../../crew/converge.ts";
3333 import type { Lineage, LineageNode } from "../../core/following.ts";
34 import {
35 parentReleases,
36 parentForgeOf,
37 releasesSentence,
38 type ParentReleases,
39 } from "../../core/parent_releases.ts";
40 import { running } from "../../kernel/version.ts";
3441 import { loadRepo, notFoundPage, repoBase, signInFirst } from "../chrome.ts";
3542 import { openProposal, proposeUpstream } from "../../actions/upstream.ts";
3643 import { field, readForm } from "../form.ts";
@@ -98,6 +105,8 @@ export function lineageCard(lineage: Lineage, hereId: string): Html {
98105 export interface Following {
99106 readonly relation: ForkRelation;
100107 readonly converge: Converge | undefined;
108 /** The parent forge's releases, when this forge names a parent forge. */
109 readonly releases: { url: string; view: ParentReleases } | undefined;
101110 }
102111
103112 /** Everything the card needs, for a repo that follows something. */
@@ -114,7 +123,43 @@ export async function followingOf(
114123 relation.state === "diverged" && !relation.clean
115124 ? convergeState(ctx.app, repo, relation.parent)
116125 : undefined;
117 return { relation, converge };
126 const { parentUrl } = ctx.app.config;
127 const releases =
128 parentUrl === undefined
129 ? undefined
130 : {
131 url: parentUrl,
132 view: parentReleases(ctx.app.config.dataDir, parentUrl, {
133 loopback: ctx.app.config.parentLoopback === true,
134 }),
135 };
136 return { relation, converge, releases };

Only the page and the terminal get this. Move this block into one helper that followingOf and the /api/v1/repos/:owner/:name/following handler both call, so the JSON door carries the same view (host, behind, the releases behind, fetchedAt, down).

137 }
138
139 /**
140 * "N releases behind <parent>", each with its name and link; or, when the
141 * parent cannot be read, that it cannot and how old the last copy is.
142 * Every field comes from another server and is escaped by `html`.
143 */
144 export function releasesBlock(parentUrl: string, releases: ParentReleases): Html {
145 const host = new URL(parentUrl).host;
146 const version = running().version;
147 const { releases: news } = parentForgeOf(parentUrl, releases, version);
148 return html`<div class="following-releases">
149 <p class="following-sentence">
150 ${releasesSentence(releases, version, host)}
151 </p>
152 ${news.length === 0
153 ? null
154 : html`<ul class="list">
155 ${news.map(
156 (r) =>
157 html`<li>
158 <a href="${r.url}" rel="noopener noreferrer">${r.name}</a>
159 </li>`,
160 )}
161 </ul>`}
162 </div>`;
118163 }
119164
120165 function plural(n: number, one: string, many = `${one}s`): string {
@@ -303,6 +348,9 @@ export function followingCard(
303348 >Reconnect it.</a
304349 >`}
305350 </p>
351 ${following.releases === undefined
352 ? null
353 : releasesBlock(following.releases.url, following.releases.view)}
306354 ${when(
307355 actions.length > 0,
308356 () => html`<div class="following-actions">${actions}</div>`,
src/web/standards.ts +19
@@ -6,6 +6,8 @@
66 * /.well-known/change-password where a password manager sends someone
77 * /robots.txt what a crawler may index, and the sitemap
88 * /sitemap.xml every public repository
9 * /releases.json the releases this forge has shipped, newest
10 * first, for a child forge to read
911 * /manifest.webmanifest the forge as an installable app
1012 * /llms.txt the forge for an agent: what it is, how to
1113 * report a problem without an account, how
@@ -18,6 +20,7 @@
1820 * FORGE_SECURITY_CONTACT.
1921 */
2022
23 import releases from "../core/releases.json" with { type: "json" };
2124 import { markPng, type MarkShape } from "../design/mark.ts";
2225 import { BODY_MAX, TITLE_MAX } from "../core/reports.ts";
2326 import { canRead, getRepo, listReposVisibleTo } from "../core/repos.ts";
@@ -88,6 +91,22 @@ export function changePassword(): Response {
8891 });
8992 }
9093
94 /**
95 * GET /releases.json — `{ releases: [{ name, url }] }`, newest first, read
96 * by a child forge (`core/parent_releases.ts`). Public and read-only. The
97 * link of each is this document at that release; there is no page per
98 * release to point at.
99 */
100 export function releasesJson(ctx: Ctx): Response {
101 const body = {
102 releases: releases.releases.map((r) => ({
103 name: r.version,
104 url: `${ctx.url.origin}/releases.json#${encodeURIComponent(r.version)}`,
105 })),
106 };
107 return text(JSON.stringify(body), "application/json");
108 }
109
91110 /** GET /robots.txt */
92111 export function robotsTxt(ctx: Ctx): Response {
93112 return text(
test/following.test.ts +185
@@ -8,6 +8,9 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
88 import { mkdtemp, rm } from "node:fs/promises";
99 import { tmpdir } from "node:os";
1010 import { join } from "node:path";
11 import { parentUrlProblem } from "../src/core/parent_releases.ts";
12 import { Session } from "../src/tui/session.ts";
13 import { releasesRow } from "../src/tui/screens/about.ts";
1114 import { createApp, type App } from "../src/app.ts";
1215 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1316 import { startChecks, type ChecksHandle } from "../src/checks/service.ts";
@@ -486,6 +489,167 @@ describe("a fork that follows its parent", () => {
486489 expect(page).toContain("ahead");
487490 });
488491
492 test("a child forge says how many releases it is behind its parent, and a parent that is down leaves the page up", async () => {
493 let mode: "ok" | "500" | "garbage" = "ok";
494 let asked = 0;
495 let open!: () => void;
496 const gate = new Promise<void>((r) => (open = r));
497 const fake = Bun.serve({
498 port: 0,
499 hostname: "127.0.0.1",
500 fetch: async () => {
501 asked++;
502 if (mode === "500") return new Response("boom", { status: 500 });
503 if (mode === "garbage") return new Response("<html>not json");
504 await gate;
505 return Response.json({
506 releases: [
507 { name: "9.0.2 <b>x</b>", url: "http://127.0.0.1/r/2" },
508 { name: "9.0.1", url: "http://127.0.0.1/r/1" },
509 { name: "9.0.0", url: "http://127.0.0.1/r/0" },
510 { name: "bad", url: "javascript:alert(1)" },
511 ],
512 });
513 },
514 });
515 const config = app.config as { parentUrl?: string; parentLoopback?: boolean };
516 const cache = join(dataDir, "parent-releases.json");
517 const was = process.env.FORGE_VERSION;
518 const flat = (t: string) => t.replace(/\s+/g, " ");
519 const get = () =>
520 fetch(`${base}/bob/game`, {
521 headers: { authorization: `Bearer ${bobToken}`, accept: "text/html" },
522 });
523 const page = async () => flat(await (await get()).text());
524 try {
525 process.env.FORGE_VERSION = "9.0.0";
526 config.parentUrl = `http://127.0.0.1:${fake.port}`;
527 config.parentLoopback = true;
528
529 // The first view does not wait for the parent: it answers while the
530 // fake parent is still blocked on `gate`.
outdated

A wall-clock bound of 250 ms on a full page render against a parent that sleeps 300 ms leaves 50 ms of margin on a loaded check runner. It will flake. Assert the behaviour instead: the first view says "Checking" and asked has not yet been answered (or have the fake parent block on a promise the test resolves after the page returns).

531 const first = await page();
532 expect(first).toContain("Checking 127.0.0.1:");
533 open();
534 await settled(dataDir);
535 const ok = await page();
536 expect(ok).toContain("2 releases behind");
537 // The agent door carries the same view.
538 const api = (await (
539 await fetch(`${base}/api/v1/repos/bob/game/following`, {
540 headers: { authorization: `Bearer ${bobToken}` },
541 })
542 ).json()) as {
543 parentForge: {
544 behind: number;
545 host: string;
546 down: boolean;
547 releases: { name: string }[];
548 };
549 };
550 expect(api.parentForge.behind).toBe(2);
551 expect(api.parentForge.host).toBe(`127.0.0.1:${fake.port}`);
552 expect(api.parentForge.down).toBe(false);
553 expect(api.parentForge.releases.map((r) => r.name)).toEqual([
554 "9.0.2 <b>x</b>",
555 "9.0.1",
556 ]);
557 expect(ok).toContain("9.0.2 &lt;b&gt;x&lt;/b&gt;");
558 expect(ok).not.toContain("<b>x</b>");
559 expect(ok).not.toContain("javascript:");
560
561 // Cached for ten minutes: the parent going away changes nothing yet.
562 mode = "500";
563 const before = asked;
564 expect(await page()).toContain("2 releases behind");
565 await settled(dataDir);
566 expect(asked).toBe(before);
567
568 // Past the cache the page is served from the old copy at once, the
569 // refresh fails behind it, and the next view says so with the age.
570 const stored = JSON.parse(await Bun.file(cache).text());
571 stored.fetchedAt -= 3 * 3_600_000;
572 stored.triedAt -= 3 * 3_600_000;
573 await Bun.write(cache, JSON.stringify(stored));
574 expect(await page()).toContain("2 releases behind");
575 await settled(dataDir);
576 const down = await page();
577 expect(down).toContain("2 releases behind");
578 expect(down).toContain(
579 "could not be reached; this is a copy from 3 hours ago",
580 );
581
582 mode = "garbage";
583 stored.triedAt -= 3_600_000;
584 await Bun.write(cache, JSON.stringify(stored));
585 expect((await get()).status).toBe(200);
586 await settled(dataDir);
587 expect(await page()).toContain("could not be reached");
588
589 // A build the parent does not list is not "behind" by any count.
590 process.env.FORGE_VERSION = "1.2.3";
591 expect(await page()).toContain(
592 "This version (1.2.3) is not in 127.0.0.1:",
593 );
594
595 // No copy at all: still a page, and an honest sentence.
596 mode = "500";
597 await Bun.write(cache, "{}");
598 expect((await get()).status).toBe(200);
599 await settled(dataDir);
600 const none = await get();
601 expect(none.status).toBe(200);
602 expect(flat(await none.text())).toContain("could not be reached.");
603 } finally {
604 if (was === undefined) delete process.env.FORGE_VERSION;
605 else process.env.FORGE_VERSION = was;
606 delete config.parentUrl;
607 delete config.parentLoopback;
608 fake.stop(true);
609 }
610 });
611
612 test("the terminal's About card says the same sentence from the same cache", async () => {
613 const fake = Bun.serve({
614 port: 0,
615 hostname: "127.0.0.1",
616 fetch: () =>
617 Response.json({
618 releases: [
619 { name: "8.0.1", url: "http://127.0.0.1/r/1" },
620 { name: "8.0.0", url: "http://127.0.0.1/r/0" },
621 ],
622 }),
623 });
624 const config = app.config as { parentUrl?: string; parentLoopback?: boolean };
625 const was = process.env.FORGE_VERSION;
626 try {
627 process.env.FORGE_VERSION = "8.0.0";
628 config.parentUrl = `http://127.0.0.1:${fake.port}`;
629 config.parentLoopback = true;
630 const session = new Session(app, {
631 sshUsername: "bob",
632 remoteAddress: "127.0.0.1",
633 fingerprint: "SHA256:test",
634 user: bob,
635 });
636 const repo = fresh(fork);
637 expect(releasesRow(session, undefined)).toEqual([]);
638 expect(releasesRow(session, fresh(parent))).toEqual([]);
639 expect(releasesRow(session, repo)[0]![1]).toStartWith("Checking ");
640 await settled(dataDir);
641 expect(releasesRow(session, repo)).toEqual([
642 ["releases", `1 release behind 127.0.0.1:${fake.port}.`],
643 ]);
644 } finally {
645 if (was === undefined) delete process.env.FORGE_VERSION;
646 else process.env.FORGE_VERSION = was;
647 delete config.parentUrl;
648 delete config.parentLoopback;
649 fake.stop(true);
650 }
651 });
652
489653 test("the family is a tree: a fork of a fork nests under it, from any page in it", async () => {
490654 const f = await createUser(app.db, { trusted: true,
491655 username: "fay",
@@ -802,3 +966,24 @@ describe("a landed pull moves the forks, like the push it amounts to", () => {
802966 expect(checked(parent)).toBeGreaterThan(0);
803967 });
804968 });
969
970 /** Waits for a background refresh to rewrite the cache (or 600 ms for none). */
971 async function settled(dir: string): Promise<void> {
972 const file = Bun.file(join(dir, "parent-releases.json"));
973 const text = async () => ((await file.exists()) ? await file.text() : "");
974 const before = await text();
975 for (let i = 0; i < 24; i++) {
976 await Bun.sleep(25);
977 if ((await text()) !== before) return;
978 }
979 }
980
981 describe("a parent URL", () => {
982 test("is https, without credentials", () => {
983 expect(parentUrlProblem("https://parent.example")).toBeNull();
984 expect(parentUrlProblem("http://parent.example")).not.toBeNull();
985 expect(parentUrlProblem("https://u:[email protected]")).not.toBeNull();
986 expect(parentUrlProblem("nonsense")).not.toBeNull();
987 expect(parentUrlProblem("http://127.0.0.1:1", true)).toBeNull();
988 });
989 });
test/standards.test.ts +13
@@ -4,6 +4,8 @@
44 * an installable manifest with real icons, and the metadata a link needs
55 * to unfurl.
66 */
7 import { parseReleases } from "../src/core/parent_releases.ts";
8 import releasesFile from "../src/core/releases.json" with { type: "json" };
79 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
810 import { mkdtemp, rm } from "node:fs/promises";
911 import { tmpdir } from "node:os";
@@ -82,6 +84,17 @@ describe("what the open web expects", () => {
8284 expect(res.headers.get("location")).toBe("/settings/password");
8385 });
8486
87 test("releases.json is the shipped releases, newest first, in the shape a child reads", async () => {
88 const res = await fetch(`${base}/releases.json`);
89 expect(res.status).toBe(200);
90 expect(res.headers.get("content-type")).toContain("application/json");
91 const doc = (await res.json()) as { releases: unknown[] };
92 const read = parseReleases(doc, true);
93 expect(read!.length).toBe(doc.releases.length);
94 expect(read![0]!.name).toBe(releasesFile.releases[0]!.version);
95 expect(read![0]!.url).toStartWith(`${base}/releases.json#`);
96 });
97
8598 test("robots keeps crawlers out of accounts and points at the sitemap", async () => {
8699 const body = await fetch(`${base}/robots.txt`).then((r) => r.text());
87100 expect(body).toContain("User-agent: *");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs