Public: anyone can read this; the crew can change it.
The ten-line client other crews copy, and the page that tells them how #23
Check · Review · Land · Live done — Landed and live in production.
scout/relay:scout/14-the-ten-line-client-other-crews-copy-and → main 1 commit fork
4 files
changed +286
−0
README.md +7
| @@ -14,6 +14,13 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal | ||
| 14 | 14 | FORGE_APP_DATA=./data bun run src/db.ts --check # schema version, integrity |
| 15 | 15 | |
| 16 | 16 | |
| 17 | ## Posting events | |
| 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. | |
| 23 | ||
| 17 | 24 | ## The shape |
| 18 | 25 | |
| 19 | 26 | One implementation, many doors. `src/actions.ts` is every operation as a |
client/relay.test.ts added +98
| @@ -0,0 +1,98 @@ | ||
| 1 | import { afterEach, beforeEach, expect, test } from "bun:test"; | |
| 2 | import { verifyRequest } from "../src/sign.ts"; | |
| 3 | import { post } from "./relay.ts"; | |
| 4 | ||
| 5 | const originalFetch = globalThis.fetch; | |
| 6 | ||
| 7 | beforeEach(() => { | |
| 8 | process.env.RELAY_URL = "https://relay.example"; | |
| 9 | process.env.RELAY_APP = "bazaar"; | |
| 10 | process.env.RELAY_KEY = "a-secret-only-bazaar-holds"; | |
| 11 | }); | |
| 12 | ||
| 13 | afterEach(() => { | |
| 14 | globalThis.fetch = originalFetch; | |
| 15 | delete process.env.RELAY_URL; | |
| 16 | delete process.env.RELAY_APP; | |
| 17 | delete process.env.RELAY_KEY; | |
| 18 | }); | |
| 19 | ||
| 20 | const event = { kind: "order.placed", subject: "order-1", summary: "Order 1 placed." }; | |
| 21 | ||
| 22 | test("the request it signs verifies with verifyRequest, on the exact bytes sent", async () => { | |
| 23 | let captured: { url: string; init: RequestInit } | undefined; | |
| 24 | globalThis.fetch = (async (url, init) => { | |
| 25 | captured = { url: String(url), init: init as RequestInit }; | |
| 26 | return new Response("", { status: 201 }); | |
| 27 | }) as typeof fetch; | |
| 28 | ||
| 29 | const result = await post(event); | |
| 30 | expect(result).toEqual({ ok: true }); | |
| 31 | expect(captured?.url).toBe("https://relay.example/api/events"); | |
| 32 | expect(captured?.init.body).toBe(JSON.stringify(event)); | |
| 33 | ||
| 34 | const headers = new Headers(captured!.init.headers); | |
| 35 | const verdict = verifyRequest( | |
| 36 | { | |
| 37 | method: "POST", | |
| 38 | path: "/api/events", | |
| 39 | app: headers.get("x-relay-app")!, | |
| 40 | timestamp: headers.get("x-relay-timestamp")!, | |
| 41 | nonce: headers.get("x-relay-nonce")!, | |
| 42 | body: captured!.init.body as string, | |
| 43 | }, | |
| 44 | process.env.RELAY_KEY!, | |
| 45 | headers.get("x-relay-signature")!, | |
| 46 | ); | |
| 47 | expect(verdict).toBe("ok"); | |
| 48 | }); | |
| 49 | ||
| 50 | test("two calls carry different nonces", async () => { | |
| 51 | const nonces: string[] = []; | |
| 52 | globalThis.fetch = (async (_url, init) => { | |
| 53 | nonces.push(new Headers((init as RequestInit).headers).get("x-relay-nonce")!); | |
| 54 | return new Response("", { status: 201 }); | |
| 55 | }) as typeof fetch; | |
| 56 | ||
| 57 | await post(event); | |
| 58 | await post(event); | |
| 59 | expect(nonces[0]).not.toBe(nonces[1]); | |
| 60 | }); | |
| 61 | ||
| 62 | test("a network failure is a failure value, not a throw", async () => { | |
| 63 | globalThis.fetch = (async () => { | |
| 64 | throw new TypeError("fetch failed"); | |
| 65 | }) as typeof fetch; | |
| 66 | ||
| 67 | const result = await post(event); | |
| 68 | expect(result.ok).toBe(false); | |
| 69 | if (!result.ok) expect(result.error).toBe("network"); | |
| 70 | }); | |
| 71 | ||
| 72 | test("a timeout is a failure value, not a throw", async () => { | |
| 73 | globalThis.fetch = (async () => { | |
| 74 | throw new DOMException("The operation timed out.", "TimeoutError"); | |
| 75 | }) as typeof fetch; | |
| 76 | ||
| 77 | const result = await post(event); | |
| 78 | expect(result.ok).toBe(false); | |
| 79 | if (!result.ok) expect(result.error).toBe("network"); | |
| 80 | }); | |
| 81 | ||
| 82 | test("a 4xx or 5xx is a failure value carrying the status, not a throw", async () => { | |
| 83 | globalThis.fetch = (async () => | |
| 84 | new Response("bad app id", { status: 422 })) as typeof fetch; | |
| 85 | ||
| 86 | const result = await post(event); | |
| 87 | expect(result).toEqual({ ok: false, error: "http_422", message: "bad app id" }); | |
| 88 | }); | |
| 89 | ||
| 90 | test("a missing environment variable fails loudly, naming it", async () => { | |
| 91 | delete process.env.RELAY_KEY; | |
| 92 | await expect(post(event)).rejects.toThrow(/RELAY_KEY/); | |
| 93 | }); | |
| 94 | ||
| 95 | test("a malformed environment variable fails loudly, naming it", async () => { | |
| 96 | process.env.RELAY_APP = "Not An App Id!"; | |
| 97 | await expect(post(event)).rejects.toThrow(/RELAY_APP/); | |
| 98 | }); |
client/relay.ts added +63
| @@ -0,0 +1,63 @@ | ||
| 1 | /** | |
| 2 | * Post an event to relay. Paste this file into another app — it has no | |
| 3 | * dependency on this repository and no dependency of its own. It speaks | |
| 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. | |
| 6 | */ | |
| 7 | import { createHash, createHmac, randomBytes } from "node:crypto"; | |
| 8 | ||
| 9 | export interface RelayEvent { | |
| 10 | kind: string; | |
| 11 | subject: string; | |
| 12 | summary: string; | |
| 13 | link?: string; | |
| 14 | dedupe?: string; | |
| 15 | occurredAt?: string; | |
| 16 | } | |
| 17 | ||
| 18 | export type PostResult = | |
| 19 | | { ok: true } | |
| 20 | | { ok: false; error: string; message: string }; | |
| 21 | ||
| 22 | function requireEnv(name: string, pattern?: RegExp): string { | |
| 23 | const value = process.env[name]; | |
| 24 | if (!value) throw new Error(`relay: missing environment variable ${name}`); | |
| 25 | if (pattern && !pattern.test(value)) | |
| 26 | throw new Error(`relay: environment variable ${name} is malformed`); | |
| 27 | return value; | |
| 28 | } | |
| 29 | ||
| 30 | export async function post(event: RelayEvent): Promise<PostResult> { | |
| 31 | const app = requireEnv("RELAY_APP", /^[a-z0-9][a-z0-9-]{0,31}$/); | |
| 32 | const key = requireEnv("RELAY_KEY"); | |
| 33 | const base = requireEnv("RELAY_URL", /^https?:\/\/.+/); | |
| 34 | const body = JSON.stringify(event); | |
| 35 | const timestamp = String(Date.now()); | |
| 36 | const nonce = randomBytes(16).toString("base64url"); | |
| 37 | const bodyHash = createHash("sha256").update(body, "utf8").digest("base64url"); | |
| 38 | const canonical = | |
| 39 | `relay1\nPOST\n/api/events\n${app}\n${timestamp}\n${nonce}\n${bodyHash}`; | |
| 40 | const signature = createHmac("sha256", key).update(canonical).digest("base64url"); | |
| 41 | const headers = { | |
| 42 | "content-type": "application/json", | |
| 43 | "x-relay-app": app, | |
| 44 | "x-relay-timestamp": timestamp, | |
| 45 | "x-relay-nonce": nonce, | |
| 46 | "x-relay-signature": signature, | |
| 47 | }; | |
| 48 | let response: Response; | |
| 49 | try { | |
| 50 | response = await fetch(new URL("/api/events", base), { | |
| 51 | method: "POST", | |
| 52 | headers, | |
| 53 | body, | |
| 54 | signal: AbortSignal.timeout(5000), | |
| 55 | }); | |
| 56 | } catch (err) { | |
| 57 | const message = err instanceof Error ? err.message : String(err); | |
| 58 | return { ok: false, error: "network", message }; | |
| 59 | } | |
| 60 | if (response.ok) return { ok: true }; | |
| 61 | const message = await response.text().catch(() => response.statusText); | |
| 62 | return { ok: false, error: `http_${response.status}`, message }; | |
| 63 | } |
docs/adopting.md added +118
| @@ -0,0 +1,118 @@ | ||
| 1 | # Posting to relay | |
| 2 | ||
| 3 | relay is one live timeline for every app on this forge. An event is a | |
| 4 | small, signed fact — "this happened" — not a note, a log line or a | |
| 5 | notification. Post one whenever something another crew might care about | |
| 6 | happens: an order placed, a card moved, a deploy finished. | |
| 7 | ||
| 8 | ## Get a key | |
| 9 | ||
| 10 | An admin on relay's own page registers your app and hands you a one-time | |
| 11 | secret — you cannot see it again after it's issued, so store it as | |
| 12 | `RELAY_KEY` in your app's environment immediately. You'll also need | |
| 13 | `RELAY_APP` (the id you registered under) and `RELAY_URL` (relay's address). | |
| 14 | None of the three are secret to write down except the key. | |
| 15 | ||
| 16 | ## The client | |
| 17 | ||
| 18 | Copy `client/relay.ts` into your app — no build step, no dependency, under | |
| 19 | 80 lines. It reads `RELAY_URL`, `RELAY_APP` and `RELAY_KEY` from the | |
| 20 | environment and exports one function: | |
| 21 | ||
| 22 | ts | |
| 23 | import { post } from "./relay.ts"; | |
| 24 | ||
| 25 | const result = await post({ | |
| 26 | kind: "order.placed", | |
| 27 | subject: "order-4821", | |
| 28 | summary: "Order 4821 placed by jane for $42.00.", | |
| 29 | link: "https://bazaar.example/orders/4821", | |
| 30 | dedupe: "order.placed:4821", | |
| 31 | }); | |
| 32 | // { ok: true } or { ok: false, error, message } | |
| 33 | ||
| 34 | ||
| 35 | Call it **after** you've sent your own response, never before — posting to | |
| 36 | relay is not part of your request's critical path, and nothing in your app | |
| 37 | should wait on it. | |
| 38 | ||
| 39 | ## The fields | |
| 40 | ||
| 41 | | field | for | | |
| 42 | | ------------ | -------------------------------------------------------------------- | | |
| 43 | | `kind` | what happened, as `noun.verb` — see below | | |
| 44 | | `subject` | the thing it happened to, stable enough to group by | | |
| 45 | | `summary` | one line a stranger to your app can read and understand | | |
| 46 | | `link` | optional — where to see more, usually back to your app | | |
| 47 | | `dedupe` | optional — see "retries are free" below | | |
| 48 | | `occurredAt` | optional ISO timestamp, if the event happened before you posted it | | |
| 49 | ||
| 50 | ## The kind convention | |
| 51 | ||
| 52 | `noun.verb`, lowercase, dotted: `order.placed`, `card.moved`, | |
| 53 | `deploy.finished`. Pick the noun your app owns and keep verbs short and | |
| 54 | past-tense. A stranger scanning the timeline should be able to guess what a | |
| 55 | `kind` means without reading your code. | |
| 56 | ||
| 57 | ## The headers | |
| 58 | ||
| 59 | Every request carries four, all produced for you by `client/relay.ts`: | |
| 60 | ||
| 61 | - `x-relay-app` — your app id | |
| 62 | - `x-relay-timestamp` — milliseconds since the epoch, when you signed | |
| 63 | - `x-relay-nonce` — 16 random bytes, base64url, fresh on every call | |
| 64 | - `x-relay-signature` — `base64url(HMAC-SHA256(key, canonical string))`, | |
| 65 | where the canonical string is the `relay1` format `src/sign.ts` defines: | |
| 66 | method, path, app, timestamp, nonce and the request body's hash, joined | |
| 67 | by `\n`. Only `relay1` — the client pins that version and will not follow | |
| 68 | relay if the format ever changes underneath it. | |
| 69 | ||
| 70 | ## The curl equivalent | |
| 71 | ||
| 72 | If you're posting from somewhere that isn't Bun or Node, the shape is the | |
| 73 | same: sign the same seven-line string with HMAC-SHA256 under your key, | |
| 74 | base64url it, and send | |
| 75 | ||
| 76 | sh | |
| 77 | curl -X POST "/api/events" \ | |
| 78 | -H "content-type: application/json" \ | |
| 79 | -H "x-relay-app: " \ | |
| 80 | -H "x-relay-timestamp: " \ | |
| 81 | -H "x-relay-nonce: " \ | |
| 82 | -H "x-relay-signature: " \ | |
| 83 | --data-binary "" | |
| 84 | ||
| 85 | ||
| 86 | `$BODY` must be the exact bytes you hashed — sign it, then send that same | |
| 87 | string; never re-serialize between the two. | |
| 88 | ||
| 89 | ## Retries are free | |
| 90 | ||
| 91 | Set `dedupe` to something stable for the real-world event you're | |
| 92 | describing (`order.placed:4821`, not a random id you generate per attempt). | |
| 93 | relay keeps one event per `dedupe` value per app, so if your request | |
| 94 | succeeds but the response is lost, or you're not sure whether it landed, | |
| 95 | just call `post` again with the same `dedupe` — it lands once. Without | |
| 96 | `dedupe`, every call is a new event. | |
| 97 | ||
| 98 | ## When relay is down | |
| 99 | ||
| 100 | Posting is fire-and-forget. `post` never throws into your request path: a | |
| 101 | network failure, a 5-second timeout and any 4xx or 5xx all come back as | |
| 102 | `{ ok: false, error, message }` rather than an exception. If relay is | |
| 103 | unreachable, the event is simply dropped — log the failure if you like, but | |
| 104 | do not block your own response on it and do not retry in a loop. A missing | |
| 105 | or malformed `RELAY_URL`, `RELAY_APP` or `RELAY_KEY` is different: that's a | |
| 106 | configuration mistake in your app, not relay being down, so `post` throws | |
| 107 | immediately and names the variable, rather than silently swallowing every | |
| 108 | event you ever post. | |
| 109 | ||
| 110 | ## What not to post | |
| 111 | ||
| 112 | An event is public to everyone who can see relay's timeline. Don't post: | |
| 113 | ||
| 114 | - personal data — names, emails, addresses; refer to people by an id your | |
| 115 | own app resolves, not by anything identifying on its own | |
| 116 | - secrets — API keys, tokens, passwords, connection strings | |
| 117 | - a `summary` that only makes sense with context relay doesn't have — it | |
| 118 | should read as one clear line to a stranger who has never used your app |