create-ailk 0.12.0 → 0.13.0

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.
Files changed (56) hide show
  1. package/dist/module-architecture.js +27 -0
  2. package/package.json +3 -3
  3. package/templates/.env.example +23 -9
  4. package/templates/apps/api/CLAUDE.md +21 -17
  5. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +98 -0
  6. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +321 -0
  7. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +117 -0
  8. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +39 -14
  9. package/templates/apps/api/src/config/index.ts +35 -20
  10. package/templates/apps/api/src/lib/__mocks__/prisma-client.js +3 -0
  11. package/templates/apps/api/src/lib/__mocks__/prisma.ts +14 -0
  12. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +468 -0
  13. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +32 -2
  14. package/templates/apps/api/src/lib/agent-store.ts +521 -0
  15. package/templates/apps/api/src/lib/spoke-store.ts +13 -2
  16. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +384 -1
  17. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +61 -0
  18. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +176 -0
  19. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +76 -0
  20. package/templates/apps/api/src/middleware/auth.ts +219 -5
  21. package/templates/apps/api/src/middleware/default-tenant.ts +29 -8
  22. package/templates/apps/api/src/middleware/rate-limit.ts +35 -13
  23. package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +76 -15
  24. package/templates/apps/api/src/routes/leads/index.ts +14 -6
  25. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -6
  26. package/templates/apps/api/src/routes/project-listings/index.ts +14 -9
  27. package/templates/apps/api/src/routes/project-listings/me.ts +4 -3
  28. package/templates/apps/api/src/routes/project-listings/patch-copy.ts +7 -5
  29. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +198 -0
  30. package/templates/apps/api/src/server.ts +27 -0
  31. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +374 -57
  32. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +25 -2
  33. package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +303 -17
  34. package/templates/apps/api/src/services/audit-log.ts +278 -47
  35. package/templates/apps/api/src/services/default-tenant.ts +55 -5
  36. package/templates/apps/api/src/services/leads-inbox.ts +12 -21
  37. package/templates/apps/api/src/services/project-listings.ts +1 -21
  38. package/templates/apps/api/src/services/public-site-resolver.ts +103 -13
  39. package/templates/apps/mcp/CLAUDE.md +44 -5
  40. package/templates/apps/mcp/__tests__/parity.test.ts +53 -1
  41. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +364 -0
  42. package/templates/apps/mcp/src/http-transport.ts +107 -1
  43. package/templates/apps/mcp/src/workspace-server.ts +193 -0
  44. package/templates/apps/web/.env.example +64 -37
  45. package/templates/apps/web/app/v1/[...path]/route.ts +16 -0
  46. package/templates/apps/web/lib/__tests__/relay.test.ts +515 -0
  47. package/templates/apps/web/lib/relay.ts +279 -0
  48. package/templates/apps/web/middleware.ts +5 -4
  49. package/templates/apps/web/next.config.mjs +6 -0
  50. package/templates/database/CHANGELOG.md +25 -0
  51. package/templates/database/__tests__/agents.db.test.ts +235 -0
  52. package/templates/database/__tests__/build.test.ts +131 -0
  53. package/templates/database/auth/schema.prisma +61 -0
  54. package/templates/database/migrations/20261005120000_agents/migration.sql +84 -0
  55. package/templates/database/package.json +3 -3
  56. package/versions.json +34 -34
@@ -192,7 +192,7 @@ describe("listOwnedLeads", () => {
192
192
  expect(prisma.lead.findMany).not.toHaveBeenCalled();
193
193
  });
194
194
 
