@crewhaus/federation-protocol 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/federation-protocol",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Cross-deployment A2A wire protocol: federation envelope (extends @crewhaus/a2a-protocol) + mTLS HTTPS POST transport with cert pinning (Section 34)",
6
6
  "main": "src/index.ts",
@@ -12,13 +12,13 @@
12
12
  "test": "bun test src"
13
13
  },
14
14
  "dependencies": {
15
- "@crewhaus/errors": "0.1.1"
15
+ "@crewhaus/errors": "0.1.2"
16
16
  },
17
17
  "license": "Apache-2.0",
18
18
  "author": {
19
19
  "name": "Max Meier",
20
- "email": "max@studiomax.io",
21
- "url": "https://studiomax.io"
20
+ "email": "max@crewhaus.ai",
21
+ "url": "https://crewhaus.ai"
22
22
  },
23
23
  "repository": {
24
24
  "type": "git",
@@ -30,12 +30,7 @@
30
30
  "url": "https://github.com/crewhaus/factory/issues"
31
31
  },
32
32
  "publishConfig": {
33
- "access": "restricted"
33
+ "access": "public"
34
34
  },
35
- "files": [
36
- "src",
37
- "README.md",
38
- "LICENSE",
39
- "NOTICE"
40
- ]
35
+ "files": ["src", "README.md", "LICENSE", "NOTICE"]
41
36
  }
