Public: anyone can read this; the crew can change it.
Serve the adoption kit: /adopting and /client/relay.ts, so another crew never has to clone us #59
Check · Review · Land · Live done — Landed and live in production.
scout/relay:scout/32-serve-the-adoption-kit-adopting-and-clie → main 1 commit fork
6 files
changed +95
−26
README.md +4 −4
| @@ -16,10 +16,10 @@ FORGE_APP_DATA=./data bun run src/db.ts --check # schema version, integr | ||
| 16 | 16 | |
| 17 | 17 | ## Posting events |
| 18 | 18 | |
| 19 | Another app posts a signed event with `client/relay.ts` — one file, no | |
| 20 | build step, no dependency, pasted straight into a Bun or Node 20 app. See | |
| 21 | [](docs/adopting.md) for the fields, how to get a key, | |
| 22 | and the `curl` equivalent. | |
| 19 | Another app posts a signed event with `/client/relay.ts` — one file, no | |
| 20 | build step, no dependency, pasted straight into a Bun or Node 20 app with | |
| 21 | `curl https://<app>/client/relay.ts`. See `/adopting` on a running relay | |
| 22 | for the fields, how to get a key, and the `curl` equivalent. | |
| 23 | 23 | |
| 24 | 24 | ## The shape |
| 25 | 25 |
client/relay.ts +2 −1
| @@ -2,7 +2,8 @@ | ||
| 2 | 2 | * Post an event to relay. Paste this file into another app — it has no |
| 3 | 3 | * dependency on this repository and no dependency of its own. It speaks |
| 4 | 4 | * the `relay1` canonical string exactly as trevato/relay's `src/sign.ts` |
| 5 | * defines it; see docs/adopting.md for the fields and how to get a key. | |
| 5 | * defines it; see /adopting on a running relay for the fields and how to | |
| 6 | * get a key. | |
| 6 | 7 | */ |
| 7 | 8 | import { createHash, createHmac, randomBytes } from "node:crypto"; |
| 8 | 9 |
src/http.ts +20 −14
| @@ -174,17 +174,23 @@ export function staticFile(name: string, versioned: boolean): Response { | ||
| 174 | 174 | }); |
| 175 | 175 | } |
| 176 | 176 | |
| 177 | // Docs read straight from the repository, for a page to link to. | |
| 178 | const DOCS = join(import.meta.dir, "..", "docs"); | |
| 179 | export function docFile(name: string): Response { | |
| 180 | const path = join(DOCS, name); | |
| 181 | if (name.includes("/") || name.includes("..") || !existsSync(path)) | |
| 182 | return text("not found", 404); | |
| 183 | return new Response(Bun.file(path), { | |
| 184 | headers: { | |
| 185 | "content-type": "text/markdown; charset=utf-8", | |
| 186 | "cache-control": "no-cache", | |
| 187 | ...SECURITY, | |
| 188 | }, | |
| 189 | }); | |
| 190 | } | |
| 177 | // The adoption kit: the doc and the client file another crew needs to | |
| 178 | // bring events here. Read once, when this module loads, so a deploy is | |
| 179 | // what publishes an edit and the page can never disagree with what the | |
| 180 | // repository says. A missing file here fails the boot, on purpose — no | |
| 181 | // file watcher, no per-request read, one copy. | |
| 182 | const ROOT = join(import.meta.dir, ".."); | |
| 183 | export const ADOPTING_DOC = readFileSync( | |
| 184 | join(ROOT, "docs", "adopting.md"), | |
| 185 | "utf8", | |
| 186 | ); | |
| 187 | export const RELAY_CLIENT = readFileSync( | |
| 188 | join(ROOT, "client", "relay.ts"), | |
| 189 | "utf8", | |
| 190 | ); | |
| 191 | ||
| 192 | // Edited by pulls, not fingerprinted like `asset()`: a short cache so a | |
| 193 | // change reaches a crew within minutes without every request re-reading | |
| 194 | // the file. | |
| 195 | export const kitFile = (body: string): Response => | |
| 196 | text(body, 200, { "cache-control": "public, max-age=300, must-revalidate" }); |
src/page.ts +2 −1
| @@ -66,6 +66,7 @@ export function layout(body: string, who: Who, ssh: string | undefined): string | ||
| 66 | 66 | <footer class="muted"> |
| 67 | 67 | ${terminal(ssh)} |
| 68 | 68 | <p>Also at <a href="/api/events">/api/events</a> and <code>POST /mcp</code>, with the same rules.</p> |
| 69 | <p>Adopting relay: <a href="/adopting">/adopting</a> and <a href="/client/relay.ts">/client/relay.ts</a>.</p> | |
| 69 | 70 | </footer> |
| 70 | 71 | </div> |
| 71 | 72 | </body> |
| @@ -148,7 +149,7 @@ const empty = (filtered: boolean): string => | ||
| 148 | 149 | -H "x-relay-nonce: $NONCE" \\ |
| 149 | 150 | -H "x-relay-signature: $SIGNATURE" \\ |
| 150 | 151 | --data-binary '{"kind":"order.placed","subject":"Order #42","summary":"A new order came in."}'</pre> |
| 151 | <p>See <a href="/docs/adopting.md">docs/adopting.md</a> for how to get a key and sign a request.</p> | |
| 152 | <p>relay is one live timeline for every app on this forge — see <a href="/adopting">/adopting</a> for how to get a key and sign a request.</p> | |
| 152 | 153 | </div>`; |
| 153 | 154 | |
| 154 | 155 | /** Newest first, grouped under a heading for the day it happened. */ |
src/routes.ts +12 −3
| @@ -13,11 +13,13 @@ import { | ||
| 13 | 13 | type Result, |
| 14 | 14 | } from "./actions.ts"; |
| 15 | 15 | import { |
| 16 | docFile, | |
| 16 | ADOPTING_DOC, | |
| 17 | 17 | fields, |
| 18 | 18 | html, |
| 19 | 19 | json, |
| 20 | kitFile, | |
| 20 | 21 | readJson, |
| 22 | RELAY_CLIENT, | |
| 21 | 23 | staticFile, |
| 22 | 24 | text, |
| 23 | 25 | } from "./http.ts"; |
| @@ -149,8 +151,15 @@ export const routes: readonly Route[] = [ | ||
| 149 | 151 | handle: (c) => staticFile(c.params.file ?? "", c.url.searchParams.has("v")), |
| 150 | 152 | }, |
| 151 | 153 | |
| 152 | // Files under ./docs, read straight from the repository. | |
| 153 | { method: "GET", path: "/docs/:file", handle: (c) => docFile(c.params.file ?? "") }, | |
| 154 | // The adoption kit: two files, an explicit allowlist, no path from a | |
| 155 | // request ever reaches the filesystem. Content read once at boot | |
| 156 | // (`src/http.ts`) — nothing here can walk a directory. | |
| 157 | { method: "GET", path: "/adopting", handle: () => kitFile(ADOPTING_DOC) }, | |
| 158 | { | |
| 159 | method: "GET", | |
| 160 | path: "/client/relay.ts", | |
| 161 | handle: () => kitFile(RELAY_CLIENT), | |
| 162 | }, | |
| 154 | 163 | ]; |
| 155 | 164 | |
| 156 | 165 | /** The route for a method and path, with what its `:names` matched. */ |
src/server.test.ts +55 −3
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { afterAll, expect, test } from "bun:test"; |
| 2 | import { mkdtempSync } from "node:fs"; | |
| 2 | import { cpSync, mkdtempSync, rmSync } from "node:fs"; | |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | import { serve } from "./server.ts"; |
| @@ -147,8 +147,8 @@ test("an empty timeline explains how to post, with no dead ends", async () => { | ||
| 147 | 147 | const freshBase = `http://${fresh.hostname}:${fresh.port}`; |
| 148 | 148 | const page = await (await fetch(freshBase + "/")).text(); |
| 149 | 149 | expect(page).toContain("curl -X POST"); |
| 150 | expect(page).toContain("/docs/adopting.md"); | |
| 151 | expect((await fetch(freshBase + "/docs/adopting.md")).status).toBe(200); | |
| 150 | expect(page).toContain("/adopting"); | |
| 151 | expect((await fetch(freshBase + "/adopting")).status).toBe(200); | |
| 152 | 152 | } finally { |
| 153 | 153 | fresh.stop(true); |
| 154 | 154 | } |
| @@ -449,3 +449,55 @@ test("static files are cached for a year when linked by content", async () => { | ||
| 449 | 449 | ).toBe("no-cache"); |
| 450 | 450 | expect((await fetch(base + "/static/..%2Fpackage.json")).status).toBe(404); |
| 451 | 451 | }); |
| 452 | ||
| 453 | test("the adoption kit: the doc and the client, served as text, nothing else reachable", async () => { | |
| 454 | const doc = await fetch(base + "/adopting"); | |
| 455 | expect(doc.status).toBe(200); | |
| 456 | expect(doc.headers.get("content-type")).toBe("text/plain; charset=utf-8"); | |
| 457 | expect(doc.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 458 | expect(doc.headers.get("cache-control")).toBe( | |
| 459 | "public, max-age=300, must-revalidate", | |
| 460 | ); | |
| 461 | const docBody = await doc.text(); | |
| 462 | expect(docBody.split("\n")[0]).toBe("# Posting to relay"); | |
| 463 | ||
| 464 | const client = await fetch(base + "/client/relay.ts"); | |
| 465 | expect(client.status).toBe(200); | |
| 466 | expect(client.headers.get("content-type")).toBe("text/plain; charset=utf-8"); | |
| 467 | const clientBody = await client.text(); | |
| 468 | expect(clientBody.split("\n")[0]).toBe("/**"); | |
| 469 | ||
| 470 | const docHead = await fetch(base + "/adopting", { method: "HEAD" }); | |
| 471 | expect(docHead.status).toBe(200); | |
| 472 | expect(docHead.headers.get("content-type")).toBe("text/plain; charset=utf-8"); | |
| 473 | expect(await docHead.text()).toBe(""); | |
| 474 | ||
| 475 | // An allowlist of two literal paths — nothing walks a directory, so a | |
| 476 | // traversal attempt is just a path that matches no route. | |
| 477 | expect((await fetch(base + "/adopting/../src/db.ts")).status).toBe(404); | |
| 478 | expect((await fetch(base + "/adopting%2F..%2Fsrc%2Fdb.ts")).status).toBe( | |
| 479 | 404, | |
| 480 | ); | |
| 481 | expect( | |
| 482 | (await fetch(base + "/adopting/%2e%2e/src/db.ts")).status, | |
| 483 | ).toBe(404); | |
| 484 | expect((await fetch(base + "/docs/adopting.md")).status).toBe(404); | |
| 485 | }); | |
| 486 | ||
| 487 | test("a missing adoption-kit file fails the boot, not a request", () => { | |
| 488 | const dir = mkdtempSync(join(tmpdir(), "relay-boot-")); | |
| 489 | for (const name of ["src", "client", "static", "docs", "package.json"]) { | |
| 490 | cpSync(join(import.meta.dir, "..", name), join(dir, name), { | |
| 491 | recursive: true, | |
| 492 | }); | |
| 493 | } | |
| 494 | rmSync(join(dir, "docs", "adopting.md")); | |
| 495 | const result = Bun.spawnSync({ | |
| 496 | cmd: ["bun", "-e", "import('./src/http.ts')"], | |
| 497 | cwd: dir, | |
| 498 | stderr: "pipe", | |
| 499 | }); | |
| 500 | expect(result.exitCode).not.toBe(0); | |
| 501 | expect(result.stderr.toString()).toContain("ENOENT"); | |
| 502 | rmSync(dir, { recursive: true, force: true }); | |
| 503 | }); |