195
- it("returns an empty page for a `member` — the role floor is owner|admin", async () => {
195
+ it("returns the rows to a `member` — `inbox-read` holds every role (#7636 D2)", async () => {
196
196
  (prisma.membership.findMany as jest.Mock).mockResolvedValue(
197
197
  membershipsWithRole("member"),
198
198
  );
@@ -200,6 +200,19 @@ describe("listOwnedLeads", () => {
200
200
 
201
201
  const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
202
202
 
203
+ expect(result.ok).toBe(true);
204
+ if (!result.ok) return;
205
+ expect(result.value.items.map((row) => row.id)).toEqual(["lead_own_1"]);
206
+ });
207
+
208
+ it("returns an empty page for a role outside the permission table — default deny", async () => {
209
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(
210
+ membershipsWithRole("writer"),
211
+ );
212
+ seedLeadTable([lead({ id: "lead_own_1", siteId: OWN_SITE_ID })]);
213
+
214
+ const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
215
+
203
216
  expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
204
217
  expect(prisma.lead.findMany).not.toHaveBeenCalled();
205
218
  });
@@ -428,11 +441,21 @@ describe("listOwnedLeads — single-tenant build (#6730 D7)", () => {
428
441
  expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
429
442
  });
430
443
 
431
- it("a `member` role → empty — the role floor still holds", async () => {
444
+ it("a `member` role → the default workspace's leads — `inbox-read` holds every role", async () => {
432
445
  oneWorkspaceDeployment({ email: OPERATOR_EMAIL, role: "member" });
433
446
 
434
447
  const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
435
448
 
449
+ expect(result.ok).toBe(true);
450
+ if (!result.ok) return;
451
+ expect(result.value.items.map((r) => r.id)).toEqual(["lead_own"]);
452
+ });
453
+
454
+ it("a role outside the permission table → empty — default deny", async () => {
455
+ oneWorkspaceDeployment({ email: OPERATOR_EMAIL, role: "writer" });
456
+
457
+ const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
458
+
436
459
  expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
437
460
  });
438
461
 
@@ -2,7 +2,11 @@
2
2
  * @file spoke-resolution.test.ts
3
3
  *
4
4
  * `resolveCallerSite(request)` and the public resolver's spoke arms (#7532,
5
- * spec docs/specs/7532-a-spoke-s-public-reads-spec.md D3, AC-1, AC-3, AC-4).
5
+ * spec docs/specs/7532-a-spoke-s-public-reads-spec.md D3, AC-1, AC-3, AC-4),
6
+ * and its keyless branch since #7633 (spec
7
+ * docs/specs/7633-expected-a-keyless-public-read-spec.md D1–D3, AC-1, AC-2,
8
+ * AC-5): a hub — a deployment whose registry holds a row — refuses a keyless
9
+ * call exactly as it refuses an unknown key, with no switch set.
6
10
  *
7
11
  * A spoke names itself with ONE request header, `x-ailk-spoke-key`, looked up
8
12
  * by its SHA-256 digest in the hub's registry. Nothing else on the request —
@@ -10,7 +14,9 @@
10
14
  *
11
15
  * Fixture (spec § 5): three workspaces — the sentinel default `F`, `W1`, `W2`;
12
16
  * spokes `S1` → `W1`, `S1b` → `W1`, `S2` → `W2`, each with its own key.
13
- * `db.spoke.findUnique` answers by `credentialHash`.
17
+ * `db.spoke.findUnique` answers by `credentialHash`; `db.spoke.findFirst` — the
18
+ * "is this a hub" read — answers `{ id: S1 }` (a non-empty registry) unless a
19
+ * case empties it.
14
20
  *
15
21
  * External dependency boundaries mocked:
16
22
  * - @working-theory/database (via the prisma singleton; __mocks__/prisma.ts)
@@ -28,10 +34,12 @@ jest.mock("../../lib/content-adapter.js", () => ({
28
34
  import type { FastifyRequest } from "fastify";
29
35
 
30
36
  import * as configModule from "../../config/index.js";
37
+ import type { HubSpokeKeyMode } from "../../config/index.js";
31
38
  import { getContentAdapter } from "../../lib/content-adapter.js";
32
39
  import { mockSentinelWorkspaceExists } from "../../lib/__mocks__/prisma.js";
33
40
  import { prisma } from "../../lib/prisma.js";
34
41
  import {
42
+ __resetHubStatusForTests,
35
43
  resolveCallerSite,
36
44
  resolvePublicContentAdapter,
37
45
  resolvePublicWorkspace,
@@ -58,7 +66,10 @@ const digest = (key: string) => createHash("sha256").update(key).digest("hex");
58
66
 
59
67
  type SpokeRow = { id: string; workspaceId: string; revokedAt: Date | null };
60
68
 
61
- /** Program the registry: `db.spoke.findUnique` answers by digest. */
69
+ /**
70
+ * Program the registry: `db.spoke.findUnique` answers by digest, and
71
+ * `db.spoke.findFirst` reports a non-empty registry (#7633 D1).
72
+ */
62
73
  function seedRegistry(revoked: readonly string[] = []): void {
63
74
  const rows = new Map<string, SpokeRow>([
64
75
  [digest(KEY_S1), { id: S1, workspaceId: W1, revokedAt: null }],
@@ -73,6 +84,12 @@ function seedRegistry(revoked: readonly string[] = []): void {
73
84
  }: {
74
85
  where: { credentialHash: string };
75
86
  }) => rows.get(where.credentialHash) ?? null) as never);
87
+ db.spoke.findFirst.mockResolvedValue({ id: S1 } as never);
88
+ }
89
+
90
+ /** An empty registry: a deployment with no spoke is not a hub (#7633 D1). */
91
+ function emptyRegistry(): void {
92
+ db.spoke.findFirst.mockResolvedValue(null);
76
93
  }
77
94
 
78
95
  /** A request double carrying only what the resolver may read. */
@@ -89,10 +106,10 @@ function requestWith(headers: Record<string, string | string[]>): {
89
106
  return { request, warn };
90
107
  }
91
108
 
92
- function setStrictMode(on: boolean): void {
109
+ function setMode(mode: HubSpokeKeyMode): void {
93
110
  jest.replaceProperty(configModule, "config", {
94
111
  ...configModule.config,
95
- AILK_HUB_REQUIRE_SPOKE_KEY: on,
112
+ AILK_HUB_REQUIRE_SPOKE_KEY: mode,
96
113
  });
97
114
  }
98
115
 
@@ -108,6 +125,7 @@ function expectNoSecretIn(log: jest.Mock): void {
108
125
 
109
126
  const savedAdapter = process.env.CONTENT_ADAPTER;
110
127
  const savedDatabaseUrl = process.env.DATABASE_URL;
128
+ const savedMode = process.env.AILK_HUB_REQUIRE_SPOKE_KEY;
111
129
 
112
130
  beforeEach(() => {
113
131
  jest.resetAllMocks();
@@ -117,7 +135,8 @@ beforeEach(() => {
117
135
  seedRegistry();
118
136
  mockSentinelWorkspaceExists(db, W1, W2);
119
137
  db.workspace.count.mockResolvedValue(3);
120
- setStrictMode(false);
138
+ setMode("auto");
139
+ __resetHubStatusForTests();
121
140
  });
122
141
 
123
142
  afterEach(() => {
@@ -126,6 +145,8 @@ afterEach(() => {
126
145
  else process.env.CONTENT_ADAPTER = savedAdapter;
127
146
  if (savedDatabaseUrl === undefined) delete process.env.DATABASE_URL;
128
147
  else process.env.DATABASE_URL = savedDatabaseUrl;
148
+ if (savedMode === undefined) delete process.env.AILK_HUB_REQUIRE_SPOKE_KEY;
149
+ else process.env.AILK_HUB_REQUIRE_SPOKE_KEY = savedMode;
129
150
  });
130
151
 
131
152
  describe("resolveCallerSite — a registered spoke (AC-1)", () => {
@@ -224,8 +245,8 @@ describe("resolveCallerSite — fail closed (AC-3)", () => {
224
245
  expectNoSecretIn(warn);
225
246
  });
226
247
 
227
- it("(v) strict mode refuses a header-less request whatever host, origin or workspace header it carries", async () => {
228
- setStrictMode(true);
248
+ it("(v) mode \"on\" refuses a header-less request whatever host, origin or workspace header it carries", async () => {
249
+ setMode("on");
229
250
  const { request } = requestWith({
230
251
  host: "w1.example",
231
252
  "x-forwarded-host": "w1.example",
@@ -265,17 +286,20 @@ describe("resolveCallerSite — the registry module is loaded only when a key ne
265
286
  jest.dontMock("../../lib/spoke-store.js");
266
287
  });
267
288
 
268
- it("a header-less request is anonymous: zero registry calls, no registry import", async () => {
289
+ it("with DATABASE_URL unset, a header-less request is anonymous: no registry read, no registry import (#7633 AC-2)", async () => {
290
+ delete process.env.DATABASE_URL;
269
291
  await withThrowingSpokeStore(async (mod) => {
270
292
  const { request } = requestWith({});
271
293
  await expect(mod.resolveCallerSite(request)).resolves.toEqual({
272
294
  kind: "anonymous",
273
295
  });
274
296
  });
297
+ expect(db.spoke.findFirst).not.toHaveBeenCalled();
275
298
  expect(db.spoke.findUnique).not.toHaveBeenCalled();
276
299
  });
277
300
 
278
- it("a bare request object with no headers at all is anonymous (the frozen suite's shape)", async () => {
301
+ it("with DATABASE_URL unset, a bare request object with no headers at all is anonymous (the frozen suite's shape)", async () => {
302
+ delete process.env.DATABASE_URL;
279
303
  await withThrowingSpokeStore(async (mod) => {
280
304
  await expect(
281
305
  mod.resolveCallerSite({} as FastifyRequest),
@@ -283,6 +307,17 @@ describe("resolveCallerSite — the registry module is loaded only when a key ne
283
307
  });
284
308
  });
285
309
 
310
+ it("mode \"off\" on a hub: a header-less request is anonymous, with no registry import (#7633 AC-5)", async () => {
311
+ // The isolated resolver reads a fresh config, parsed from the environment.
312
+ process.env.AILK_HUB_REQUIRE_SPOKE_KEY = "false";
313
+ await withThrowingSpokeStore(async (mod) => {
314
+ const { request } = requestWith({});
315
+ await expect(mod.resolveCallerSite(request)).resolves.toEqual({
316
+ kind: "anonymous",
317
+ });
318
+ });
319
+ });
320
+
286
321
  it("(iv) a valid-looking key with DATABASE_URL unset is no-database, with no registry import", async () => {
287
322
  delete process.env.DATABASE_URL;
288
323
  await withThrowingSpokeStore(async (mod) => {
@@ -327,7 +362,8 @@ describe("resolvePublicWorkspace — the spoke prefix (D3)", () => {
327
362
  expect(db.workspace.count).not.toHaveBeenCalled();
328
363
  });
329
364
 
330
- it("a header-less request keeps today's answer — ambiguous on a three-workspace hub", async () => {
365
+ it("with an empty registry, a header-less request keeps today's answer — ambiguous on three workspaces (#7633 AC-2)", async () => {
366
+ emptyRegistry();
331
367
  const { request } = requestWith({});
332
368
  await expect(resolvePublicWorkspace(request)).resolves.toEqual({
333
369
  kind: "unresolved",
@@ -359,12 +395,13 @@ describe("resolvePublicContentAdapter — a spoke's content read (AC-1)", () =>
359
395
  expect(getContentAdapter).toHaveBeenCalledWith();
360
396
  });
361
397
 
362
- it.each<[string, Record<string, string>, boolean]>([
363
- ["an unregistered key", { "x-ailk-spoke-key": KEY_UNKNOWN }, false],
364
- ["a malformed key", { "x-ailk-spoke-key": "" }, false],
365
- ["a header-less request in strict mode", { host: "w1.example" }, true],
366
- ])("%s resolves to null — no adapter at all (AC-3)", async (_label, headers, strict) => {
367
- setStrictMode(strict);
398
+ it.each<[string, Record<string, string>, HubSpokeKeyMode]>([
399
+ ["an unregistered key", { "x-ailk-spoke-key": KEY_UNKNOWN }, "auto"],
400
+ ["a malformed key", { "x-ailk-spoke-key": "" }, "auto"],
401
+ ["a header-less request in mode \"on\"", { host: "w1.example" }, "on"],
402
+ ["a header-less request on a hub, with no switch set (#7633 AC-1)", { host: "w1.example" }, "auto"],
403
+ ])("%s resolves to null — no adapter at all (AC-3)", async (_label, headers, mode) => {
404
+ setMode(mode);
368
405
  const { request, warn } = requestWith(headers);
369
406
 
370
407
  await expect(resolvePublicContentAdapter(request)).resolves.toBeNull();
@@ -372,3 +409,252 @@ describe("resolvePublicContentAdapter — a spoke's content read (AC-1)", () =>
372
409
  expectNoSecretIn(warn);
373
410
  });
374
411
  });
412
+
413
+ // ── #7633 — the keyless branch on a hub ──────────────────────────────────────
414
+
415
+ describe("resolveCallerSite — a keyless call on a hub is refused like an unknown key (#7633 AC-1)", () => {
416
+ it("is refused as key-required after one registry read with no where, and no key lookup", async () => {
417
+ const { request, warn } = requestWith({});
418
+
419
+ await expect(resolveCallerSite(request)).resolves.toEqual({
420
+ kind: "refused",
421
+ reason: "key-required",
422
+ });
423
+ expect(db.spoke.findFirst).toHaveBeenCalledTimes(1);
424
+ expect(db.spoke.findFirst).toHaveBeenCalledWith({ select: { id: true } });
425
+ expect(db.spoke.findUnique).not.toHaveBeenCalled();
426
+ expect(warn).toHaveBeenCalledWith(
427
+ { reason: "key-required" },
428
+ expect.any(String),
429
+ );
430
+ });
431
+
432
+ it("is refused when the registry's only row is revoked — revocation never re-opens keyless access", async () => {
433
+ seedRegistry([S1, S1B, S2]);
434
+ const { request } = requestWith({});
435
+
436
+ await expect(resolveCallerSite(request)).resolves.toEqual({
437
+ kind: "refused",
438
+ reason: "key-required",
439
+ });
440
+ expect(db.spoke.findFirst).toHaveBeenCalledWith({ select: { id: true } });
441
+ expect(db.spoke.findUnique).not.toHaveBeenCalled();
442
+ });
443
+
444
+ it("is refused whatever host, origin or workspace header it carries", async () => {
445
+ const { request } = requestWith({
446
+ host: "w1.example",
447
+ "x-forwarded-host": "w1.example",
448
+ origin: "https://w1.example",
449
+ "x-ailk-workspace-id": W1,
450
+ });
451
+
452
+ await expect(resolveCallerSite(request)).resolves.toEqual({
453
+ kind: "refused",
454
+ reason: "key-required",
455
+ });
456
+ expect(db.spoke.findFirst).toHaveBeenCalledTimes(1);
457
+ expect(db.spoke.findUnique).not.toHaveBeenCalled();
458
+ });
459
+
460
+ it("resolvePublicWorkspace answers it exactly as it answers an unknown key", async () => {
461
+ const keyless = await resolvePublicWorkspace(requestWith({}).request);
462
+ const unknown = await resolvePublicWorkspace(
463
+ requestWith({ "x-ailk-spoke-key": KEY_UNKNOWN }).request,
464
+ );
465
+
466
+ expect(keyless).toEqual({ kind: "unresolved", reason: "spoke-refused" });
467
+ expect(keyless).toEqual(unknown);
468
+ expect(db.workspace.count).not.toHaveBeenCalled();
469
+ });
470
+ });
471
+
472
+ describe("resolveCallerSite — a deployment with no spoke keeps today's keyless answer (#7633 AC-2)", () => {
473
+ it("with DATABASE_URL set and an empty registry, is anonymous after exactly one registry read", async () => {
474
+ emptyRegistry();
475
+ const { request, warn } = requestWith({});
476
+
477
+ await expect(resolveCallerSite(request)).resolves.toEqual({
478
+ kind: "anonymous",
479
+ });
480
+ expect(db.spoke.findFirst).toHaveBeenCalledTimes(1);
481
+ expect(db.spoke.findUnique).not.toHaveBeenCalled();
482
+ expect(warn).not.toHaveBeenCalled();
483
+ });
484
+
485
+ it("an unprogrammed read (undefined, the shared mock's default) is read as an empty registry", async () => {
486
+ db.spoke.findFirst.mockReset();
487
+ await expect(resolveCallerSite(requestWith({}).request)).resolves.toEqual({
488
+ kind: "anonymous",
489
+ });
490
+ });
491
+ });
492
+
493
+ describe("resolveCallerSite — the switch and fail-closed reads (#7633 AC-5)", () => {
494
+ it("mode \"off\" with a non-empty registry is anonymous with zero registry calls", async () => {
495
+ setMode("off");
496
+ await expect(resolveCallerSite(requestWith({}).request)).resolves.toEqual({
497
+ kind: "anonymous",
498
+ });
499
+ expect(db.spoke.findFirst).not.toHaveBeenCalled();
500
+ expect(db.spoke.findUnique).not.toHaveBeenCalled();
501
+ });
502
+
503
+ it("mode \"on\" with an empty registry is refused with zero registry calls", async () => {
504
+ setMode("on");
505
+ emptyRegistry();
506
+ await expect(resolveCallerSite(requestWith({}).request)).resolves.toEqual({
507
+ kind: "refused",
508
+ reason: "key-required",
509
+ });
510
+ expect(db.spoke.findFirst).not.toHaveBeenCalled();
511
+ expect(db.spoke.findUnique).not.toHaveBeenCalled();
512
+ });
513
+
514
+ it.each<[string, unknown]>([
515
+ ["a leftover boolean true", true],
516
+ ["an unknown string", "yes"],
517
+ ])("a mode outside the union (%s) refuses, with zero registry calls (NFR5)", async (_label, value) => {
518
+ emptyRegistry();
519
+ jest.replaceProperty(configModule, "config", {
520
+ ...configModule.config,
521
+ AILK_HUB_REQUIRE_SPOKE_KEY: value as HubSpokeKeyMode,
522
+ });
523
+ await expect(resolveCallerSite(requestWith({}).request)).resolves.toEqual({
524
+ kind: "refused",
525
+ reason: "key-required",
526
+ });
527
+ expect(db.spoke.findFirst).not.toHaveBeenCalled();
528
+ });
529
+
530
+ it("a failed registry read rejects — never anonymous — and is not held: the next call reads again (NFR3)", async () => {
531
+ db.spoke.findFirst.mockRejectedValueOnce(new Error("connection reset"));
532
+
533
+ await expect(resolveCallerSite(requestWith({}).request)).rejects.toThrow(
534
+ "connection reset",
535
+ );
536
+
537
+ emptyRegistry();
538
+ await expect(resolveCallerSite(requestWith({}).request)).resolves.toEqual({
539
+ kind: "anonymous",
540
+ });
541
+ expect(db.spoke.findFirst).toHaveBeenCalledTimes(2);
542
+ });
543
+ });
544
+
545
+ describe("resolveCallerSite — the held hub answer (#7633 D3, AC-5)", () => {
546
+ const T0 = 1_800_000_000_000;
547
+ let now: number;
548
+
549
+ beforeEach(() => {
550
+ now = T0;
551
+ jest.spyOn(Date, "now").mockImplementation(() => now);
552
+ });
553
+
554
+ async function keyless() {
555
+ return resolveCallerSite(requestWith({}).request);
556
+ }
557
+
558
+ it("a \"yes\" is held: two keyless calls on a hub make one read, and both are refused", async () => {
559
+ await expect(keyless()).resolves.toMatchObject({ kind: "refused" });
560
+ now = T0 + 5_000;
561
+ await expect(keyless()).resolves.toMatchObject({ kind: "refused" });
562
+ expect(db.spoke.findFirst).toHaveBeenCalledTimes(1);
563
+ });
564
+
565
+ it("a \"no\" is held for 30 s, then read again; a later \"yes\" is held for good", async () => {
566
+ emptyRegistry();
567
+
568
+ await expect(keyless()).resolves.toEqual({ kind: "anonymous" });
569
+ now = T0 + 29_999;
570
+ await expect(keyless()).resolves.toEqual({ kind: "anonymous" });
571
+ expect(db.spoke.findFirst).toHaveBeenCalledTimes(1);
572
+
573
+ // The first registration lands; the held "no" expires.
574
+ db.spoke.findFirst.mockResolvedValue({ id: S1 } as never);
575
+ now = T0 + 30_001;
576
+ await expect(keyless()).resolves.toEqual({
577
+ kind: "refused",
578
+ reason: "key-required",
579
+ });
580
+ expect(db.spoke.findFirst).toHaveBeenCalledTimes(2);
581
+
582
+ // Never reverts in-process, whatever the table later says.
583
+ emptyRegistry();
584
+ now = T0 + 10 * 60_000;
585
+ await expect(keyless()).resolves.toMatchObject({ kind: "refused" });
586
+ expect(db.spoke.findFirst).toHaveBeenCalledTimes(2);
587
+ });
588
+ });
589
+
590
+ describe("resolveCallerSite — a failed hub-status read (#7633 conductor amendment to NFR3: lead capture is never lost)", () => {
591
+ const T0 = 1_800_000_000_000;
592
+ let now: number;
593
+
594
+ beforeEach(() => {
595
+ now = T0;
596
+ jest.spyOn(Date, "now").mockImplementation(() => now);
597
+ });
598
+
599
+ /** A request double whose `log.error` is observable. */
600
+ function keylessWithLog() {
601
+ const error = jest.fn();
602
+ // eslint-disable-next-line no-restricted-syntax -- a FastifyRequest double carrying only the members the resolver reads (headers, log)
603
+ const request = {
604
+ headers: {},
605
+ log: { warn: jest.fn(), info: jest.fn(), error },
606
+ } as unknown as FastifyRequest;
607
+ return { request, error };
608
+ }
609
+
610
+ it("(a) with a previous successful \"not a hub\", the held answer is reused on every route, and the failure is logged at error", async () => {
611
+ emptyRegistry();
612
+ await expect(resolveCallerSite(requestWith({}).request)).resolves.toEqual({ kind: "anonymous" });
613
+
614
+ db.spoke.findFirst.mockRejectedValue(new Error("connection reset"));
615
+ now = T0 + 30_001;
616
+ const { request, error } = keylessWithLog();
617
+ await expect(resolveCallerSite(request)).resolves.toEqual({ kind: "anonymous" });
618
+ expect(error).toHaveBeenCalledTimes(1);
619
+ expect(JSON.stringify(error.mock.calls)).not.toContain("connection reset");
620
+ expect(db.spoke.findFirst).toHaveBeenCalledTimes(2);
621
+ });
622
+
623
+ it("(b) a cold start: a keyless lead write proceeds as on a non-hub, logged at error; every other caller still rejects", async () => {
624
+ db.spoke.findFirst.mockRejectedValue(new Error("connection reset"));
625
+
626
+ const lead = keylessWithLog();
627
+ await expect(
628
+ resolveCallerSite(lead.request, { keylessOnRegistryFailure: "anonymous" }),
629
+ ).resolves.toEqual({ kind: "anonymous" });
630
+ expect(lead.error).toHaveBeenCalledTimes(1);
631
+
632
+ // Nothing is held: a content read or schedule call still rejects.
633
+ await expect(resolveCallerSite(requestWith({}).request)).rejects.toThrow("connection reset");
634
+ await expect(resolvePublicWorkspace(requestWith({}).request)).rejects.toThrow("connection reset");
635
+ });
636
+
637
+ it("(b) a call that carries a key is unchanged under the lead-write option — an unknown key is refused", async () => {
638
+ db.spoke.findFirst.mockRejectedValue(new Error("connection reset"));
639
+ const { request } = requestWith({ "x-ailk-spoke-key": KEY_UNKNOWN });
640
+ await expect(
641
+ resolveCallerSite(request, { keylessOnRegistryFailure: "anonymous" }),
642
+ ).resolves.toEqual({ kind: "refused", reason: "key-unknown" });
643
+ expect(db.spoke.findFirst).not.toHaveBeenCalled();
644
+ });
645
+
646
+ it("(c) a process that has seen a hub keeps refusing keyless calls through an outage — on a lead write too", async () => {
647
+ await expect(resolveCallerSite(requestWith({}).request)).resolves.toMatchObject({ kind: "refused" });
648
+
649
+ db.spoke.findFirst.mockRejectedValue(new Error("connection reset"));
650
+ now = T0 + 10 * 60_000;
651
+ await expect(
652
+ resolveCallerSite(requestWith({}).request, { keylessOnRegistryFailure: "anonymous" }),
653
+ ).resolves.toEqual({ kind: "refused", reason: "key-required" });
654
+ await expect(resolveCallerSite(requestWith({}).request)).resolves.toEqual({
655
+ kind: "refused",
656
+ reason: "key-required",
657
+ });
658
+ expect(db.spoke.findFirst).toHaveBeenCalledTimes(1);
659
+ });
660
+ });