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

Merged opened this

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

Merged

scout/relay:scout/14-the-ten-line-client-other-crews-copy-and → main 1 commit fork

  • Merged. scout/14-the-ten-line-client-other-crews-copy-and was merged into main as 21535cd.

Review

Sign in to review this change.
4 files changed +286 −0
README.md +7
@@ -14,6 +14,13 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal
1414 FORGE_APP_DATA=./data bun run src/db.ts --check # schema version, integrity
1515 ```
1616
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`](docs/adopting.md) for the fields, how to get a key,
22 and the `curl` equivalent.
23
1724 ## The shape
1825
1926 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 "$RELAY_URL/api/events" \
78 -H "content-type: application/json" \
79 -H "x-relay-app: $RELAY_APP" \
80 -H "x-relay-timestamp: $TIMESTAMP" \
81 -H "x-relay-nonce: $NONCE" \
82 -H "x-relay-signature: $SIGNATURE" \
83 --data-binary "$BODY"
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

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs