@crewhaus/federation-discovery 0.1.0 → 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 +6 -11
- package/src/index.test.ts +233 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/federation-discovery",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Federation peer lookup: DNS SRV + .well-known/crewhaus.json with TTL caching (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.
|
|
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@
|
|
21
|
-
"url": "https://
|
|
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": "
|
|
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
|
}
|
package/src/index.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
FederationDiscoveryError,
|
|
@@ -210,3 +210,235 @@ describe("discoverDeployment top-level helper", () => {
|
|
|
210
210
|
});
|
|
211
211
|
});
|
|
212
212
|
});
|
|
213
|
+
|
|
214
|
+
describe("default fetcher (no injected wellKnownFetcher)", () => {
|
|
215
|
+
test("uses globalThis.fetch with GET + Accept: application/json and parses the response", async () => {
|
|
216
|
+
const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(
|
|
217
|
+
new Response(JSON.stringify(goodPayload), { status: 200 }),
|
|
218
|
+
);
|
|
219
|
+
try {
|
|
220
|
+
const rec = await discoverDeployment("deployment-b.example");
|
|
221
|
+
expect(rec.endpoint).toBe(goodPayload.endpoint);
|
|
222
|
+
expect(rec.publicKeyFingerprint).toBe(HEX64);
|
|
223
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
224
|
+
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
225
|
+
expect(url).toBe("https://deployment-b.example/.well-known/crewhaus.json");
|
|
226
|
+
expect(init.method).toBe("GET");
|
|
227
|
+
expect((init.headers as Record<string, string>)["Accept"]).toBe("application/json");
|
|
228
|
+
} finally {
|
|
229
|
+
fetchSpy.mockRestore();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("propagates a non-200 status from the real-fetch path as a FederationDiscoveryError", async () => {
|
|
234
|
+
const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(
|
|
235
|
+
new Response("nope", { status: 404 }),
|
|
236
|
+
);
|
|
237
|
+
try {
|
|
238
|
+
await expect(discoverDeployment("deployment-b.example")).rejects.toThrow(/returned 404/);
|
|
239
|
+
} finally {
|
|
240
|
+
fetchSpy.mockRestore();
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe("parsing branches", () => {
|
|
246
|
+
test("accepts snake_case field aliases (supported_shapes / public_key_fingerprint)", async () => {
|
|
247
|
+
const d = createDiscovery({
|
|
248
|
+
wellKnownFetcher: fetcherReturning({
|
|
249
|
+
endpoint: "https://snake.example",
|
|
250
|
+
version: "crewhaus.federation.v1",
|
|
251
|
+
supported_shapes: ["cli"],
|
|
252
|
+
public_key_fingerprint: HEX64,
|
|
253
|
+
}),
|
|
254
|
+
});
|
|
255
|
+
const rec = await d.discover("snake.example");
|
|
256
|
+
expect(rec.supportedShapes).toEqual(["cli"]);
|
|
257
|
+
expect(rec.publicKeyFingerprint).toBe(HEX64);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("drops non-string entries from supportedShapes and defaults a missing list to []", async () => {
|
|
261
|
+
const withMixed = createDiscovery({
|
|
262
|
+
wellKnownFetcher: fetcherReturning({ ...goodPayload, supportedShapes: ["cli", 7, null] }),
|
|
263
|
+
});
|
|
264
|
+
expect((await withMixed.discover("mixed.example")).supportedShapes).toEqual(["cli"]);
|
|
265
|
+
|
|
266
|
+
const withNone = createDiscovery({
|
|
267
|
+
wellKnownFetcher: fetcherReturning({
|
|
268
|
+
endpoint: "https://noshapes.example",
|
|
269
|
+
version: "crewhaus.federation.v1",
|
|
270
|
+
publicKeyFingerprint: HEX64,
|
|
271
|
+
}),
|
|
272
|
+
});
|
|
273
|
+
expect((await withNone.discover("noshapes.example")).supportedShapes).toEqual([]);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("rejects a non-object peer record (JSON primitive)", async () => {
|
|
277
|
+
// A bare JSON number/string is `typeof !== "object"`, so it hits the
|
|
278
|
+
// top-level guard. (Arrays are `typeof === "object"` and fall through to
|
|
279
|
+
// the endpoint+version check instead.)
|
|
280
|
+
const num = createDiscovery({ wellKnownFetcher: fetcherReturning(42) });
|
|
281
|
+
await expect(num.discover("num.example")).rejects.toThrow(/is not an object/);
|
|
282
|
+
|
|
283
|
+
const nul = createDiscovery({ wellKnownFetcher: fetcherReturning(null) });
|
|
284
|
+
await expect(nul.discover("null.example")).rejects.toThrow(/is not an object/);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("normalizes an upper-case fingerprint to lower-case", async () => {
|
|
288
|
+
const d = createDiscovery({
|
|
289
|
+
wellKnownFetcher: fetcherReturning({ ...goodPayload, publicKeyFingerprint: "A".repeat(64) }),
|
|
290
|
+
});
|
|
291
|
+
expect((await d.discover("upper.example")).publicKeyFingerprint).toBe(HEX64);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("allows http://localhost when allowInsecureLocalhost is set", async () => {
|
|
295
|
+
const d = createDiscovery({
|
|
296
|
+
allowInsecureLocalhost: true,
|
|
297
|
+
wellKnownFetcher: fetcherReturning({
|
|
298
|
+
...goodPayload,
|
|
299
|
+
endpoint: "http://localhost:8443",
|
|
300
|
+
}),
|
|
301
|
+
});
|
|
302
|
+
expect((await d.discover("local.example")).endpoint).toBe("http://localhost:8443");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("still rejects http://localhost when allowInsecureLocalhost is NOT set", async () => {
|
|
306
|
+
const d = createDiscovery({
|
|
307
|
+
wellKnownFetcher: fetcherReturning({ ...goodPayload, endpoint: "http://localhost:8443" }),
|
|
308
|
+
});
|
|
309
|
+
await expect(d.discover("local.example")).rejects.toThrow(/must be https/);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe("SRV edge cases", () => {
|
|
314
|
+
test("SRV returns zero records → falls through to direct .well-known", async () => {
|
|
315
|
+
const srv: SrvResolver = async () => ({ records: [], ttl: 60 });
|
|
316
|
+
const seen: string[] = [];
|
|
317
|
+
const d = createDiscovery({
|
|
318
|
+
srvDomain: "internal.crewhaus",
|
|
319
|
+
srvResolver: srv,
|
|
320
|
+
wellKnownFetcher: async (url) => {
|
|
321
|
+
seen.push(url);
|
|
322
|
+
return { status: 200, body: JSON.stringify(goodPayload) };
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
const rec = await d.discover("deployment-b.example");
|
|
326
|
+
expect(rec.endpoint).toBe(goodPayload.endpoint);
|
|
327
|
+
// Direct fetch used the deployment URL, not an SRV-derived one.
|
|
328
|
+
expect(seen).toEqual(["https://deployment-b.example/.well-known/crewhaus.json"]);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("SRV hit whose well-known is bad falls through to the direct .well-known", async () => {
|
|
332
|
+
const srv: SrvResolver = async () => ({
|
|
333
|
+
records: [{ priority: 10, weight: 5, port: 8443, name: "fed.deployment-b.example" }],
|
|
334
|
+
ttl: 60,
|
|
335
|
+
});
|
|
336
|
+
const seen: string[] = [];
|
|
337
|
+
const d = createDiscovery({
|
|
338
|
+
srvDomain: "internal.crewhaus",
|
|
339
|
+
srvResolver: srv,
|
|
340
|
+
wellKnownFetcher: async (url) => {
|
|
341
|
+
seen.push(url);
|
|
342
|
+
// First call (SRV endpoint) 404s; second call (direct) succeeds.
|
|
343
|
+
if (url.startsWith("https://fed.deployment-b.example:8443")) {
|
|
344
|
+
return { status: 404, body: "" };
|
|
345
|
+
}
|
|
346
|
+
return { status: 200, body: JSON.stringify(goodPayload) };
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
const rec = await d.discover("deployment-b.example");
|
|
350
|
+
expect(seen[0]).toBe("https://fed.deployment-b.example:8443/.well-known/crewhaus.json");
|
|
351
|
+
expect(seen[1]).toBe("https://deployment-b.example/.well-known/crewhaus.json");
|
|
352
|
+
expect(rec.endpoint).toBe(goodPayload.endpoint);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("SRV sorts by priority then by descending weight", async () => {
|
|
356
|
+
const srv: SrvResolver = async () => ({
|
|
357
|
+
records: [
|
|
358
|
+
{ priority: 20, weight: 99, port: 1, name: "low-priority.example" },
|
|
359
|
+
{ priority: 10, weight: 1, port: 2, name: "lo-weight.example" },
|
|
360
|
+
{ priority: 10, weight: 50, port: 8443, name: "winner.example" },
|
|
361
|
+
],
|
|
362
|
+
ttl: 60,
|
|
363
|
+
});
|
|
364
|
+
const seen: string[] = [];
|
|
365
|
+
const d = createDiscovery({
|
|
366
|
+
srvDomain: "internal.crewhaus",
|
|
367
|
+
srvResolver: srv,
|
|
368
|
+
wellKnownFetcher: async (url) => {
|
|
369
|
+
seen.push(url);
|
|
370
|
+
return { status: 200, body: JSON.stringify(goodPayload) };
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
const rec = await d.discover("deployment-b");
|
|
374
|
+
expect(seen[0]).toBe("https://winner.example:8443/.well-known/crewhaus.json");
|
|
375
|
+
expect(rec.endpoint).toBe("https://winner.example:8443");
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("srvDomain set but no srvResolver → skips SRV entirely", async () => {
|
|
379
|
+
const seen: string[] = [];
|
|
380
|
+
const d = createDiscovery({
|
|
381
|
+
srvDomain: "internal.crewhaus",
|
|
382
|
+
wellKnownFetcher: async (url) => {
|
|
383
|
+
seen.push(url);
|
|
384
|
+
return { status: 200, body: JSON.stringify(goodPayload) };
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
const rec = await d.discover("deployment-b.example");
|
|
388
|
+
expect(seen).toEqual(["https://deployment-b.example/.well-known/crewhaus.json"]);
|
|
389
|
+
expect(rec.endpoint).toBe(goodPayload.endpoint);
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
describe("error semantics", () => {
|
|
394
|
+
test("non-FederationDiscoveryError from the fetcher is wrapped with its cause preserved", async () => {
|
|
395
|
+
const boom = new TypeError("socket hang up");
|
|
396
|
+
const d = createDiscovery({
|
|
397
|
+
wellKnownFetcher: async () => {
|
|
398
|
+
throw boom;
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
let caught: unknown;
|
|
402
|
+
try {
|
|
403
|
+
await d.discover("deployment-b.example");
|
|
404
|
+
} catch (e) {
|
|
405
|
+
caught = e;
|
|
406
|
+
}
|
|
407
|
+
expect(caught).toBeInstanceOf(FederationDiscoveryError);
|
|
408
|
+
expect((caught as FederationDiscoveryError).message).toMatch(/peer discovery failed/);
|
|
409
|
+
expect((caught as FederationDiscoveryError).message).toMatch(/socket hang up/);
|
|
410
|
+
expect((caught as FederationDiscoveryError).cause).toBe(boom);
|
|
411
|
+
expect((caught as FederationDiscoveryError).code).toBe("config");
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("cacheStats exposes per-deployment expiry timestamps", async () => {
|
|
415
|
+
const clock = 1_000;
|
|
416
|
+
const d = createDiscovery({
|
|
417
|
+
now: () => clock,
|
|
418
|
+
wellKnownFetcher: fetcherReturning(goodPayload),
|
|
419
|
+
});
|
|
420
|
+
await d.discover("deployment-b.example");
|
|
421
|
+
const stats = d.cacheStats();
|
|
422
|
+
expect(stats.entries).toBe(1);
|
|
423
|
+
expect(stats.expirations).toEqual([
|
|
424
|
+
{ deployment: "deployment-b.example", expiresAt: 1_000 + 60_000 },
|
|
425
|
+
]);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("an expired entry is evicted and re-resolved (covers stale-branch delete)", async () => {
|
|
429
|
+
let clock = 0;
|
|
430
|
+
let calls = 0;
|
|
431
|
+
const d = createDiscovery({
|
|
432
|
+
now: () => clock,
|
|
433
|
+
wellKnownFetcher: async () => {
|
|
434
|
+
calls++;
|
|
435
|
+
return { status: 200, body: JSON.stringify(goodPayload) };
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
await d.discover("deployment-b.example");
|
|
439
|
+
expect(calls).toBe(1);
|
|
440
|
+
clock += 60_001; // past the 60s record TTL
|
|
441
|
+
await d.discover("deployment-b.example");
|
|
442
|
+
expect(calls).toBe(2);
|
|
443
|
+
});
|
|
444
|
+
});
|