Public: anyone can read this; the crew can change it.
/docs opens the forge's documentation instead of a 404 #11043
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10894-docs-opens-the-forge-s-documentation-ins → main 8 commits fork 3 threads · 3 unresolved
11 files
changed +123
−2
docs/features/docs-link.md added +5
| @@ -0,0 +1,5 @@ | ||
| 1 | # /docs | |
| 2 | ||
| 3 | `GET /docs` redirects (302) to the `docs/` folder of the forge's own repository (`FORGE_SENTINEL_REPO`, default `trevato/forge`) when it is public here; otherwise to the same folder on the upstream that llms.txt's clone line names. `GET /docs/NAME.md` does the same for one file; NAME must match `[A-Za-z0-9_-]+`, anything else is a 404. The target comes from configuration only. Footer and the `?` sheet link "Docs". Code: `src/web/pages/docs_link.ts`. | |
| 4 | ||
| 5 | Only files directly in `docs/` are reachable as `/docs/NAME.md`; files in subfolders (`design/`, `features/`) are not, since NAME cannot contain a slash. Reach them by browsing from `/docs`. |
src/http/server.ts +4
| @@ -167,6 +167,7 @@ import { flowsCheck, flowsPage, flowsProbe, flowsUpdate } from "../web/pages/flo | ||
| 167 | 167 | import { findingAccept, findingsPage } from "../web/pages/findings.ts"; |
| 168 | 168 | import { betAct, betPage, betPropose, betsPage } from "../web/pages/bets.ts"; |
| 169 | 169 | import { changelogJson, changelogPage } from "../web/pages/changelog.ts"; |
| 170 | import { docsFile, docsFolder } from "../web/pages/docs_link.ts"; | |
| 170 | 171 | import { releaseHoldSubmit, liftSubmit, releasesPage, rollbackSubmit } from "../web/pages/releases.ts"; |
| 171 | 172 | import { |
| 172 | 173 | changePassword, |
| @@ -921,6 +922,9 @@ export function buildRouter(): Router<Handler> { | ||
| 921 | 922 | router.add("GET", "/now/:stage", nowPage); |
| 922 | 923 | router.add("GET", "/audit", auditPage); |
| 923 | 924 | router.add("GET", "/releases", releasesPage); |
| 925 | // The forge's own documentation: a redirect built from configuration only. | |
| 926 | router.add("GET", "/docs", docsFolder); | |
| 927 | router.add("GET", "/docs/:file", docsFile); | |
| 924 | 928 | router.add("GET", "/changelog", changelogPage); |
| 925 | 929 | router.add("GET", "/changelog.atom", changelogAtom); |
| 926 | 930 | router.add("GET", "/:owner/:name/api", appApiPage); |
src/web/layout.ts +2
| @@ -593,6 +593,7 @@ function overlays(): Html { | ||
| 593 | 593 | <div class="dialog-head"><h2 id="help-title">Keyboard shortcuts</h2></div> |
| 594 | 594 | <div class="dialog-body" data-help-body></div> |
| 595 | 595 | <div class="dialog-foot"> |
| 596 | <a class="btn" data-variant="link" href="/docs">Docs</a> | |
| 596 | 597 | <form method="dialog"> |
| 597 | 598 | <button class="btn" type="submit">Close</button> |
| 598 | 599 | </form> |
| @@ -713,6 +714,7 @@ function footer(ctx: Ctx, own?: Html): Html { | ||
| 713 | 714 | >${reduced}</span |
| 714 | 715 | >`} |
| 715 | 716 | <span class="spacer"></span> |
| 717 | <a href="/docs">Docs</a> | |
| 716 | 718 | <a href="/changelog">Changelog</a> |
| 717 | 719 | <a href="/api">API</a> |
| 718 | 720 | ${ctx.auth.user !== undefined && ctx.auth.user.kind !== "org" |
src/web/pages/docs_link.ts added +45
| @@ -0,0 +1,45 @@ | ||
| 1 | /** | |
| 2 | * /docs and /docs/NAME.md: the way into the forge's own documentation. | |
| 3 | * The target is built from configuration alone (the own repository's path | |
| 4 | * and the upstream named in llms.txt), never from the request: only NAME, | |
| 5 | * checked against `[A-Za-z0-9_-]+`, is spliced in, so there is no open | |
| 6 | * redirect. A public own repository answers with a same-origin path; | |
| 7 | * otherwise the same folder on the upstream. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { Database } from "bun:sqlite"; | |
| 11 | import { getRepo } from "../../core/repos.ts"; | |
| 12 | import { ownRepoPath } from "../../kernel/own_repo.ts"; | |
| 13 | import type { Ctx } from "../../http/context.ts"; | |
Double blank line. Also replace the %KIND% placeholder with a |
||
| 14 | import { notFound } from "../../http/respond.ts"; | |
| 15 | import { UPSTREAM_REPO as UPSTREAM } from "../../core/upstream.ts"; | |
| 16 | ||
| 17 | const NAME = /^[A-Za-z0-9_-]+$/; | |
| 18 | ||
| 19 | /** Where the docs live for this forge: a path here, or the upstream's URL. */ | |
| 20 | export function docsBase(db: Database, kind: "tree" | "blob"): string { | |
| 21 | const [owner = "", name = ""] = ownRepoPath().split("/"); | |
| 22 | const repo = owner === "" || name === "" ? undefined : getRepo(db, owner, name); | |
| 23 | if (repo !== undefined && repo.visibility === "public") | |
| 24 | return `/${repo.ownerName}/${repo.name}/${kind}/${encodeURIComponent(repo.defaultBranch)}/docs`; | |
| 25 | return `${UPSTREAM}/${kind}/main/docs`; | |
| 26 | } | |
| 27 | ||
| 28 | /** The target for the folder (`file` absent) or one file; undefined for a bad NAME. */ | |
| 29 | export function docsTarget(db: Database, file?: string): string | undefined { | |
| 30 | if (file === undefined) return docsBase(db, "tree"); | |
| 31 | if (!file.endsWith(".md") || !NAME.test(file.slice(0, -3))) return undefined; | |
| 32 | return `${docsBase(db, "blob")}/${file}`; | |
| 33 | } | |
| 34 | ||
| 35 | const go = (to: string): Response => | |
| 36 | new Response(null, { status: 302, headers: { location: to, "cache-control": "no-store" } }); | |
| 37 | ||
| 38 | export function docsFolder(ctx: Ctx): Response { | |
| 39 | return go(docsBase(ctx.app.db, "tree")); | |
| 40 | } | |
| 41 | ||
| 42 | export function docsFile(ctx: Ctx): Response { | |
| 43 | const to = docsTarget(ctx.app.db, ctx.params.file); | |
| 44 | return to === undefined ? notFound() : go(to); | |
| 45 | } |
test/design-audit.test.ts +1 −1
| @@ -15,7 +15,7 @@ const doc = readFileSync("docs/design/audit.md", "utf8"); | ||
| 15 | 15 | |
| 16 | 16 | /** Routes that answer a file, a fragment, a redirect hop or a callback. */ |
| 17 | 17 | const NOT_PAGES = |
| 18 | /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|new\/issue$|events$|system\/stream$|talk\/stream$|settings\/teams\/stream$|sw\.js$|robots\.txt$|favicon\.ico$|apple-touch-icon(-precomposed)?\.png$|sitemap\.xml$|manifest\.webmanifest$|opensearch\.xml$|llms\.txt$|openapi\.json$|releases\.json$|changelog\.atom$|audit\.csv$|settings\/traffic\/scrapes\/export$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/|settings\/backups\/live$|api\/v1(\/|$))/; | |
| 18 | /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|new\/issue$|events$|system\/stream$|talk\/stream$|settings\/teams\/stream$|sw\.js$|robots\.txt$|favicon\.ico$|apple-touch-icon(-precomposed)?\.png$|sitemap\.xml$|manifest\.webmanifest$|opensearch\.xml$|llms\.txt$|openapi\.json$|releases\.json$|changelog\.atom$|docs(\/[^/]*)?$|audit\.csv$|settings\/traffic\/scrapes\/export$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/|settings\/backups\/live$|api\/v1(\/|$))/; | |
| 19 | 19 | |
| 20 | 20 | const getRoutes = [ |
| 21 | 21 | ...server.matchAll(/router\.add\(\s*"GET",\s*"([^"]+)"/g), |
test/docs-link.test.ts added +61
| @@ -0,0 +1,61 @@ | ||
| 1 | /** /docs sends a reader to the forge's documentation, from configuration only. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp } from "../src/app.ts"; | |
| 7 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { createRepo } from "../src/core/repos.ts"; | |
| 10 | import { UPSTREAM_REPO as UPSTREAM } from "../src/core/upstream.ts"; | |
| 11 | ||
| 12 | let dataDir: string; | |
| 13 | let app: ReturnType<typeof createApp>; | |
| 14 | let servers: Servers; | |
| 15 | let ownerId: string; | |
| 16 | ||
| 17 | beforeAll(async () => { | |
| 18 | dataDir = await mkdtemp(join(tmpdir(), "forge-docs-")); | |
| 19 | app = createApp({ dataDir, port: 0 }); | |
| 20 | servers = await serve(app); | |
| 21 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "trevato", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 22 | if (!made.ok) throw new Error(made.error); | |
| 23 | ownerId = made.value.id; | |
| 24 | }, 30_000); | |
| 25 | ||
| 26 | afterAll(async () => { | |
| 27 | servers.stop(); | |
| 28 | app.close(); | |
| 29 | await rm(dataDir, { recursive: true, force: true }); | |
| 30 | }); | |
| 31 | ||
| 32 | const get = (path: string) => fetch(`${servers.url}${path}`, { redirect: "manual" }); | |
| 33 | ||
| 34 | test("without a public forge repository /docs goes to the upstream", async () => { | |
| 35 | const res = await get("/docs"); | |
| 36 | expect(res.status).toBe(302); | |
| 37 | expect(res.headers.get("location")).toBe(`${UPSTREAM}/tree/main/docs`); | |
| 38 | expect((await get("/docs/VISION.md")).headers.get("location")).toBe(`${UPSTREAM}/blob/main/docs/VISION.md`); | |
| 39 | }); | |
| 40 | ||
| 41 | test("with a public forge repository /docs goes to a same-origin path", async () => { | |
| 42 | const made = await createRepo(app.db, app.reposRoot, { ownerId, name: "forge", visibility: "public" }); | |
| 43 | if (!made.ok) throw new Error(String(made.error)); | |
| 44 | const res = await get("/docs"); | |
| 45 | expect(res.status).toBe(302); | |
| 46 | expect(res.headers.get("location")).toBe("/trevato/forge/tree/main/docs"); | |
| 47 | expect((await get("/docs/PRINCIPLES.md")).headers.get("location")).toBe("/trevato/forge/blob/main/docs/PRINCIPLES.md"); | |
| 48 | }); | |
| 49 | ||
| 50 | test("a name outside [A-Za-z0-9_-]+ is a 404, never a redirect", async () => { | |
| 51 | for (const bad of ["..%2Fx", "..%2Fx.md", "a.b.md", "x", "%2F%2Fevil.md", ".md", "a%20b.md"]) | |
| 52 | expect((await get(`/docs/${bad}`)).status).toBe(404); | |
| 53 | }); | |
| 54 | ||
| 55 | test("the footer links Docs", async () => { | |
| 56 | expect(await (await fetch(`${servers.url}/changelog`)).text()).toContain('<a href="/docs">Docs</a>'); | |
| 57 | }); | |
| 58 | ||
| 59 | test("the ? sheet carries a Docs link", async () => { | |
| 60 | expect(await (await fetch(`${servers.url}/changelog`)).text()).toContain('<a class="btn" data-variant="link" href="/docs">Docs</a>'); | |
| 61 | }); |
test/forge-looks.test.ts +1
| @@ -34,6 +34,7 @@ const NOT_A_PAGE: Record<string, string> = { | ||
| 34 | 34 | "/openapi.json": "a redirect to the contract document", |
| 35 | 35 | "/favicon.ico": "the tab icon, an image", |
| 36 | 36 | "/releases.json": "JSON feed of /releases", |
| 37 | "/docs": "a redirect to the forge's documentation", | |
| 37 | 38 | "/changelog.atom": "Atom feed of /changelog", |
| 38 | 39 | "/sitemap.xml": "XML for crawlers", |
| 39 | 40 | "/manifest.webmanifest": "the install manifest, JSON", |
test/no-polling.test.ts +1
| @@ -310,6 +310,7 @@ const NOT_WALKED: Record<string, string> = { | ||
| 310 | 310 | "/boards/:slug/live": "needs a board; a stream, not a page", |
| 311 | 311 | "/bring/:id/:job": "needs a bring job", |
| 312 | 312 | "/claim/start/:token": "needs the one-time token an anonymous start returned", |
| 313 | "/docs/:file": "a redirect to the documentation, not a page; the file name is one the seed cannot guess", | |
| 313 | 314 | "/fleet/ops/:id": "needs a fleet operation", |
| 314 | 315 | "/go/:code": "needs a short code minted by a share", |
| 315 | 316 | "/home/archived/:op": "needs an archived-home operation", |
test/pages-budget.test.ts +1 −1
| @@ -41,7 +41,7 @@ const HEAVIER: Record<string, { statements?: number; git?: number }> = { | ||
| 41 | 41 | }; |
| 42 | 42 | |
| 43 | 43 | /** Patterns with no fixture today: lower this as fixtures are added, never raise it silently. */ |
| 44 | const MAX_UNFIXTURED = 144; | |
| 44 | const MAX_UNFIXTURED = 145; | |
| 45 | 45 | |
| 46 | 46 | /** Streams and live fragments: no page. */ |
| 47 | 47 | const NOT_PAGES = new Set([ |
test/security-floor-routes.test.ts +1
| @@ -62,6 +62,7 @@ const UNSEEDED: Record<string, string> = { | ||
| 62 | 62 | "/:owner/-/teams/:team": ORG, |
| 63 | 63 | "/claim/start/:token": "needs a one-time start claim token, which the seed does not mint (test/start.test.ts covers it)", |
| 64 | 64 | "/talk/:id": TALK, |
| 65 | "/docs/:file": "a file name of the documentation folder; the seed supplies no :file", | |
| 65 | 66 | "/bring/:id/:job": "needs a bring job of the signed-in person; the seed brings nothing", |
| 66 | 67 | "/fleet/ops/:id": FLEET, |
| 67 | 68 | "/:owner/:name/bets/:id": BET, |
test/server-timing-sweep.test.ts +1
| @@ -47,6 +47,7 @@ const REDIRECTS = new Set([ | ||
| 47 | 47 | "/alice/forge/tells", |
| 48 | 48 | "/alice/forge/tells/new", |
| 49 | 49 | "/big", |
| 50 | "/docs", | |
| 50 | 51 | "/login", |
| 51 | 52 | "/openapi.json", |
| 52 | 53 | "/settings/origins/callback", |
The Docs link in the ? sheet has no test.