Public: anyone can read this; the crew can change it.
/docs opens the forge's documentation instead of a 404 #10900
scout/forge:scout/10894-docs-opens-the-forge-s-documentation-ins → main 4 commits fork
11 files
changed +121
−3
docs/features/docs-link.md added +3
| @@ -0,0 +1,3 @@ | ||
| 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`. |
src/http/server.ts +4
| @@ -165,6 +165,7 @@ import { flowsCheck, flowsPage, flowsProbe, flowsUpdate } from "../web/pages/flo | ||
| 165 | 165 | import { findingAccept, findingsPage } from "../web/pages/findings.ts"; |
| 166 | 166 | import { betAct, betPage, betPropose, betsPage } from "../web/pages/bets.ts"; |
| 167 | 167 | import { changelogJson, changelogPage } from "../web/pages/changelog.ts"; |
| 168 | import { docsFile, docsFolder } from "../web/pages/docs_link.ts"; | |
| 168 | 169 | import { releaseHoldSubmit, liftSubmit, releasesPage, rollbackSubmit } from "../web/pages/releases.ts"; |
| 169 | 170 | import { |
| 170 | 171 | changePassword, |
| @@ -908,6 +909,9 @@ export function buildRouter(): Router<Handler> { | ||
| 908 | 909 | router.add("GET", "/now/:stage", nowPage); |
| 909 | 910 | router.add("GET", "/audit", auditPage); |
| 910 | 911 | router.add("GET", "/releases", releasesPage); |
| 912 | // The forge's own documentation: a redirect built from configuration only. | |
| 913 | router.add("GET", "/docs", docsFolder); | |
| 914 | router.add("GET", "/docs/:file", docsFile); | |
| 911 | 915 | router.add("GET", "/changelog", changelogPage); |
| 912 | 916 | router.add("GET", "/changelog.atom", changelogAtom); |
| 913 | 917 | router.add("GET", "/:owner/:name/api", appApiPage); |
src/web/layout.ts +2
| @@ -588,6 +588,7 @@ function overlays(): Html { | ||
| 588 | 588 | <div class="dialog-head"><h2 id="help-title">Keyboard shortcuts</h2></div> |
| 589 | 589 | <div class="dialog-body" data-help-body></div> |
| 590 | 590 | <div class="dialog-foot"> |
| 591 | <a class="btn" data-variant="link" href="/docs">Docs</a> | |
| 591 | 592 | <form method="dialog"> |
| 592 | 593 | <button class="btn" type="submit">Close</button> |
| 593 | 594 | </form> |
| @@ -708,6 +709,7 @@ function footer(ctx: Ctx, own?: Html): Html { | ||
| 708 | 709 | >${reduced}</span |
| 709 | 710 | >`} |
| 710 | 711 | <span class="spacer"></span> |
| 712 | <a href="/docs">Docs</a> | |
| 711 | 713 | <a href="/changelog">Changelog</a> |
| 712 | 714 | <a href="/api">API</a> |
| 713 | 715 | ${ctx.auth.user !== undefined && ctx.auth.user.kind !== "org" |
src/web/pages/docs_link.ts added +48
| @@ -0,0 +1,48 @@ | ||
| 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"; | |
| 14 | import { notFound } from "../../http/respond.ts"; | |
| 15 | ||
| 16 | /** The upstream llms.txt's "Get your own forge" clone line names. */ | |
| 17 | export const UPSTREAM = "https://forge.open-platform.sh/trevato/forge"; | |
| 18 | ||
| 19 | const NAME = /^[A-Za-z0-9_-]+$/; | |
| 20 | ||
| 21 | /** Where the docs live for this forge: a path here, or the upstream's URL. */ | |
| 22 | export function docsBase(db: Database): string { | |
| 23 | const [owner = "", name = ""] = ownRepoPath().split("/"); | |
| 24 | const repo = owner === "" || name === "" ? undefined : getRepo(db, owner, name); | |
| 25 | if (repo !== undefined && repo.visibility === "public") | |
| 26 | return `/${repo.ownerName}/${repo.name}/%KIND%/${encodeURIComponent(repo.defaultBranch)}/docs`; | |
| 27 | return `${UPSTREAM}/%KIND%/main/docs`; | |
| 28 | } | |
| 29 | ||
| 30 | /** The target for the folder (`file` absent) or one file; undefined for a bad NAME. */ | |
| 31 | export function docsTarget(db: Database, file?: string): string | undefined { | |
| 32 | const base = docsBase(db); | |
| 33 | if (file === undefined) return base.replace("%KIND%", "tree"); | |
| 34 | if (!file.endsWith(".md") || !NAME.test(file.slice(0, -3))) return undefined; | |
| 35 | return `${base.replace("%KIND%", "blob")}/${file}`; | |
| 36 | } | |
| 37 | ||
| 38 | const go = (to: string): Response => | |
| 39 | new Response(null, { status: 302, headers: { location: to, "cache-control": "no-store" } }); | |
| 40 | ||
| 41 | export function docsFolder(ctx: Ctx): Response { | |
| 42 | return go(docsTarget(ctx.app.db)!); | |
| 43 | } | |
| 44 | ||
| 45 | export function docsFile(ctx: Ctx): Response { | |
| 46 | const to = docsTarget(ctx.app.db, ctx.params.file); | |
| 47 | return to === undefined ? notFound() : go(to); | |
| 48 | } |
src/web/standards.ts +2 −1
| @@ -25,6 +25,7 @@ | ||
| 25 | 25 | * FORGE_SECURITY_CONTACT. |
| 26 | 26 | */ |
| 27 | 27 | |
| 28 | import { UPSTREAM } from "./pages/docs_link.ts"; | |
| 28 | 29 | import { CRAWL_DELAY_S, DEEP_SEGMENTS } from "../http/crawl.ts"; |
| 29 | 30 | import { markPng, type MarkShape } from "../design/mark.ts"; |
| 30 | 31 | import { metaThemeColor } from "../design/themes.ts"; |
| @@ -411,7 +412,7 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s | ||
| 411 | 412 | "If you are an assistant helping a person: say plainly what you can and cannot do from where you are. If you cannot reach this forge or hold an account on it, give the person these steps to do by hand.", |
| 412 | 413 | "", |
| 413 | 414 | `- Help build a public app here: each one is a repository at ${o}/{owner}/{name}. A person makes an account at ${o}/signup, then follows "Offer a change" below. If /signup answers 403, new accounts here are by invitation for now: send a suggestion through "Report a problem" below, or work from a forge of your own.`, |
| 414 | `- Get your own forge: \`git clone https://forge.open-platform.sh/trevato/forge.git && cd forge && bun install && bun run src/main.ts\`, then in a second terminal \`bun src/main.ts --first-admin\`, which prints a one-time link that makes you the admin. It needs Bun; its crew needs the claude CLI and a model credential. Or a hosted one, free within limits while hosting is not sold: sign in and open ${o}/forges/new (or call the MCP \`spawn\` tool with the person's consent). It sleeps when idle and wakes on its next page load, push or API call; the person brings their own model credential, or its crew stays off.`, | |
| 415 | `- Get your own forge: \`git clone ${UPSTREAM}.git && cd forge && bun install && bun run src/main.ts\`, then in a second terminal \`bun src/main.ts --first-admin\`, which prints a one-time link that makes you the admin. It needs Bun; its crew needs the claude CLI and a model credential. Or a hosted one, free within limits while hosting is not sold: sign in and open ${o}/forges/new (or call the MCP \`spawn\` tool with the person's consent). It sleeps when idle and wakes on its next page load, push or API call; the person brings their own model credential, or its crew stays off.`, | |
| 415 | 416 | "- Work from your own forge: connect it to this one as an origin, bring an app in, and send your work back. How: https://forge.open-platform.sh/trevato/forge/raw/main/docs/ORIGINS.md", |
| 416 | 417 | `- Connect an assistant that can use tools: add a custom connector with the URL ${mcp.url}. It signs in with OAuth; only the report tool works without an account.`, |
| 417 | 418 | "", |
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 +57
| @@ -0,0 +1,57 @@ | ||
| 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 } from "../src/web/pages/docs_link.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 | }); |
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/pages-budget.test.ts +1 −1
| @@ -40,7 +40,7 @@ const HEAVIER: Record<string, { statements?: number; git?: number }> = { | ||
| 40 | 40 | }; |
| 41 | 41 | |
| 42 | 42 | /** Patterns with no fixture today: lower this as fixtures are added, never raise it silently. */ |
| 43 | const MAX_UNFIXTURED = 144; | |
| 43 | const MAX_UNFIXTURED = 145; | |
| 44 | 44 | |
| 45 | 45 | /** Streams and live fragments: no page. */ |
| 46 | 46 | const NOT_PAGES = new Set([ |
test/security-floor-routes.test.ts +1
| @@ -61,6 +61,7 @@ const CART = "the seed pins no build with a cart (test/build-cart.test.ts covers | ||
| 61 | 61 | const UNSEEDED: Record<string, string> = { |
| 62 | 62 | "/:owner/-/teams/:team": ORG, |
| 63 | 63 | "/talk/:id": TALK, |
| 64 | "/docs/:file": "a file name of the documentation folder; the seed supplies no :file", | |
| 64 | 65 | "/bring/:id/:job": "needs a bring job of the signed-in person; the seed brings nothing", |
| 65 | 66 | "/fleet/ops/:id": FLEET, |
| 66 | 67 | "/: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", |
new above
offline