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
@@ -0,0 +1,321 @@
1
+ /**
2
+ * @file in-memory-prisma.ts
3
+ *
4
+ * An in-memory, where-clause-honouring Prisma double, shared by the route
5
+ * suites that register every tenant-guarded route plugin behind the real auth
6
+ * and tenant preHandlers (`routes/workspaces/__tests__/member-operates.test.ts`,
7
+ * #7636, and `agent-guard-parity.test.ts`, #3591).
8
+ *
9
+ * Every delegate call is recorded on `__calls` and the store is exposed on
10
+ * `__db`, so a refusal can be shown to read nothing. A relation filter the
11
+ * double does not model throws, so scoping that rides on one can never pass
12
+ * for the wrong reason.
13
+ *
14
+ * Usage — the factory must run inside the hoisted `jest.mock` callback:
15
+ *
16
+ * jest.mock("../../../lib/prisma.js", () =>
17
+ * jest
18
+ * .requireActual<typeof import("../../../__tests__/factories/in-memory-prisma")>(
19
+ * "../../../__tests__/factories/in-memory-prisma",
20
+ * )
21
+ * .createInMemoryPrisma(),
22
+ * );
23
+ */
24
+ export function createInMemoryPrisma(): { prisma: Record<string, unknown> } {
25
+ type Row = Record<string, unknown>;
26
+ type Where = Record<string, unknown>;
27
+ const db: Record<string, Row[]> = {};
28
+ const calls: Array<{ model: string; op: string }> = [];
29
+ const OPS = new Set([
30
+ "in",
31
+ "notIn",
32
+ "lt",
33
+ "lte",
34
+ "gt",
35
+ "gte",
36
+ "not",
37
+ "equals",
38
+ "startsWith",
39
+ "endsWith",
40
+ "contains",
41
+ "has",
42
+ "hasSome",
43
+ "mode",
44
+ "path",
45
+ ]);
46
+ const isPlain = (v: unknown): v is Record<string, unknown> =>
47
+ v !== null &&
48
+ typeof v === "object" &&
49
+ !Array.isArray(v) &&
50
+ !(v instanceof Date);
51
+ const norm = (v: unknown) => (v instanceof Date ? v.getTime() : (v ?? null));
52
+ const eq = (a: unknown, b: unknown) => norm(a) === norm(b);
53
+ const cmp = (a: unknown, b: unknown) => {
54
+ const x = norm(a) as number;
55
+ const y = norm(b) as number;
56
+ return x < y ? -1 : x > y ? 1 : 0;
57
+ };
58
+ const table = (model: string) => (db[model] ??= []);
59
+
60
+ function matchCond(value: unknown, cond: unknown): boolean {
61
+ if (cond === undefined) return true;
62
+ if (!isPlain(cond)) return eq(value, cond);
63
+ const keys = Object.keys(cond);
64
+ if (!keys.every((k) => OPS.has(k))) {
65
+ // A nested filter is matched against a nested value. A relation filter
66
+ // this double does not model fails loudly, so scoping that rides on
67
+ // one can never pass for the wrong reason.
68
+ if (
69
+ keys.some((k) =>
70
+ ["some", "none", "every", "is", "isNot"].includes(k),
71
+ ) ||
72
+ !isPlain(value)
73
+ ) {
74
+ throw new Error(
75
+ `in-memory prisma: unmodelled filter ${JSON.stringify(keys)}`,
76
+ );
77
+ }
78
+ return matches(value, cond);
79
+ }
80
+ if ("path" in cond) {
81
+ const path = cond.path as string[];
82
+ let nested: unknown = value;
83
+ for (const p of path) nested = isPlain(nested) ? nested[p] : undefined;
84
+ const { path: _p, ...rest } = cond;
85
+ return matchCond(nested, rest);
86
+ }
87
+ for (const [op, arg] of Object.entries(cond)) {
88
+ switch (op) {
89
+ case "in":
90
+ if (!(arg as unknown[]).some((v) => eq(value, v))) return false;
91
+ break;
92
+ case "notIn":
93
+ if ((arg as unknown[]).some((v) => eq(value, v))) return false;
94
+ break;
95
+ case "lt":
96
+ if (value == null || cmp(value, arg) >= 0) return false;
97
+ break;
98
+ case "lte":
99
+ if (value == null || cmp(value, arg) > 0) return false;
100
+ break;
101
+ case "gt":
102
+ if (value == null || cmp(value, arg) <= 0) return false;
103
+ break;
104
+ case "gte":
105
+ if (value == null || cmp(value, arg) < 0) return false;
106
+ break;
107
+ case "not":
108
+ if (matchCond(value, arg)) return false;
109
+ break;
110
+ case "equals":
111
+ if (!eq(value, arg)) return false;
112
+ break;
113
+ case "startsWith":
114
+ if (typeof value !== "string" || !value.startsWith(arg as string))
115
+ return false;
116
+ break;
117
+ case "endsWith":
118
+ if (typeof value !== "string" || !value.endsWith(arg as string))
119
+ return false;
120
+ break;
121
+ case "contains":
122
+ if (typeof value !== "string" || !value.includes(arg as string))
123
+ return false;
124
+ break;
125
+ case "has":
126
+ if (!Array.isArray(value) || !value.includes(arg)) return false;
127
+ break;
128
+ case "hasSome":
129
+ if (
130
+ !Array.isArray(value) ||
131
+ !(arg as unknown[]).some((v) => value.includes(v))
132
+ ) {
133
+ return false;
134
+ }
135
+ break;
136
+ default:
137
+ break;
138
+ }
139
+ }
140
+ return true;
141
+ }
142
+
143
+ function matches(row: Row, where: Where | undefined): boolean {
144
+ for (const [key, cond] of Object.entries(where ?? {})) {
145
+ if (key === "AND") {
146
+ const all = Array.isArray(cond) ? cond : [cond];
147
+ if (!all.every((w) => matches(row, w as Where))) return false;
148
+ } else if (key === "OR") {
149
+ if (!(cond as Where[]).some((w) => matches(row, w))) return false;
150
+ } else if (key === "NOT") {
151
+ const all = Array.isArray(cond) ? cond : [cond];
152
+ if (all.some((w) => matches(row, w as Where))) return false;
153
+ } else if (
154
+ !(key in row) &&
155
+ isPlain(cond) &&
156
+ !Object.keys(cond).every((k) => OPS.has(k))
157
+ ) {
158
+ if (!matches(row, cond)) return false;
159
+ } else if (!matchCond(row[key], cond)) {
160
+ return false;
161
+ }
162
+ }
163
+ return true;
164
+ }
165
+
166
+ function pick(value: unknown, select: unknown): unknown {
167
+ if (!isPlain(select) || value == null) return value;
168
+ if (Array.isArray(value)) return value.map((v) => pick(v, select));
169
+ if (!isPlain(value)) return value;
170
+ const out: Row = {};
171
+ for (const [key, spec] of Object.entries(select)) {
172
+ if (spec === true) out[key] = value[key];
173
+ else if (isPlain(spec) && key in value) {
174
+ out[key] = pick(value[key], (spec as Row).select ?? spec);
175
+ }
176
+ }
177
+ return out;
178
+ }
179
+
180
+ function sorted(rows: Row[], orderBy: unknown): Row[] {
181
+ if (!orderBy) return rows;
182
+ const orders = (Array.isArray(orderBy) ? orderBy : [orderBy]) as Array<
183
+ Record<string, string>
184
+ >;
185
+ return [...rows].sort((a, b) => {
186
+ for (const ord of orders) {
187
+ const [field, dir] = Object.entries(ord)[0] as [string, string];
188
+ const c = cmp(a[field], b[field]);
189
+ if (c !== 0) return dir === "desc" ? -c : c;
190
+ }
191
+ return 0;
192
+ });
193
+ }
194
+
195
+ let seq = 0;
196
+ function apply(row: Row, data: Row): Row {
197
+ for (const [key, value] of Object.entries(data)) {
198
+ if (isPlain(value) && "increment" in value) {
199
+ row[key] = ((row[key] as number) ?? 0) + (value.increment as number);
200
+ } else if (isPlain(value) && "set" in value) {
201
+ row[key] = value.set;
202
+ } else {
203
+ row[key] = value;
204
+ }
205
+ }
206
+ row["updatedAt"] = new Date();
207
+ return row;
208
+ }
209
+
210
+ function delegate(model: string) {
211
+ const log = (op: string) => calls.push({ model, op });
212
+ const findMany = (args: Row = {}) => {
213
+ let rows = sorted(
214
+ table(model).filter((r) => matches(r, args.where as Where)),
215
+ args.orderBy,
216
+ );
217
+ if (typeof args.skip === "number") rows = rows.slice(args.skip);
218
+ if (typeof args.take === "number") rows = rows.slice(0, args.take);
219
+ return rows.map(
220
+ (r) => pick({ ...r }, args.select ?? args.include) as Row,
221
+ );
222
+ };
223
+ const create = (args: Row) => {
224
+ const data = args.data as Row;
225
+ const row: Row = {
226
+ // A registry-shaped id, so an `isId` boundary check accepts it.
227
+ id: `${model === "user" ? "user" : model}_00000000-0000-7000-8000-${String(++seq).padStart(12, "0")}`,
228
+ createdAt: new Date(),
229
+ updatedAt: new Date(),
230
+ ...data,
231
+ };
232
+ table(model).push(row);
233
+ return pick({ ...row }, args.select);
234
+ };
235
+ return {
236
+ findMany: jest.fn(
237
+ async (args: Row = {}) => (log("findMany"), findMany(args)),
238
+ ),
239
+ findFirst: jest.fn(
240
+ async (args: Row = {}) => (log("findFirst"), findMany(args)[0] ?? null),
241
+ ),
242
+ findUnique: jest.fn(
243
+ async (args: Row = {}) => (
244
+ log("findUnique"),
245
+ findMany(args)[0] ?? null
246
+ ),
247
+ ),
248
+ findUniqueOrThrow: jest.fn(async (args: Row = {}) => {
249
+ log("findUniqueOrThrow");
250
+ const row = findMany(args)[0];
251
+ if (!row) throw new Error(`${model} not found`);
252
+ return row;
253
+ }),
254
+ count: jest.fn(
255
+ async (args: Row = {}) => (
256
+ log("count"),
257
+ findMany({ where: args.where }).length
258
+ ),
259
+ ),
260
+ create: jest.fn(async (args: Row) => (log("create"), create(args))),
261
+ update: jest.fn(async (args: Row) => {
262
+ log("update");
263
+ const row = table(model).find((r) => matches(r, args.where as Where));
264
+ if (!row) throw new Error(`${model} not found`);
265
+ return pick({ ...apply(row, args.data as Row) }, args.select);
266
+ }),
267
+ updateMany: jest.fn(async (args: Row) => {
268
+ log("updateMany");
269
+ const rows = table(model).filter((r) =>
270
+ matches(r, args.where as Where),
271
+ );
272
+ for (const row of rows) apply(row, args.data as Row);
273
+ return { count: rows.length };
274
+ }),
275
+ upsert: jest.fn(async (args: Row) => {
276
+ log("upsert");
277
+ const row = table(model).find((r) => matches(r, args.where as Where));
278
+ if (row)
279
+ return pick({ ...apply(row, args.update as Row) }, args.select);
280
+ return create({ data: args.create, select: args.select });
281
+ }),
282
+ deleteMany: jest.fn(async (args: Row = {}) => {
283
+ log("deleteMany");
284
+ const before = table(model).length;
285
+ db[model] = table(model).filter(
286
+ (r) => !matches(r, args.where as Where),
287
+ );
288
+ return { count: before - table(model).length };
289
+ }),
290
+ groupBy: jest.fn(async () => (log("groupBy"), [])),
291
+ aggregate: jest.fn(
292
+ async () => (
293
+ log("aggregate"),
294
+ { _count: {}, _sum: {}, _max: {}, _min: {} }
295
+ ),
296
+ ),
297
+ };
298
+ }
299
+
300
+ const delegates: Record<string, ReturnType<typeof delegate>> = {};
301
+ const prisma: Record<string, unknown> = new Proxy(
302
+ {},
303
+ {
304
+ get(_target, prop) {
305
+ if (prop === "__db") return db;
306
+ if (prop === "__calls") return calls;
307
+ if (prop === "$transaction") {
308
+ return async (arg: unknown) =>
309
+ typeof arg === "function"
310
+ ? (arg as (tx: unknown) => unknown)(prisma)
311
+ : Promise.all(arg as Promise<unknown>[]);
312
+ }
313
+ if (prop === "$queryRaw" || prop === "$executeRaw")
314
+ return async () => [];
315
+ if (typeof prop !== "string" || prop === "then") return undefined;
316
+ return (delegates[prop] ??= delegate(prop));
317
+ },
318
+ },
319
+ );
320
+ return { prisma };
321
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * @file schedule-forwarded-rate-limit.test.ts
3
+ * @description #7633 (conductor amendment, the relay's security review) — a
4
+ * site's relayed schedule calls are limited per visitor end to end, through
5
+ * the real `build()`, the schedule scope's own limiter and the REAL
6
+ * `@fastify/rate-limit` global ceiling: the schedule scope's twin of
7
+ * `leads-forwarded-rate-limit.test.ts` (#7394 AC-7).
8
+ *
9
+ * Once browsers post through a site's same-origin relay (#7644), every
10
+ * visitor's `GET /v1/schedule/availability` and `POST /v1/schedule/book`
11
+ * reaches the hub from the site's server. With the hub-issued forwarding key,
12
+ * each visitor gets the scope's own per-visitor budget, and the key as a
13
+ * whole a ceiling of 60 a minute — well below the lead routes' 300, because a
14
+ * booking is a real booking.
15
+ *
16
+ * `DATABASE_URL` is unset and the `scheduling` module is enabled for the
17
+ * file's duration. Whatever each route answers past the limiter (a 400 or a
18
+ * 503) is not this file's subject: the limiter runs on `onRequest`, before
19
+ * any handler.
20
+ */
21
+
22
+ import { __resetBuckets } from "../middleware/rate-limit";
23
+ import { build } from "../server";
24
+
25
+ jest.mock("@fastify/cors", () => {
26
+ return jest.fn().mockResolvedValue(undefined);
27
+ });
28
+
29
+ jest.mock("@working-theory/email", () => ({
30
+ sendLeadNotification: jest.fn().mockResolvedValue({ ok: true }),
31
+ }));
32
+
33
+ const KEY = "0123456789abcdef".repeat(4); // gitleaks:allow — test fixture, not a credential (#7394)
34
+ const SPOKE_HOST = "198.51.100.7";
35
+
36
+ /** A distinct, valid visitor IP per index. */
37
+ function visitor(i: number): string {
38
+ return `203.0.${Math.floor(i / 250)}.${(i % 250) + 1}`;
39
+ }
40
+
41
+ function headersFor(visitorIp: string, withKey: boolean): Record<string, string> {
42
+ return {
43
+ "x-forwarded-for": SPOKE_HOST,
44
+ "x-ailk-visitor-ip": visitorIp,
45
+ ...(withKey && { "x-ailk-visitor-forward-key": KEY }),
46
+ };
47
+ }
48
+
49
+ const ROUTES = [
50
+ { method: "GET", url: "/v1/schedule/availability?calLink=acme%2Fintro" },
51
+ { method: "POST", url: "/v1/schedule/book", payload: {} },
52
+ ] as const;
53
+
54
+ describe.each(ROUTES)("$method $url for a keyed site, against the real limiters (#7633)", (route) => {
55
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- server is typed via Fastify generics; explicit any matches server.test.ts
56
+ let app: any;
57
+ const prior = {
58
+ keys: process.env.AILK_VISITOR_FORWARD_KEYS,
59
+ modules: process.env.AILK_ENABLED_MODULES,
60
+ databaseUrl: process.env.DATABASE_URL,
61
+ };
62
+
63
+ async function statuses(count: number, visitorFor: (i: number) => string, withKey: boolean): Promise<number[]> {
64
+ const codes: number[] = [];
65
+ for (let i = 0; i < count; i++) {
66
+ const res = await app.inject({ ...route, headers: headersFor(visitorFor(i), withKey) });
67
+ codes.push(res.statusCode);
68
+ }
69
+ return codes;
70
+ }
71
+
72
+ beforeEach(async () => {
73
+ __resetBuckets();
74
+ process.env.AILK_VISITOR_FORWARD_KEYS = KEY;
75
+ process.env.AILK_ENABLED_MODULES = "scheduling";
76
+ delete process.env.DATABASE_URL;
77
+ app = await build();
78
+ await app.ready();
79
+ });
80
+
81
+ afterEach(async () => {
82
+ if (app) await app.close();
83
+ app = undefined;
84
+ });
85
+
86
+ afterAll(() => {
87
+ for (const [name, value] of [
88
+ ["AILK_VISITOR_FORWARD_KEYS", prior.keys],
89
+ ["AILK_ENABLED_MODULES", prior.modules],
90
+ ["DATABASE_URL", prior.databaseUrl],
91
+ ] as const) {
92
+ if (value === undefined) delete process.env[name];
93
+ else process.env[name] = value;
94
+ }
95
+ });
96
+
97
+ it("two visitors behind one forwarding key are counted separately: the first's eleventh call is 429, the second's first is not", async () => {
98
+ const first = await statuses(11, () => "203.0.113.1", true);
99
+ expect(first.slice(0, 10).every((code) => code !== 429)).toBe(true);
100
+ expect(first[10]).toBe(429);
101
+
102
+ const second = await statuses(1, () => "203.0.113.2", true);
103
+ expect(second[0]).not.toBe(429);
104
+ });
105
+
106
+ it("without the key, distinct visitors behind one host share the host's budget: the eleventh call is 429", async () => {
107
+ const codes = await statuses(11, visitor, false);
108
+ expect(codes.slice(0, 10).every((code) => code !== 429)).toBe(true);
109
+ expect(codes[10]).toBe(429);
110
+ });
111
+
112
+ it("the key's own ceiling is 60 a minute across all of its visitors: the sixty-first is 429", async () => {
113
+ const codes = await statuses(61, visitor, true);
114
+ expect(codes.slice(0, 60).every((code) => code !== 429)).toBe(true);
115
+ expect(codes[60]).toBe(429);
116
+ });
117
+ });
@@ -1,12 +1,15 @@
1
1
  /**
2
2
  * @file hub-require-spoke-key.test.ts
3
- * @description `AILK_HUB_REQUIRE_SPOKE_KEY` — the hub's strict-mode flag
4
- * (#7532, spec docs/specs/7532-a-spoke-s-public-reads-spec.md D7, AC-5).
3
+ * @description `AILK_HUB_REQUIRE_SPOKE_KEY` — the hub's spoke-key mode
4
+ * (#7532 D7; three-state since #7633, spec
5
+ * docs/specs/7633-expected-a-keyless-public-read-spec.md D2, AC-5).
5
6
  *
6
- * `true`/`1` turn it on; unset, empty, `false` and `0` leave it off —
7
- * case-insensitively, after trimming. Any other value refuses the boot with a
8
- * message naming the variable, never the value. On with no `DATABASE_URL`
9
- * refuses the boot too: strict mode with no registry would refuse every call.
7
+ * Unset, empty or blank is `"auto"` (refuse a keyless call once the registry
8
+ * holds a row); `true`/`1` is `"on"`; `false`/`0` is `"off"`, the emergency
9
+ * opt-out, which warns at boot — case-insensitively, after trimming. Any other
10
+ * value refuses the boot with a message naming the variable, never the value.
11
+ * `"on"` with no `DATABASE_URL` refuses the boot too: it would refuse every
12
+ * call. `"auto"` and `"off"` boot without a database.
10
13
  */
11
14
 
12
15
  // Mock dotenv so a developer's local .env never leaks into the config module.
@@ -25,7 +28,7 @@ jest.mock('@working-theory/observability', () => ({
25
28
  },
26
29
  }));
