Public: anyone can read this; the crew can change it.
/docs opens the forge's documentation instead of a 404 #10958
mason/forge:mason/10894-docs-opens-the-forge-s-documentation-ins → main 3 commits fork
4 files
changed +98
−0
src/http/server.ts +3
| @@ -164,6 +164,7 @@ import { apiPage, appApiPage } from "../web/pages/api.ts"; | ||
| 164 | 164 | import { flowsCheck, flowsPage, flowsProbe, flowsUpdate } from "../web/pages/flows.ts"; |
| 165 | 165 | import { findingAccept, findingsPage } from "../web/pages/findings.ts"; |
| 166 | 166 | import { betAct, betPage, betPropose, betsPage } from "../web/pages/bets.ts"; |
| 167 | import { docsFile, docsFolder } from "../web/pages/docs_link.ts"; | |
| 167 | 168 | import { changelogJson, changelogPage } from "../web/pages/changelog.ts"; |
| 168 | 169 | import { releaseHoldSubmit, liftSubmit, releasesPage, rollbackSubmit } from "../web/pages/releases.ts"; |
| 169 | 170 | import { |
| @@ -752,6 +753,8 @@ export function buildRouter(): Router<Handler> { | ||
| 752 | 753 | router.add("GET", "/.well-known/mcp.json", mcpJson); |
| 753 | 754 | router.add("GET", "/.well-known/forge", forgeJson); |
| 754 | 755 | router.add("GET", "/llms.txt", llmsTxt); |
| 756 | router.add("GET", "/docs", docsFolder); | |
| 757 | router.add("GET", "/docs/:name", docsFile); | |
| 755 | 758 | // The same words as a page, for an assistant whose reader takes only HTML. |
| 756 | 759 | router.add("GET", "/agents", agentsPage); |
| 757 | 760 | router.add("GET", "/.well-known/change-password", changePassword); |
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 href="/docs">Docs</a> | |
| 596 | 597 | <form method="dialog"> |
| 597 | 598 | <button class="btn" type="submit">Close</button> |
| 598 | 599 | </form> |
| @@ -715,6 +716,7 @@ function footer(ctx: Ctx, own?: Html): Html { | ||
| 715 | 716 | <span class="spacer"></span> |
| 716 | 717 | <a href="/changelog">Changelog</a> |
| 717 | 718 | <a href="/api">API</a> |
| 719 | <a href="/docs">Docs</a> | |
| 718 | 720 | ${ctx.auth.user !== undefined && ctx.auth.user.kind !== "org" |
| 719 | 721 | ? html`<a class="btn" data-variant="link" data-size="sm" href="/#composer">Tell the crew</a>` |
| 720 | 722 | : ""} |
src/web/pages/docs_link.ts added +37
| @@ -0,0 +1,37 @@ | ||
| 1 | /** | |
| 2 | * GET /docs and GET /docs/:name.md: the way into the forge's own | |
| 3 | * documentation. A redirect to the `docs/` folder of the forge's source while | |
| 4 | * that repository is public here, else the same folder on the upstream. The | |
| 5 | * target is built only from configuration; the one piece of the request that | |
| 6 | * reaches it, the file name, is validated to `[A-Za-z0-9_-]+` first. | |
| 7 | */ | |
| 8 | ||
| 9 | import { UPSTREAM_REPO } from "../../core/upstream.ts"; | |
| 10 | import { offeredPluginRepo } from "../../core/mcp_card.ts"; | |
| 11 | import type { Ctx } from "../../http/context.ts"; | |
| 12 | import { notFound } from "../../http/respond.ts"; | |
| 13 | ||
| 14 | const DOC_NAME = /^[A-Za-z0-9_-]+$/; | |
| 15 | const BRANCH = "main"; | |
| 16 | ||
| 17 | function redirect(location: string): Response { | |
| 18 | return new Response(null, { status: 302, headers: { location, "cache-control": "no-store" } }); | |
| 19 | } | |
| 20 | ||
| 21 | /** The folder (or one file in it) of the docs, as a same-origin path or an upstream URL. */ | |
| 22 | export function docsTarget(repo: string | undefined, name?: string): string { | |
| 23 | const rest = name === undefined ? `tree/${BRANCH}/docs` : `blob/${BRANCH}/docs/${name}.md`; | |
| 24 | const base = repo === undefined || repo.startsWith("https://") ? (repo ?? UPSTREAM_REPO) : `/${repo}`; | |
| 25 | return `${base}/${rest}`; | |
| 26 | } | |
| 27 | ||
| 28 | export function docsFolder(ctx: Ctx): Response { | |
| 29 | return redirect(docsTarget(offeredPluginRepo(ctx.app.db, ctx.app.config.forgeRepo, UPSTREAM_REPO))); | |
| 30 | } | |
| 31 | ||
| 32 | export function docsFile(ctx: Ctx): Response { | |
| 33 | const raw = ctx.params.name ?? ""; | |
| 34 | const name = raw.endsWith(".md") ? raw.slice(0, -3) : undefined; | |
| 35 | if (name === undefined || !DOC_NAME.test(name)) return notFound(); | |
| 36 | return redirect(docsTarget(offeredPluginRepo(ctx.app.db, ctx.app.config.forgeRepo, UPSTREAM_REPO), name)); | |
| 37 | } |
test/docs-link.test.ts added +56
| @@ -0,0 +1,56 @@ | ||
| 1 | /** /docs redirects to the docs folder: here when the forge's repository is public, else upstream. */ | |
| 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/actions/repos.ts"; | |
| 10 | import { UPSTREAM_REPO } from "../src/core/upstream.ts"; | |
| 11 | ||
| 12 | let dataDir: string; | |
| 13 | let app: ReturnType<typeof createApp>; | |
| 14 | let servers: Servers; | |
| 15 | const get = (path: string) => fetch(servers.url + path, { redirect: "manual" }); | |
| 16 | ||
| 17 | beforeAll(async () => { | |
| 18 | dataDir = await mkdtemp(join(tmpdir(), "forge-docs-")); | |
| 19 | app = createApp({ dataDir, port: 0, forgeRepo: "alice/forge-code" }); | |
| 20 | servers = await serve(app); | |
| 21 | const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 22 | if (!a.ok) throw new Error("user"); | |
| 23 | (globalThis as { __alice?: unknown }).__alice = a.value; | |
| 24 | }, 30_000); | |
| 25 | ||
| 26 | afterAll(async () => { | |
| 27 | servers.stop(); | |
| 28 | app.close(); | |
| 29 | await rm(dataDir, { recursive: true, force: true }); | |
| 30 | }); | |
| 31 | ||
| 32 | test("without a public forge repository /docs goes upstream", async () => { | |
| 33 | const r = await get("/docs"); | |
| 34 | expect(r.status).toBe(302); | |
| 35 | expect(r.headers.get("location")).toBe(`${UPSTREAM_REPO}/tree/main/docs`); | |
| 36 | expect((await get("/docs/VISION.md")).headers.get("location")).toBe(`${UPSTREAM_REPO}/blob/main/docs/VISION.md`); | |
| 37 | }); | |
| 38 | ||
| 39 | test("with a public forge repository /docs stays on this origin", async () => { | |
| 40 | const alice = (globalThis as { __alice?: never }).__alice as never; | |
| 41 | await createRepo(app, alice, { name: "forge-code", visibility: "public" }); | |
| 42 | const r = await get("/docs"); | |
| 43 | expect(r.status).toBe(302); | |
| 44 | expect(r.headers.get("location")).toBe("/alice/forge-code/tree/main/docs"); | |
| 45 | expect((await get("/docs/PRINCIPLES.md")).headers.get("location")).toBe("/alice/forge-code/blob/main/docs/PRINCIPLES.md"); | |
| 46 | }); | |
| 47 | ||
| 48 | test("a name outside [A-Za-z0-9_-]+ is a 404", async () => { | |
| 49 | for (const p of ["/docs/..%2Fx", "/docs/..%2Fx.md", "/docs/a.b.md", "/docs/VISION", "/docs/%2F.md"]) { | |
| 50 | expect((await get(p)).status).toBe(404); | |
| 51 | } | |
| 52 | }); | |
| 53 | ||
| 54 | test("the footer links Docs", async () => { | |
| 55 | expect(await (await get("/")).text()).toContain('href="/docs"'); | |
| 56 | }); |
new above
offline