@@ -0,0 +1,383 @@
1
+ import { afterAll, afterEach, beforeAll, describe, expect, mock, test } from "bun:test";
2
+ /**
3
+ * Coverage for the production `defaultTransport` path — the branch that issues
4
+ * a real `node:https` POST with mTLS + cert pinning (src/index.ts lines
5
+ * 212-272). `federationCall` only reaches `defaultTransport` when no
6
+ * `transport` override is supplied.
7
+ *
8
+ * To stay fully deterministic — no real socket, no TLS handshake, no wall-clock
9
+ * timers — we replace `node:https.request` with a hand-driven fake via
10
+ * `mock.module`. The fake captures the assembled `RequestOptions` (so we can
11
+ * call `checkServerIdentity` directly) and hands back a `FakeClientRequest`
12
+ * (an `EventEmitter`) so the test drives the `error` / `timeout` lifecycle, and
13
+ * a `FakeResponse` (also an `EventEmitter`) so the test drives `data` / `end`.
14
+ *
15
+ * `mock.module` mutates the shared module registry, so the module under test is
16
+ * imported with `await import("./index")` AFTER the stub is registered (its
17
+ * `import { request as httpsRequest } from "node:https"` binding then resolves
18
+ * to the fake). `afterEach` restores the real `node:https` and clears per-test
19
+ * state so nothing leaks between tests or into sibling test files.
20
+ *
21
+ * NO-HANG CONTRACT — every `federationCall(...)` promise in this file is made
22
+ * to settle before the test ends:
23
+ * - happy / end paths resolve naturally once `res` emits `end`;
24
+ * - paths that only inspect `checkServerIdentity` settle by emitting a
25
+ * request `"error"` and asserting the call REJECTS;
26
+ * - the error / timeout paths assert `.rejects`;
27
+ * - the response-stream-error path emits `res.on("error")` and asserts the
28
+ * call REJECTS — `defaultTransport` now registers a `res.on("error")`
29
+ * listener that rejects, so this settles instead of hanging. (The previous
30
+ * version of the transport omitted that listener; emitting a response-stream
31
+ * error then could never settle the promise, which is the latent hang this
32
+ * fix closes.)
33
+ */
34
+ import { EventEmitter } from "node:events";
35
+ import type { PeerCertificate } from "node:tls";
36
+
37
+ const realHttps = require("node:https") as typeof import("node:https");
38
+
39
+ /** Minimal stand-in for the response `IncomingMessage` (`data` / `end`). */
40
+ class FakeResponse extends EventEmitter {
41
+ statusCode?: number;
42
+ }
43
+
44
+ /**
45
+ * Minimal stand-in for `ClientRequest`. It is an `EventEmitter` so the
46
+ * transport can attach `error` / `timeout` listeners. `write` / `end` record
47
+ * what was sent; `destroy(err)` mirrors Node by surfacing the destroy reason as
48
+ * an `error` event on the next microtask — which is how the production timeout
49
+ * handler ultimately rejects the promise.
50
+ */
51
+ class FakeClientRequest extends EventEmitter {
52
+ written = "";
53
+ ended = false;
54
+ destroyedWith: unknown = undefined;
55
+ write(chunk: string): boolean {
56
+ this.written += chunk;
57
+ return true;
58
+ }
59
+ end(): void {
60
+ this.ended = true;
61
+ }
62
+ destroy(err?: unknown): void {
63
+ this.destroyedWith = err;
64
+ if (err) queueMicrotask(() => this.emit("error", err));
65
+ }
66
+ }
67
+
68
+ /** What each invocation of the fake `request` captures for the test to drive. */
69
+ type Capture = {
70
+ opts: import("node:https").RequestOptions;
71
+ req: FakeClientRequest;
72
+ cb: (res: FakeResponse) => void;
73
+ res?: FakeResponse;
74
+ };
75
+
76
+ let lastCall: Capture | undefined;
77
+
78
+ /**
79
+ * Behaviour selector, reset to `"manual"` after every test:
80
+ * - "respond": on the next microtask deliver a response, emit `respondChunks`,
81
+ * then `end` (happy path — promise resolves).
82
+ * - "open": on the next microtask deliver a response (so `res.on(...)`
83
+ * listeners are attached) and emit `respondChunks`, but DO NOT
84
+ * emit `end`. The stream is left open for the test to drive — e.g.
85
+ * to emit a response-stream `"error"`. The response is captured in
86
+ * `lastCall.res` so the test can reach it.
87
+ * - "manual": capture `opts` / `req` only; the test drives every event and is
88
+ * responsible for settling the promise (reject via req `error`).
89
+ */
90
+ let mode: "respond" | "open" | "manual" = "manual";
91
+ let respondStatus: number | undefined = 200;
92
+ let respondChunks: Buffer[] = [];
93
+
94
+ mock.module("node:https", () => ({
95
+ ...realHttps,
96
+ request: (opts: import("node:https").RequestOptions, cb: (res: FakeResponse) => void) => {
97
+ const req = new FakeClientRequest();
98
+ const call: Capture = { opts, req, cb };
99
+ lastCall = call;
100
+ if (mode === "respond" || mode === "open") {
101
+ // Deliver the response only after the synchronous body of
102
+ // `defaultTransport` has run `req.write(body)` / `req.end()`.
103
+ queueMicrotask(() => {
104
+ const res = new FakeResponse();
105
+ res.statusCode = respondStatus;
106
+ call.res = res;
107
+ cb(res);
108
+ for (const c of respondChunks) res.emit("data", c);
109
+ // "open" leaves the stream live so the test can drive `error`/`end`.
110
+ if (mode === "respond") res.emit("end");
111
+ });
112
+ }
113
+ return req;
114
+ },
115
+ }));
116
+
117
+ // Import AFTER registering the mock so the static `node:https` binding inside
118
+ // `defaultTransport` resolves to the fake.
119
+ const { FEDERATION_VERSION, FederationProtocolError, federationCall, fingerprintCert } =
120
+ await import("./index");
121
+ const { makeFixtureCertSet } = await import("./test-helpers");
122
+
123
+ type Creds = {
124
+ caCertPem: string;
125
+ clientCertPem: string;
126
+ clientKeyPem: string;
127
+ pinnedFingerprint: string;
128
+ };
129
+
130
+ let creds: Creds;
131
+ const baseEnvelope = {
132
+ version: FEDERATION_VERSION,
133
+ traceparent: "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
134
+ federation: {
135
+ from: { deployment: "deployment-a", role: "researcher" },
136
+ to: { deployment: "deployment-b", role: "code-reviewer" },
137
+ mtls: { client_cert_subject: "CN=deployment-a" },
138
+ },
139
+ kind: "question" as const,
140
+ payload: "review the patch",
141
+ };
142
+
143
+ beforeAll(() => {
144
+ const c = makeFixtureCertSet();
145
+ creds = {
146
+ caCertPem: c.caCertPem,
147
+ clientCertPem: c.clientCertPem,
148
+ clientKeyPem: c.clientKeyPem,
149
+ pinnedFingerprint: c.pinnedFingerprint,
150
+ };
151
+ });
152
+
153
+ afterEach(() => {
154
+ lastCall = undefined;
155
+ mode = "manual";
156
+ respondStatus = 200;
157
+ respondChunks = [];
158
+ });
159
+
160
+ // Restore the genuine `node:https` so the stub cannot outlive this file.
161
+ afterAll(() => {
162
+ mock.module("node:https", () => realHttps);
163
+ });
164
+
165
+ /**
166
+ * Drive the pinned-cert check for an in-flight call, then settle the call by
167
+ * rejecting it (the production code has no other exit once the request is live
168
+ * and no response is delivered). Returns whatever `checkServerIdentity`
169
+ * returned so the caller can assert on it.
170
+ */
171
+ async function inspectThenSettle(
172
+ call: Promise<unknown>,
173
+ peer: Partial<PeerCertificate>,
174
+ ): Promise<Error | undefined> {
175
+ // Let the synchronous request-assembly body of defaultTransport run.
176
+ await Promise.resolve();
177
+ const check = lastCall?.opts.checkServerIdentity;
178
+ expect(typeof check).toBe("function");
179
+ const result = check?.(
180
+ "deployment-b.example",
181
+ peer as unknown as Parameters<NonNullable<typeof check>>[1],
182
+ );
183
+ // Settle the dangling promise so no handle is left open.
184
+ lastCall?.req.emit("error", new Error("teardown"));
185
+ await expect(call).rejects.toThrow();
186
+ return result as Error | undefined;
187
+ }
188
+
189
+ describe("defaultTransport (real node:https path, request mocked)", () => {
190
+ test("rejects a non-https url before issuing any request", async () => {
191
+ await expect(
192
+ federationCall({
193
+ url: "http://deployment-b.example/federation",
194
+ envelope: baseEnvelope,
195
+ credentials: creds,
196
+ }),
197
+ ).rejects.toThrow(/federation transport requires https:\/\/, got http:/);
198
+ // The guard returns before `httpsRequest` is ever called.
199
+ expect(lastCall).toBeUndefined();
200
+ });
201
+
202
+ test("happy path POSTs the encoded envelope and resolves with status + body", async () => {
203
+ mode = "respond";
204
+ respondStatus = 200;
205
+ respondChunks = [Buffer.from('{"reply":', "utf8"), Buffer.from('"ack"}', "utf8")];
206
+
207
+ const result = await federationCall({
208
+ url: "https://deployment-b.example:8443/federation?trace=1",
209
+ envelope: baseEnvelope,
210
+ credentials: creds,
211
+ });
212
+
213
+ expect(result).toEqual({ status: 200, body: '{"reply":"ack"}' });
214
+
215
+ // Request options were assembled from the URL.
216
+ const opts = lastCall?.opts;
217
+ expect(opts?.method).toBe("POST");
218
+ expect(opts?.protocol).toBe("https:");
219
+ expect(opts?.hostname).toBe("deployment-b.example");
220
+ expect(opts?.port).toBe("8443");
221
+ expect(opts?.path).toBe("/federation?trace=1");
222
+ expect(opts?.rejectUnauthorized).toBe(true);
223
+ expect(opts?.ca).toBe(creds.caCertPem);
224
+ expect(opts?.cert).toBe(creds.clientCertPem);
225
+ expect(opts?.key).toBe(creds.clientKeyPem);
226
+
227
+ // The body written to the socket is the encoded envelope.
228
+ const written = lastCall?.req.written ?? "";
229
+ expect(JSON.parse(written)).toEqual(baseEnvelope);
230
+ expect(lastCall?.req.ended).toBe(true);
231
+
232
+ // Headers reflect the encoded body.
233
+ const headers = opts?.headers as Record<string, string>;
234
+ expect(headers["Content-Type"]).toBe("application/json");
235
+ expect(headers["Content-Length"]).toBe(Buffer.byteLength(written, "utf8").toString());
236
+ expect(headers["X-Crewhaus-Federation-Version"]).toBe(FEDERATION_VERSION);
237
+ });
238
+
239
+ test("defaults the port to 443 and path to '/' when the url omits them", async () => {
240
+ mode = "respond";
241
+ respondStatus = 204;
242
+ respondChunks = [];
243
+ const result = await federationCall({
244
+ url: "https://deployment-b.example",
245
+ envelope: baseEnvelope,
246
+ credentials: creds,
247
+ });
248
+ expect(result).toEqual({ status: 204, body: "" });
249
+ const opts = lastCall?.opts;
250
+ expect(opts?.port).toBe(443); // `u.port || 443` — empty string → numeric default
251
+ expect(opts?.path).toBe("/"); // empty pathname → "/"
252
+ });
253
+
254
+ test("resolves status 0 when the response carries no statusCode", async () => {
255
+ mode = "respond";
256
+ respondStatus = undefined; // exercise `res.statusCode ?? 0`
257
+ respondChunks = [Buffer.from("hi", "utf8")];
258
+ const result = await federationCall({
259
+ url: "https://deployment-b.example/federation",
260
+ envelope: baseEnvelope,
261
+ credentials: creds,
262
+ });
263
+ expect(result).toEqual({ status: 0, body: "hi" });
264
+ });
265
+
266
+ test("checkServerIdentity accepts a peer cert whose fingerprint matches the pin", async () => {
267
+ const call = federationCall({
268
+ url: "https://deployment-b.example/federation",
269
+ envelope: baseEnvelope,
270
+ credentials: creds,
271
+ timeoutMs: 5_000,
272
+ });
273
+ // Node yields fingerprint256 colon-separated + upper-case; the transport
274
+ // strips ":" and lower-cases before comparing.
275
+ const pinnedColon = creds.pinnedFingerprint.toUpperCase().replace(/(..)(?=.)/g, "$1:");
276
+ const ok = await inspectThenSettle(call, { fingerprint256: pinnedColon });
277
+ expect(ok).toBeUndefined();
278
+ });
279
+
280
+ test("checkServerIdentity rejects a peer cert whose fingerprint differs from the pin", async () => {
281
+ const call = federationCall({
282
+ url: "https://deployment-b.example/federation",
283
+ envelope: baseEnvelope,
284
+ credentials: creds,
285
+ });
286
+ const wrong = await inspectThenSettle(call, { fingerprint256: "AA:BB:CC" });
287
+ expect(wrong).toBeInstanceOf(Error);
288
+ expect((wrong as Error).message).toMatch(/cert-pin mismatch/);
289
+ });
290
+
291
+ test("checkServerIdentity treats a missing fingerprint256 as '' (mismatch)", async () => {
292
+ const call = federationCall({
293
+ url: "https://deployment-b.example/federation",
294
+ envelope: baseEnvelope,
295
+ credentials: creds,
296
+ });
297
+ // No fingerprint256 → `?? ""` → "" !== pin → Error with empty fp in message.
298
+ const res = await inspectThenSettle(call, {});
299
+ expect(res).toBeInstanceOf(Error);
300
+ expect((res as Error).message).toMatch(/peer fingerprint {2}!= pinned/);
301
+ });
302
+
303
+ test("rejects with a FederationProtocolError when the request emits 'error'", async () => {
304
+ const call = federationCall({
305
+ url: "https://deployment-b.example/federation",
306
+ envelope: baseEnvelope,
307
+ credentials: creds,
308
+ });
309
+ await Promise.resolve(); // let the request assemble + write/end
310
+ expect(lastCall?.req.ended).toBe(true);
311
+ lastCall?.req.emit("error", new Error("ECONNREFUSED boom"));
312
+ await expect(call).rejects.toThrow(/federation transport error: ECONNREFUSED boom/);
313
+ await call.catch((e) => {
314
+ expect(e).toBeInstanceOf(FederationProtocolError);
315
+ // The original socket error is preserved as the cause.
316
+ expect((e as InstanceType<typeof FederationProtocolError>).cause).toBeInstanceOf(Error);
317
+ });
318
+ });
319
+
320
+ test("on 'timeout' it destroys the request and rejects with the configured timeout", async () => {
321
+ const call = federationCall({
322
+ url: "https://deployment-b.example/federation",
323
+ envelope: baseEnvelope,
324
+ credentials: creds,
325
+ timeoutMs: 1234,
326
+ });
327
+ await Promise.resolve();
328
+ expect(lastCall?.opts.timeout).toBe(1234);
329
+ // Fire the socket timeout: the handler calls req.destroy(err); our fake
330
+ // surfaces that destroy reason as an `error` event, which rejects.
331
+ lastCall?.req.emit("timeout");
332
+ await expect(call).rejects.toThrow(/federation transport timeout after 1234ms/);
333
+ expect(lastCall?.req.destroyedWith).toBeInstanceOf(FederationProtocolError);
334
+ });
335
+
336
+ test("uses the default 30s timeout when none is supplied", async () => {
337
+ const call = federationCall({
338
+ url: "https://deployment-b.example/federation",
339
+ envelope: baseEnvelope,
340
+ credentials: creds,
341
+ });
342
+ await Promise.resolve();
343
+ expect(lastCall?.opts.timeout).toBe(30_000);
344
+ // Settle so no handle dangles.
345
+ lastCall?.req.emit("error", new Error("teardown"));
346
+ await expect(call).rejects.toThrow();
347
+ });
348
+
349
+ test("fingerprintCert agrees with the pin the transport compares against", () => {
350
+ // Anchors the colon-stripping / lower-casing contract checkServerIdentity
351
+ // relies on: the helper and the pin must agree on the same canonical form.
352
+ expect(fingerprintCert(creds.clientCertPem)).toBe(creds.pinnedFingerprint);
353
+ });
354
+
355
+ test("rejects with a FederationProtocolError when the response stream emits 'error'", async () => {
356
+ // Regression guard for the latent hang: if the response stream errors
357
+ // mid-body, `defaultTransport` must reject (settle) — never leave the
358
+ // returned promise pending. "open" delivers a live `res` (so the
359
+ // production `res.on("error")` listener is attached) and a partial body,
360
+ // then hands control to the test.
361
+ mode = "open";
362
+ respondStatus = 200;
363
+ respondChunks = [Buffer.from('{"partial":', "utf8")];
364
+ const call = federationCall({
365
+ url: "https://deployment-b.example/federation",
366
+ envelope: baseEnvelope,
367
+ credentials: creds,
368
+ });
369
+ // Let the request assemble + the queued microtask deliver `res` and the
370
+ // partial chunk, so the response stream is live with listeners attached.
371
+ await Promise.resolve();
372
+ await Promise.resolve();
373
+ expect(lastCall?.res).toBeInstanceOf(EventEmitter);
374
+ // Mid-body stream failure: without `res.on("error")` this would hang.
375
+ lastCall?.res?.emit("error", new Error("ECONNRESET mid-body"));
376
+ await expect(call).rejects.toThrow(/federation transport error: ECONNRESET mid-body/);
377
+ await call.catch((e) => {
378
+ expect(e).toBeInstanceOf(FederationProtocolError);
379
+ // The original stream error is preserved as the cause.
380
+ expect((e as InstanceType<typeof FederationProtocolError>).cause).toBeInstanceOf(Error);
381
+ });
382
+ });
383
+ });
package/src/index.ts CHANGED
@@ -250,6 +250,9 @@ function defaultTransport(timeoutMs: number): FederationTransport {
250
250
  const req = httpsRequest(opts, (res) => {
251
251
  const chunks: Buffer[] = [];
252
252
  res.on("data", (c) => chunks.push(c as Buffer));
253
+ res.on("error", (err) =>
254
+ reject(new FederationProtocolError(`federation transport error: ${err.message}`, err)),
255
+ );
253
256
  res.on("end", () =>
254
257
  resolve({
255
258
  status: res.statusCode ?? 0,