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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1569-a-child-forge-reads-its-parent-s-release → main 8 commits fork 3 threads · 3 unresolved
README.md +15
| @@ -151,6 +151,21 @@ Unset, only this machine may say so, and the named header is never read. Everyth | ||
| 151 | 151 | instance _is_ lives under the data directory — the SQLite index, the bare |
| 152 | 152 | repos, and the SSH host key, in plain layout. Tar it up and it moves. |
| 153 | 153 | |
| 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 | ||
| 154 | 169 | ## Health |
| 155 | 170 | |
| 156 | 171 | `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 | ||
| 44 | 44 | each fork's standing as a badge. The JSON is |
| 45 | 45 | `GET /api/v1/repos/:owner/:name/following`. |
| 46 | 46 | |
| 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 | ||
| 47 | 62 | ## A frozen parent |
| 48 | 63 | |
| 49 | 64 | A parent can itself be a mirror whose origin was disconnected — a detached |
src/app.ts +7
| @@ -152,6 +152,13 @@ export interface ForgeConfig { | ||
| 152 | 152 | * `releases/<sha>/` and preflighted (`release/`, #1662). |
| 153 | 153 | */ |
| 154 | 154 | 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; | |
| 155 | 162 | /** How many `releases/<sha>/` checkouts to keep (`FORGE_RELEASES_KEEP`). @default 5 */ |
| 156 | 163 | releasesKeep?: number; |
| 157 | 164 | } |
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); | |
| 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[] = [ | ||
| 91 | 91 | "signin", |
| 92 | 92 | "signup", |
| 93 | 93 | "sitemap.xml", |
| 94 | "releases.json", | |
| 94 | 95 | "sw.js", |
| 95 | 96 | "tell", |
| 96 | 97 | "theme", |
src/http/api.ts +2
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import { voteVariant } from "../actions/variants.ts"; |
| 12 | import { parentForgeFor } from "../core/parent_releases.ts"; | |
| 12 | 13 | import { readFlow } from "../flow/read.ts"; |
| 13 | 14 | import { runQuery } from "../boards/query.ts"; |
| 14 | 15 | import { SOURCES } from "../boards/sources.ts"; |
| @@ -1838,6 +1839,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1838 | 1839 | conflicts: relation.conflicts, |
| 1839 | 1840 | forkTip: relation.forkTip, |
| 1840 | 1841 | parentTip: relation.parentTip, |
| 1842 | parentForge: parentForgeFor(ctx.app.config), | |
| 1841 | 1843 | converge: |
| 1842 | 1844 | converge === undefined |
| 1843 | 1845 | ? null |
src/http/mcp.ts +2
| @@ -129,6 +129,7 @@ import { | ||
| 129 | 129 | import { frontInput, frontJson, setFront } from "../actions/front.ts"; |
| 130 | 130 | import { readHealth } from "../actions/health.ts"; |
| 131 | 131 | import { running } from "../kernel/version.ts"; |
| 132 | import { parentForgeFor } from "../core/parent_releases.ts"; | |
| 132 | 133 | import { COPIES_PER_DAY, makeItYours } from "../actions/make_it_yours.ts"; |
| 133 | 134 | import { resourceMetadataUrl } from "./oauth.ts"; |
| 134 | 135 | import { body, boundedBody, json } from "./respond.ts"; |
| @@ -915,6 +916,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 915 | 916 | ahead: relation.ahead, |
| 916 | 917 | clean: relation.clean, |
| 917 | 918 | conflicts: relation.conflicts, |
| 919 | parentForge: parentForgeFor(ctx.app.config), | |
| 918 | 920 | converge: converge?.kind ?? null, |
| 919 | 921 | }); |
| 920 | 922 | }, |
src/http/server.ts +2
| @@ -131,6 +131,7 @@ import { | ||
| 131 | 131 | changePassword, |
| 132 | 132 | iconPng, |
| 133 | 133 | llmsTxt, |
| 134 | releasesJson, | |
| 134 | 135 | robotsTxt, |
| 135 | 136 | securityTxt, |
| 136 | 137 | sitemapXml, |
| @@ -547,6 +548,7 @@ export function buildRouter(): Router<Handler> { | ||
| 547 | 548 | router.add("GET", "/llms.txt", llmsTxt); |
| 548 | 549 | router.add("GET", "/.well-known/change-password", changePassword); |
| 549 | 550 | router.add("GET", "/robots.txt", robotsTxt); |
| 551 | router.add("GET", "/releases.json", releasesJson); | |
| 550 | 552 | router.add("GET", "/sitemap.xml", sitemapXml); |
| 551 | 553 | router.add("GET", "/manifest.webmanifest", webManifest); |
| 552 | 554 | router.add("GET", "/icons/:spec.png", iconPng); |
src/main.ts +9
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * environment-only — a handful of variables, all with sane defaults. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { parentUrlProblem } from "./core/parent_releases.ts"; | |
| 7 | 8 | import { releaseEnv } from "./release/env.ts"; |
| 8 | 9 | import { createApp } from "./app.ts"; |
| 9 | 10 | import { reconcile } from "./core/reconcile.ts"; |
| @@ -121,8 +122,16 @@ if (turnstileSecret !== "" && turnstileSiteKey === "") { | ||
| 121 | 122 | process.exit(1); |
| 122 | 123 | } |
| 123 | 124 | |
| 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 | ||
| 124 | 132 | const app = createApp({ |
| 125 | 133 | dataDir, |
| 134 | ...(parentUrl === "" ? {} : { parentUrl }), | |
| 126 | 135 | port: Number(process.env.PORT ?? 3000), |
| 127 | 136 | ...(process.env.FORGE_HOST === undefined |
| 128 | 137 | ? {} |
src/tui/screens/about.ts +26
| @@ -16,6 +16,8 @@ | ||
| 16 | 16 | */ |
| 17 | 17 | |
| 18 | 18 | import { isPinned } from "../../core/pins.ts"; |
| 19 | import { parentReleases, releasesSentence } from "../../core/parent_releases.ts"; | |
| 20 | import { running } from "../../kernel/version.ts"; | |
| 19 | 21 | import type { Repo } from "../../core/repos.ts"; |
| 20 | 22 | import type { RepoFacts, RepoSource } from "../../view/source.ts"; |
| 21 | 23 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -135,6 +137,28 @@ export function aboutScreen( | ||
| 135 | 137 | }; |
| 136 | 138 | } |
| 137 | 139 | |
| 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 | ||
| 138 | 162 | /** |
| 139 | 163 | * The label/value block. A repository we hold gets the clone URLs (the single |
| 140 | 164 | * most-copied string on any forge, and the one thing a terminal can hand over |
| @@ -171,6 +195,7 @@ function aboutFacts( | ||
| 171 | 195 | const upstream = forkOf(f, src); |
| 172 | 196 | const fork: [string, string][] = |
| 173 | 197 | upstream === "" ? [] : [["fork of", upstream]]; |
| 198 | const releases = releasesRow(f, repo); | |
| 174 | 199 | // "browsing" only when it is not the obvious answer: a row that repeats |
| 175 | 200 | // the line above it is a row nobody reads. |
| 176 | 201 | const browsing: [string, string][] = |
| @@ -181,6 +206,7 @@ function aboutFacts( | ||
| 181 | 206 | ...browsing, |
| 182 | 207 | ...counts, |
| 183 | 208 | ...fork, |
| 209 | ...releases, | |
| 184 | 210 | ...originFacts(repo), |
| 185 | 211 | ...cloneFacts(f, repo), |
| 186 | 212 | ]; |
src/web/pages/following.ts +49 −1
| @@ -31,6 +31,13 @@ import { | ||
| 31 | 31 | type Converge, |
| 32 | 32 | } from "../../crew/converge.ts"; |
| 33 | 33 | 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"; | |
| 34 | 41 | import { loadRepo, notFoundPage, repoBase, signInFirst } from "../chrome.ts"; |
| 35 | 42 | import { openProposal, proposeUpstream } from "../../actions/upstream.ts"; |
| 36 | 43 | import { field, readForm } from "../form.ts"; |
| @@ -98,6 +105,8 @@ export function lineageCard(lineage: Lineage, hereId: string): Html { | ||
| 98 | 105 | export interface Following { |
| 99 | 106 | readonly relation: ForkRelation; |
| 100 | 107 | 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; | |
| 101 | 110 | } |
| 102 | 111 | |
| 103 | 112 | /** Everything the card needs, for a repo that follows something. */ |
| @@ -114,7 +123,43 @@ export async function followingOf( | ||
| 114 | 123 | relation.state === "diverged" && !relation.clean |
| 115 | 124 | ? convergeState(ctx.app, repo, relation.parent) |
| 116 | 125 | : 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 |
||
| 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>`; | |
| 118 | 163 | } |
| 119 | 164 | |
| 120 | 165 | function plural(n: number, one: string, many = `${one}s`): string { |
| @@ -303,6 +348,9 @@ export function followingCard( | ||
| 303 | 348 | >Reconnect it.</a |
| 304 | 349 | >`} |
| 305 | 350 | </p> |
| 351 | ${following.releases === undefined | |
| 352 | ? null | |
| 353 | : releasesBlock(following.releases.url, following.releases.view)} | |
| 306 | 354 | ${when( |
| 307 | 355 | actions.length > 0, |
| 308 | 356 | () => html`<div class="following-actions">${actions}</div>`, |
src/web/standards.ts +19
| @@ -6,6 +6,8 @@ | ||
| 6 | 6 | * /.well-known/change-password where a password manager sends someone |
| 7 | 7 | * /robots.txt what a crawler may index, and the sitemap |
| 8 | 8 | * /sitemap.xml every public repository |
| 9 | * /releases.json the releases this forge has shipped, newest | |
| 10 | * first, for a child forge to read | |
| 9 | 11 | * /manifest.webmanifest the forge as an installable app |
| 10 | 12 | * /llms.txt the forge for an agent: what it is, how to |
| 11 | 13 | * report a problem without an account, how |
| @@ -18,6 +20,7 @@ | ||
| 18 | 20 | * FORGE_SECURITY_CONTACT. |
| 19 | 21 | */ |
| 20 | 22 | |
| 23 | import releases from "../core/releases.json" with { type: "json" }; | |
| 21 | 24 | import { markPng, type MarkShape } from "../design/mark.ts"; |
| 22 | 25 | import { BODY_MAX, TITLE_MAX } from "../core/reports.ts"; |
| 23 | 26 | import { canRead, getRepo, listReposVisibleTo } from "../core/repos.ts"; |
| @@ -88,6 +91,22 @@ export function changePassword(): Response { | ||
| 88 | 91 | }); |
| 89 | 92 | } |
| 90 | 93 | |
| 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 | ||
| 91 | 110 | /** GET /robots.txt */ |
| 92 | 111 | export function robotsTxt(ctx: Ctx): Response { |
| 93 | 112 | return text( |
test/following.test.ts +185
| @@ -8,6 +8,9 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | ||
| 8 | 8 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | 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"; | |
| 11 | 14 | import { createApp, type App } from "../src/app.ts"; |
| 12 | 15 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 13 | 16 | import { startChecks, type ChecksHandle } from "../src/checks/service.ts"; |
| @@ -486,6 +489,167 @@ describe("a fork that follows its parent", () => { | ||
| 486 | 489 | expect(page).toContain("ahead"); |
| 487 | 490 | }); |
| 488 | 491 | |
| 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 |
||
| 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 <b>x</b>"); | |
| 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 | ||
| 489 | 653 | test("the family is a tree: a fork of a fork nests under it, from any page in it", async () => { |
| 490 | 654 | const f = await createUser(app.db, { trusted: true, |
| 491 | 655 | username: "fay", |
| @@ -802,3 +966,24 @@ describe("a landed pull moves the forks, like the push it amounts to", () => { | ||
| 802 | 966 | expect(checked(parent)).toBeGreaterThan(0); |
| 803 | 967 | }); |
| 804 | 968 | }); |
| 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 @@ | ||
| 4 | 4 | * an installable manifest with real icons, and the metadata a link needs |
| 5 | 5 | * to unfurl. |
| 6 | 6 | */ |
| 7 | import { parseReleases } from "../src/core/parent_releases.ts"; | |
| 8 | import releasesFile from "../src/core/releases.json" with { type: "json" }; | |
| 7 | 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 8 | 10 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 11 | import { tmpdir } from "node:os"; |
| @@ -82,6 +84,17 @@ describe("what the open web expects", () => { | ||
| 82 | 84 | expect(res.headers.get("location")).toBe("/settings/password"); |
| 83 | 85 | }); |
| 84 | 86 | |
| 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 | ||
| 85 | 98 | test("robots keeps crawlers out of accounts and points at the sitemap", async () => { |
| 86 | 99 | const body = await fetch(`${base}/robots.txt`).then((r) => r.text()); |
| 87 | 100 | expect(body).toContain("User-agent: *"); |
Nit: this local
runningshadows therunningimported fromkernel/version.tson line 20. Renaming it toinFlightwould remove the trap for the next edit.