27
30
 
28
- describe('AILK_HUB_REQUIRE_SPOKE_KEY (#7532 D7)', () => {
31
+ describe('AILK_HUB_REQUIRE_SPOKE_KEY (#7532 D7, #7633 D2)', () => {
29
32
  const originalEnv = process.env;
30
33
 
31
34
  beforeEach(() => {
@@ -41,16 +44,34 @@ describe('AILK_HUB_REQUIRE_SPOKE_KEY (#7532 D7)', () => {
41
44
  jest.restoreAllMocks();
42
45
  });
43
46
 
44
- it.each(['true', 'TRUE', '1', ' True '])('%j parses on', (raw) => {
47
+ /** The boot warnings that name this variable. */
48
+ function hubWarnings(): unknown[][] {
49
+ return mockLoggerWarn.mock.calls.filter((call) =>
50
+ JSON.stringify(call).includes('AILK_HUB_REQUIRE_SPOKE_KEY'),
51
+ );
52
+ }
53
+
54
+ it.each([undefined, '', ' '])('%j parses "auto" — the default — and warns nothing', (raw) => {
55
+ if (raw !== undefined) process.env.AILK_HUB_REQUIRE_SPOKE_KEY = raw;
56
+ const { config } = require('../index');
57
+ expect(config).toMatchObject({ AILK_HUB_REQUIRE_SPOKE_KEY: 'auto' });
58
+ expect(hubWarnings()).toHaveLength(0);
59
+ });
60
+
61
+ it.each(['true', 'TRUE', '1', ' True '])('%j parses "on" and warns nothing', (raw) => {
45
62
  process.env.AILK_HUB_REQUIRE_SPOKE_KEY = raw;
46
63
  const { config } = require('../index');
47
- expect(config.AILK_HUB_REQUIRE_SPOKE_KEY).toBe(true);
64
+ expect(config).toMatchObject({ AILK_HUB_REQUIRE_SPOKE_KEY: 'on' });
65
+ expect(hubWarnings()).toHaveLength(0);
48
66
  });
49
67
 
50
- it.each([undefined, '', ' ', 'false', 'FALSE', '0'])('%j parses off', (raw) => {
51
- if (raw !== undefined) process.env.AILK_HUB_REQUIRE_SPOKE_KEY = raw;
68
+ it.each(['false', 'FALSE', '0'])('%j parses "off" — the emergency opt-out — and warns once at boot', (raw) => {
69
+ process.env.AILK_HUB_REQUIRE_SPOKE_KEY = raw;
52
70
  const { config } = require('../index');
53
- expect(config.AILK_HUB_REQUIRE_SPOKE_KEY).toBe(false);
71
+ expect(config).toMatchObject({ AILK_HUB_REQUIRE_SPOKE_KEY: 'off' });
72
+ const warnings = hubWarnings();
73
+ expect(warnings).toHaveLength(1);
74
+ expect(JSON.stringify(warnings)).toContain('AILK_HUB_REQUIRE_SPOKE_KEY=false');
54
75
  });
55
76
 
56
77
  it('any other value refuses the boot, naming the variable and never the value', () => {
@@ -83,7 +104,11 @@ describe('AILK_HUB_REQUIRE_SPOKE_KEY (#7532 D7)', () => {
83
104
  );
84
105
  });
85
106
 
86
- it('off with DATABASE_URL unset still boots database-less, as today', () => {
107
+ it.each([
108
+ ['auto', undefined],
109
+ ['off', 'false'],
110
+ ])('"%s" with DATABASE_URL unset still boots database-less', (mode, raw) => {
111
+ if (raw !== undefined) process.env.AILK_HUB_REQUIRE_SPOKE_KEY = raw;
87
112
  delete process.env.DATABASE_URL;
88
113
  const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
89
114
  throw new Error('Process exit called');
@@ -91,6 +116,6 @@ describe('AILK_HUB_REQUIRE_SPOKE_KEY (#7532 D7)', () => {
91
116
 
92
117
  const { config } = require('../index');
93
118
  expect(exitSpy).not.toHaveBeenCalled();
94
- expect(config.AILK_HUB_REQUIRE_SPOKE_KEY).toBe(false);
119
+ expect(config).toMatchObject({ AILK_HUB_REQUIRE_SPOKE_KEY: mode });
95
120
  });
96
121
  });
@@ -112,15 +112,21 @@ export interface Config {
112
112
  // env-configured CAL_API_KEY path works exactly as before. Held decoded,
113
113
  // never as the raw string, and never logged.
114
114
  SCHEDULING_CREDENTIAL_KEY?: Buffer;
115
- // Hub strict mode (#7532 D7). A hub serving several spokes sets it so a
116
- // request to a spoke-aware public surface (meeting types, the three lead
117
- // writes, booking, the public content reads) that carries no
118
- // `x-ailk-spoke-key` resolves to nothing. Off (the default) keeps today's
119
- // header-less resolution, byte for byte. Parsed by parseHubRequireSpokeKey()
120
- // below; read by services/public-site-resolver.ts.
121
- AILK_HUB_REQUIRE_SPOKE_KEY: boolean;
115
+ // What a hub does with a request to a spoke-aware public surface (meeting
116
+ // types, the three lead writes, booking, availability, event meta, the
117
+ // public content reads) that carries no `x-ailk-spoke-key` (#7532 D7,
118
+ // #7633 D2). `"auto"` (unset, the default) refuses it once the spoke
119
+ // registry holds a row, and keeps the keyless answer on a deployment with
120
+ // no spoke; `"on"` always refuses it; `"off"` never does — an emergency
121
+ // opt-out, warned at boot. A mode, never a boolean: every value is a truthy
122
+ // string, so no reader may test it by truthiness. Parsed by
123
+ // parseHubRequireSpokeKey() below; read by services/public-site-resolver.ts.
124
+ AILK_HUB_REQUIRE_SPOKE_KEY: HubSpokeKeyMode;
122
125
  }
123
126
 
127
+ /** `AILK_HUB_REQUIRE_SPOKE_KEY`'s three states (#7633 D2). */
128
+ export type HubSpokeKeyMode = 'auto' | 'on' | 'off';
129
+
124
130
  /** Why an AILK_TRUSTED_ORIGINS entry was refused (#7395 D2). */
125
131
  export type TrustedOriginRefusal =
126
132
  | 'wildcard'
@@ -248,14 +254,16 @@ export function resolveSchedulingCredentialKey(
248
254
  }
249
255
 
250
256
  /**
251
- * `AILK_HUB_REQUIRE_SPOKE_KEY` (#7532 D7): `true`/`1` → on; unset, empty,
252
- * `false`, `0` → off — case-insensitive, after trimming. `null` for any other
253
- * value, which `validateConfig()` refuses at boot.
257
+ * `AILK_HUB_REQUIRE_SPOKE_KEY` (#7532 D7, #7633 D2): unset, empty or blank →
258
+ * `"auto"`; `true`/`1` → `"on"`; `false`/`0` → `"off"` — case-insensitive,
259
+ * after trimming. `null` for any other value, which `validateConfig()`
260
+ * refuses at boot.
254
261
  */
255
- export function parseHubRequireSpokeKey(raw: string | undefined): boolean | null {
262
+ export function parseHubRequireSpokeKey(raw: string | undefined): HubSpokeKeyMode | null {
256
263
  const value = (raw ?? '').trim().toLowerCase();
257
- if (value === 'true' || value === '1') return true;
258
- if (value === '' || value === 'false' || value === '0') return false;
264
+ if (value === '') return 'auto';
265
+ if (value === 'true' || value === '1') return 'on';
266
+ if (value === 'false' || value === '0') return 'off';
259
267
  return null;
260
268
  }
261
269
 
@@ -338,11 +346,11 @@ function validateConfig(): Config {
338
346
  process.exit(1);
339
347
  }
340
348
 
341
- // #7532 D7 — the hub strict-mode flag. An unparseable value stops the API:
342
- // the variable is opt-in, and a typo read as "off" would silently leave the
343
- // hub open to header-less callers. The message names the variable, never
344
- // its value. On with no DATABASE_URL stops it too: strict mode with no
345
- // registry to look a key up in would refuse every call.
349
+ // #7532 D7, #7633 D2 — the hub's spoke-key mode. An unparseable value stops
350
+ // the API: a typo read as "off" would silently leave the hub open to
351
+ // header-less callers. The message names the variable, never its value.
352
+ // "on" with no DATABASE_URL stops it too: with no registry to look a key up
353
+ // in it would refuse every call. "auto" and "off" boot without a database.
346
354
  const hubRequireSpokeKey = parseHubRequireSpokeKey(process.env.AILK_HUB_REQUIRE_SPOKE_KEY);
347
355
  if (hubRequireSpokeKey === null) {
348
356
  const message =
@@ -351,13 +359,20 @@ function validateConfig(): Config {
351
359
  console.error(message);
352
360
  process.exit(1);
353
361
  }
354
- if (hubRequireSpokeKey && !process.env.DATABASE_URL) {
362
+ if (hubRequireSpokeKey === 'on' && !process.env.DATABASE_URL) {
355
363
  const message =
356
364
  '❌ AILK_HUB_REQUIRE_SPOKE_KEY is on but DATABASE_URL is not set — the spoke registry lives in the database';
357
365
  logger.error(message);
358
366
  console.error(message);
359
367
  process.exit(1);
360
368
  }
369
+ // The emergency opt-out is never silent: it reopens keyless access on every
370
+ // spoke-aware surface of a hub (#7633 D2, D6).
371
+ if (hubRequireSpokeKey === 'off') {
372
+ logger.warn(
373
+ 'AILK_HUB_REQUIRE_SPOKE_KEY=false — a public request with no spoke key is accepted even when spokes are registered',
374
+ );
375
+ }
361
376
 
362
377
  return {
363
378
  NODE_ENV: nodeEnv,
@@ -430,7 +445,7 @@ function validateConfig(): Config {
430
445
  ...(schedulingKey.kind === 'ok' && {
431
446
  SCHEDULING_CREDENTIAL_KEY: schedulingKey.key,
432
447
  }),
433
- AILK_HUB_REQUIRE_SPOKE_KEY: hubRequireSpokeKey === true,
448
+ AILK_HUB_REQUIRE_SPOKE_KEY: hubRequireSpokeKey,
434
449
  };
435
450
  }
436
451
 
@@ -8,6 +8,9 @@ const MockPrismaClient = jest.fn().mockImplementation((options) => ({
8
8
  user: {},
9
9
  apiToken: {},
10
10
  userData: {},
11
+ // #7633 D8 — the public resolver's "is this a hub" read. An empty registry,
12
+ // so a suite that reaches this stub keeps today's keyless answer.
13
+ spoke: { findFirst: jest.fn(async () => null) },
11
14
  }));
12
15
 
13
16
  // #4663 D6 — flow-engine.ts imports the real `Prisma` runtime namespace (a
@@ -67,6 +67,8 @@ export const prisma = {
67
67
  user: {
68
68
  findUnique: jest.fn(),
69
69
  findFirst: jest.fn(),
70
+ // #3591 — `lib/agent-store.ts`'s list names each agent's creator by id.
71
+ findMany: jest.fn(),
70
72
  update: jest.fn(),
71
73
  create: jest.fn(),
72
74
  },
@@ -387,6 +389,18 @@ export const prisma = {
387
389
  findUnique: jest.fn(),
388
390
  createMany: jest.fn(),
389
391
  },
392
+ // ── #3591 — agents (auth) ────────────────────────────────────────────────
393
+ // Every call lives in `lib/agent-store.ts`: `findUnique` by credential
394
+ // digest (the auth preHandler's one lookup) or by id; `update` — the
395
+ // best-effort `lastActiveAt` bump; `create`/`updateMany`/`findMany` — the
396
+ // management functions, each scoped by the caller's organization id.
397
+ agent: {
398
+ findUnique: jest.fn(),
399
+ findMany: jest.fn(),
400
+ create: jest.fn(),
401
+ update: jest.fn(),
402
+ updateMany: jest.fn(),
403
+ },
390
404
  // ── Content namespace (UsageEvent, UsagePeriod — FEAT-047 P1.S1/#3709) ──
391
405
  usageEvent: {
392
406
  create: jest.fn(),