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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/relay:scout/32-serve-the-adoption-kit-adopting-and-clie → main 1 commit fork

  • Merged. scout/32-serve-the-adoption-kit-adopting-and-clie was merged into main as 51e2724.

Review

Sign in to review this change.
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
1616
1717 ## Posting events
1818
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`](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.
2323
2424 ## The shape
2525
client/relay.ts +2 −1
@@ -2,7 +2,8 @@
22 * Post an event to relay. Paste this file into another app — it has no
33 * dependency on this repository and no dependency of its own. It speaks
44 * 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.
67 */
78 import { createHash, createHmac, randomBytes } from "node:crypto";
89
src/http.ts +20 −14
@@ -174,17 +174,23 @@ export function staticFile(name: string, versioned: boolean): Response {
174174 });
175175 }
176176
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
6666 <footer class="muted">
6767 ${terminal(ssh)}
6868 <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>
6970 </footer>
7071 </div>
7172 </body>
@@ -148,7 +149,7 @@ const empty = (filtered: boolean): string =>
148149 -H "x-relay-nonce: $NONCE" \\
149150 -H "x-relay-signature: $SIGNATURE" \\
150151 --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>
152153 </div>`;
153154
154155 /** Newest first, grouped under a heading for the day it happened. */
src/routes.ts +12 −3
@@ -13,11 +13,13 @@ import {
1313 type Result,
1414 } from "./actions.ts";
1515 import {
16 docFile,
16 ADOPTING_DOC,
1717 fields,
1818 html,
1919 json,
20 kitFile,
2021 readJson,
22 RELAY_CLIENT,
2123 staticFile,
2224 text,
2325 } from "./http.ts";
@@ -149,8 +151,15 @@ export const routes: readonly Route[] = [
149151 handle: (c) => staticFile(c.params.file ?? "", c.url.searchParams.has("v")),
150152 },
151153
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 },
154163 ];
155164
156165 /** The route for a method and path, with what its `:names` matched. */
src/server.test.ts +55 −3
@@ -1,5 +1,5 @@
11 import { afterAll, expect, test } from "bun:test";
2 import { mkdtempSync } from "node:fs";
2 import { cpSync, mkdtempSync, rmSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
55 import { serve } from "./server.ts";
@@ -147,8 +147,8 @@ test("an empty timeline explains how to post, with no dead ends", async () => {
147147 const freshBase = `http://${fresh.hostname}:${fresh.port}`;
148148 const page = await (await fetch(freshBase + "/")).text();
149149 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);
152152 } finally {
153153 fresh.stop(true);
154154 }
@@ -449,3 +449,55 @@ test("static files are cached for a year when linked by content", async () => {
449449 ).toBe("no-cache");
450450 expect((await fetch(base + "/static/..%2Fpackage.json")).status).toBe(404);
451451 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs