create-ailk 0.12.1 → 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.
- package/dist/module-architecture.js +27 -0
- package/package.json +3 -3
- package/templates/.env.example +23 -9
- package/templates/apps/api/CLAUDE.md +21 -17
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +98 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +321 -0
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +117 -0
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +39 -14
- package/templates/apps/api/src/config/index.ts +35 -20
- package/templates/apps/api/src/lib/__mocks__/prisma-client.js +3 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +14 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +468 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +32 -2
- package/templates/apps/api/src/lib/agent-store.ts +521 -0
- package/templates/apps/api/src/lib/spoke-store.ts +13 -2
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +384 -1
- package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +61 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +176 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +76 -0
- package/templates/apps/api/src/middleware/auth.ts +219 -5
- package/templates/apps/api/src/middleware/default-tenant.ts +29 -8
- package/templates/apps/api/src/middleware/rate-limit.ts +35 -13
- package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +76 -15
- package/templates/apps/api/src/routes/leads/index.ts +14 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -6
- package/templates/apps/api/src/routes/project-listings/index.ts +14 -9
- package/templates/apps/api/src/routes/project-listings/me.ts +4 -3
- package/templates/apps/api/src/routes/project-listings/patch-copy.ts +7 -5
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +198 -0
- package/templates/apps/api/src/server.ts +27 -0
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +374 -57
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +25 -2
- package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +303 -17
- package/templates/apps/api/src/services/audit-log.ts +278 -47
- package/templates/apps/api/src/services/default-tenant.ts +55 -5
- package/templates/apps/api/src/services/leads-inbox.ts +12 -21
- package/templates/apps/api/src/services/project-listings.ts +1 -21
- package/templates/apps/api/src/services/public-site-resolver.ts +103 -13
- package/templates/apps/mcp/CLAUDE.md +44 -5
- package/templates/apps/mcp/__tests__/parity.test.ts +53 -1
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +364 -0
- package/templates/apps/mcp/src/http-transport.ts +107 -1
- package/templates/apps/mcp/src/workspace-server.ts +193 -0
- package/templates/apps/web/.env.example +64 -37
- package/templates/apps/web/app/v1/[...path]/route.ts +16 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +515 -0
- package/templates/apps/web/lib/relay.ts +279 -0
- package/templates/apps/web/middleware.ts +5 -4
- package/templates/apps/web/next.config.mjs +6 -0
- package/templates/database/CHANGELOG.md +15 -0
- package/templates/database/__tests__/agents.db.test.ts +235 -0
- package/templates/database/auth/schema.prisma +61 -0
- package/templates/database/migrations/20261005120000_agents/migration.sql +84 -0
- package/templates/database/package.json +1 -1
- 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
|
|
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 →
|
|
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
|
-
/**
|
|
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
|
|
109
|
+
function setMode(mode: HubSpokeKeyMode): void {
|
|
93
110
|
jest.replaceProperty(configModule, "config", {
|
|
94
111
|
...configModule.config,
|
|
95
|
-
AILK_HUB_REQUIRE_SPOKE_KEY:
|
|
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
|
-
|
|
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)
|
|
228
|
-
|
|
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:
|
|
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
|
|
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>,
|
|
363
|
-
["an unregistered key", { "x-ailk-spoke-key": KEY_UNKNOWN },
|
|
364
|
-
["a malformed key", { "x-ailk-spoke-key": "" },
|
|
365
|
-
["a header-less request in
|
|
366
|
-
|
|
367
|
-
|
|
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
|
+
});
|