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.
Files changed (55) 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 +15 -0
  51. package/templates/database/__tests__/agents.db.test.ts +235 -0
  52. package/templates/database/auth/schema.prisma +61 -0
  53. package/templates/database/migrations/20261005120000_agents/migration.sql +84 -0
  54. package/templates/database/package.json +1 -1
  55. package/versions.json +34 -34
@@ -0,0 +1,468 @@
1
+ /**
2
+ * @file agent-store.test.ts
3
+ *
4
+ * `lib/agent-store.ts` — the agents table's ONE data-access boundary (#3591,
5
+ * spec docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md D1, D2, D12).
6
+ *
7
+ * Each function against the Prisma mock. What it pins:
8
+ * - the key: `ailk_agt_` + 43 base64url characters, a fresh one per call,
9
+ * stored only as its SHA-256 digest plus a 17-character prefix;
10
+ * - every read is keyed by a key's digest, an agent id, or an organization
11
+ * id the CALLER passes — never a scope of its own choosing;
12
+ * - `createAgent` derives all three references from its own reads, in one
13
+ * transaction, and refuses (not found) a workspace of another
14
+ * organization or a person with no membership there (SEC-3591-15);
15
+ * - re-keying and switching are scoped by organization: another
16
+ * organization's agent is "not found", and nothing is written.
17
+ *
18
+ * Test fixtures never hold a key-shaped literal: every `ailk_agt_` value is
19
+ * built at run time by `mintAgentKey()`, or the repo-wide secret scan flags it.
20
+ */
21
+ import { createHash } from "node:crypto";
22
+
23
+ // eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
24
+ jest.mock("../prisma.js");
25
+
26
+ import {
27
+ AGENT_KEY_PREFIX,
28
+ createAgent,
29
+ createServedAgent,
30
+ findAgentByKeyHash,
31
+ findAgentContext,
32
+ hashAgentKey,
33
+ listAgentsForOrganization,
34
+ mintAgentKey,
35
+ replaceAgentKey,
36
+ replaceServedAgentKey,
37
+ setAgentEnabled,
38
+ setServedAgentEnabled,
39
+ touchAgentLastActive,
40
+ } from "../agent-store.js";
41
+ import { prisma } from "../prisma.js";
42
+
43
+ const ORG_A = "org_00000000-0000-7000-8000-000000000aaa";
44
+ const ORG_B = "org_00000000-0000-7000-8000-000000000bbb";
45
+ const WS_A1 = "wksp_00000000-0000-7000-8000-0000000000a1";
46
+ const WS_A2 = "wksp_00000000-0000-7000-8000-0000000000a2";
47
+ const WS_B1 = "wksp_00000000-0000-7000-8000-0000000000b1";
48
+ const OWNER = "user_00000000-0000-7000-8000-0000000000f1";
49
+ const PERSON = "user_00000000-0000-7000-8000-0000000000f2";
50
+ const OWNER_MEMBERSHIP = "member_00000000-0000-7000-8000-0000000000f1";
51
+ const PERSON_MEMBERSHIP = "member_00000000-0000-7000-8000-0000000000f2";
52
+ const AGENT = "agent_00000000-0000-7000-8000-0000000000c1";
53
+
54
+ const KEY_SHAPE = /^ailk_agt_[A-Za-z0-9_-]{43}$/;
55
+ const sha256 = (value: string) => createHash("sha256").update(value).digest("hex");
56
+
57
+ const WORKSPACES: Record<string, { id: string; organizationId: string }> = {
58
+ [WS_A1]: { id: WS_A1, organizationId: ORG_A },
59
+ [WS_A2]: { id: WS_A2, organizationId: ORG_A },
60
+ [WS_B1]: { id: WS_B1, organizationId: ORG_B },
61
+ };
62
+ const MEMBERSHIPS = [
63
+ { id: OWNER_MEMBERSHIP, organizationId: ORG_A, userId: OWNER },
64
+ { id: PERSON_MEMBERSHIP, organizationId: ORG_A, userId: PERSON },
65
+ ];
66
+
67
+ const AGENT_ROW = {
68
+ id: AGENT,
69
+ name: "Inbox helper",
70
+ organizationId: ORG_A,
71
+ workspaceId: WS_A1,
72
+ responsibleMembershipId: PERSON_MEMBERSHIP,
73
+ permissionLevel: "member",
74
+ enabled: true,
75
+ lastActiveAt: null,
76
+ createdAt: new Date("2026-10-04T00:00:00Z"),
77
+ credentialPrefix: "ailk_agt_abcdefgh",
78
+ createdByUserId: OWNER,
79
+ keyIssuedToMembershipId: OWNER_MEMBERSHIP,
80
+ };
81
+
82
+ function installTenancy(): void {
83
+ (prisma.workspace.findUnique as jest.Mock).mockImplementation(
84
+ ({ where }: { where: { id: string } }) => Promise.resolve(WORKSPACES[where.id] ?? null),
85
+ );
86
+ (prisma.membership.findUnique as jest.Mock).mockImplementation(
87
+ ({ where }: { where: { organizationId_userId?: { organizationId: string; userId: string } } }) => {
88
+ const key = where.organizationId_userId;
89
+ return Promise.resolve(
90
+ MEMBERSHIPS.find(
91
+ (m) => key && m.organizationId === key.organizationId && m.userId === key.userId,
92
+ ) ?? null,
93
+ );
94
+ },
95
+ );
96
+ // The interactive transaction runs its callback against the same mock.
97
+ (prisma.$transaction as jest.Mock).mockImplementation((fn: (tx: typeof prisma) => unknown) =>
98
+ fn(prisma),
99
+ );
100
+ }
101
+
102
+ beforeEach(() => {
103
+ jest.clearAllMocks();
104
+ installTenancy();
105
+ });
106
+
107
+ describe("the key (D2)", () => {
108
+ it("mints ailk_agt_ plus 32 random bytes in base64url, fresh every call", () => {
109
+ const a = mintAgentKey();
110
+ const b = mintAgentKey();
111
+ expect(a).toMatch(KEY_SHAPE);
112
+ expect(b).toMatch(KEY_SHAPE);
113
+ expect(a).not.toBe(b);
114
+ expect(AGENT_KEY_PREFIX).toBe("ailk_agt_");
115
+ });
116
+
117
+ it("hashes the TRIMMED key to lowercase hex SHA-256", () => {
118
+ const key = mintAgentKey();
119
+ expect(hashAgentKey(key)).toBe(sha256(key));
120
+ expect(hashAgentKey(` ${key}\n`)).toBe(sha256(key));
121
+ expect(hashAgentKey(key)).toMatch(/^[0-9a-f]{64}$/);
122
+ });
123
+ });
124
+
125
+ describe("findAgentByKeyHash", () => {
126
+ it("is one findUnique by digest, selecting what the guard needs and never the digest", async () => {
127
+ (prisma.agent.findUnique as jest.Mock).mockResolvedValue({
128
+ id: AGENT,
129
+ workspaceId: WS_A1,
130
+ organizationId: ORG_A,
131
+ permissionLevel: "member",
132
+ enabled: true,
133
+ keyIssuedToMembershipId: OWNER_MEMBERSHIP,
134
+ lastActiveAt: null,
135
+ responsible: { id: PERSON_MEMBERSHIP, userId: PERSON },
136
+ });
137
+ const hash = sha256("x");
138
+
139
+ const row = await findAgentByKeyHash(hash);
140
+
141
+ expect(prisma.agent.findUnique).toHaveBeenCalledTimes(1);
142
+ expect(prisma.agent.findUnique).toHaveBeenCalledWith({
143
+ where: { credentialHash: hash },
144
+ select: {
145
+ id: true,
146
+ workspaceId: true,
147
+ organizationId: true,
148
+ permissionLevel: true,
149
+ enabled: true,
150
+ keyIssuedToMembershipId: true,
151
+ lastActiveAt: true,
152
+ responsible: { select: { id: true, userId: true } },
153
+ },
154
+ });
155
+ expect(row).toEqual({
156
+ id: AGENT,
157
+ workspaceId: WS_A1,
158
+ organizationId: ORG_A,
159
+ permissionLevel: "member",
160
+ enabled: true,
161
+ keyIssuedToMembershipId: OWNER_MEMBERSHIP,
162
+ lastActiveAt: null,
163
+ responsible: { id: PERSON_MEMBERSHIP, userId: PERSON },
164
+ });
165
+ });
166
+
167
+ it("is null for an unknown digest", async () => {
168
+ (prisma.agent.findUnique as jest.Mock).mockResolvedValue(null);
169
+ await expect(findAgentByKeyHash(sha256("nope"))).resolves.toBeNull();
170
+ });
171
+ });
172
+
173
+ describe("touchAgentLastActive", () => {
174
+ it("stamps lastActiveAt on the one agent, by id", async () => {
175
+ (prisma.agent.update as jest.Mock).mockResolvedValue({});
176
+ const at = new Date("2026-10-05T12:00:00Z");
177
+ await touchAgentLastActive(AGENT, at);
178
+ expect(prisma.agent.update).toHaveBeenCalledWith({
179
+ where: { id: AGENT },
180
+ data: { lastActiveAt: at },
181
+ select: { id: true },
182
+ });
183
+ });
184
+ });
185
+
186
+ describe("createAgent (D12 — the references always agree)", () => {
187
+ beforeEach(() => {
188
+ (prisma.agent.create as jest.Mock).mockImplementation(({ data }: { data: Record<string, unknown> }) =>
189
+ Promise.resolve({ ...AGENT_ROW, ...data, id: AGENT }),
190
+ );
191
+ });
192
+
193
+ it("binds to the target workspace, the person's membership and the creator's, and returns the key once", async () => {
194
+ const result = await createAgent({
195
+ organizationId: ORG_A,
196
+ workspaceId: WS_A2,
197
+ responsibleUserId: PERSON,
198
+ createdByUserId: OWNER,
199
+ name: "Inbox helper",
200
+ });
201
+
202
+ expect(result.ok).toBe(true);
203
+ if (!result.ok) return;
204
+ expect(result.key).toMatch(KEY_SHAPE);
205
+ expect(prisma.$transaction).toHaveBeenCalledTimes(1);
206
+ const { data, select } = (prisma.agent.create as jest.Mock).mock.calls[0][0];
207
+ expect(data).toEqual({
208
+ organizationId: ORG_A,
209
+ workspaceId: WS_A2,
210
+ responsibleMembershipId: PERSON_MEMBERSHIP,
211
+ keyIssuedToMembershipId: OWNER_MEMBERSHIP,
212
+ createdByUserId: OWNER,
213
+ name: "Inbox helper",
214
+ credentialHash: sha256(result.key),
215
+ credentialPrefix: result.key.slice(0, 17),
216
+ });
217
+ // The digest is written, never selected back; the key is never stored.
218
+ expect(select).not.toHaveProperty("credentialHash");
219
+ expect(JSON.stringify(data)).not.toContain(result.key);
220
+ expect(result.agent.workspaceId).toBe(WS_A2);
221
+ });
222
+
223
+ it("reads the organization from the workspace row, never from the caller alone", async () => {
224
+ const result = await createAgent({
225
+ organizationId: ORG_A,
226
+ workspaceId: WS_B1,
227
+ responsibleUserId: PERSON,
228
+ createdByUserId: OWNER,
229
+ name: "Intruder",
230
+ });
231
+ expect(result).toEqual({ ok: false, reason: "not-found" });
232
+ expect(prisma.agent.create).not.toHaveBeenCalled();
233
+ });
234
+
235
+ it.each([
236
+ ["an unknown workspace", { workspaceId: "wksp_00000000-0000-7000-8000-00000000dead" }],
237
+ ["a person with no membership in the organization", { responsibleUserId: "user_00000000-0000-7000-8000-00000000dead" }],
238
+ ["a creator with no membership in the organization", { createdByUserId: "user_00000000-0000-7000-8000-00000000dead" }],
239
+ ])("is not-found for %s, and writes nothing", async (_label, override) => {
240
+ const result = await createAgent({
241
+ organizationId: ORG_A,
242
+ workspaceId: WS_A1,
243
+ responsibleUserId: PERSON,
244
+ createdByUserId: OWNER,
245
+ name: "Helper",
246
+ ...override,
247
+ });
248
+ expect(result).toEqual({ ok: false, reason: "not-found" });
249
+ expect(prisma.agent.create).not.toHaveBeenCalled();
250
+ });
251
+
252
+ it("looks the person up by the organization's compound key, not by any id the caller names", async () => {
253
+ await createAgent({
254
+ organizationId: ORG_A,
255
+ workspaceId: WS_A1,
256
+ responsibleUserId: PERSON,
257
+ createdByUserId: OWNER,
258
+ name: "Helper",
259
+ });
260
+ expect(prisma.membership.findUnique).toHaveBeenCalledWith(
261
+ expect.objectContaining({
262
+ where: { organizationId_userId: { organizationId: ORG_A, userId: PERSON } },
263
+ }),
264
+ );
265
+ });
266
+ });
267
+
268
+ describe("replaceAgentKey (D2)", () => {
269
+ it("writes a new digest and prefix and records who was shown the key, scoped by organization", async () => {
270
+ (prisma.agent.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
271
+ (prisma.agent.findUnique as jest.Mock).mockResolvedValue(AGENT_ROW);
272
+
273
+ const result = await replaceAgentKey({ organizationId: ORG_A, agentId: AGENT, issuedByUserId: OWNER });
274
+
275
+ expect(result.ok).toBe(true);
276
+ if (!result.ok) return;
277
+ expect(result.key).toMatch(KEY_SHAPE);
278
+ expect(prisma.agent.updateMany).toHaveBeenCalledWith({
279
+ where: { id: AGENT, organizationId: ORG_A },
280
+ data: {
281
+ credentialHash: sha256(result.key),
282
+ credentialPrefix: result.key.slice(0, 17),
283
+ keyIssuedToMembershipId: OWNER_MEMBERSHIP,
284
+ },
285
+ });
286
+ expect(result.agent.id).toBe(AGENT);
287
+ });
288
+
289
+ it("is not-found for another organization's agent", async () => {
290
+ (prisma.agent.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
291
+ const result = await replaceAgentKey({ organizationId: ORG_B, agentId: AGENT, issuedByUserId: OWNER });
292
+ expect(result).toEqual({ ok: false, reason: "not-found" });
293
+ });
294
+
295
+ it("is not-found when the re-keying person holds no membership in the organization", async () => {
296
+ const result = await replaceAgentKey({
297
+ organizationId: ORG_A,
298
+ agentId: AGENT,
299
+ issuedByUserId: "user_00000000-0000-7000-8000-00000000dead",
300
+ });
301
+ expect(result).toEqual({ ok: false, reason: "not-found" });
302
+ expect(prisma.agent.updateMany).not.toHaveBeenCalled();
303
+ });
304
+ });
305
+
306
+ describe("setAgentEnabled", () => {
307
+ it("switches the agent, scoped by organization", async () => {
308
+ (prisma.agent.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
309
+ (prisma.agent.findUnique as jest.Mock).mockResolvedValue({ ...AGENT_ROW, enabled: false });
310
+
311
+ const agent = await setAgentEnabled({ organizationId: ORG_A, agentId: AGENT, enabled: false });
312
+
313
+ expect(prisma.agent.updateMany).toHaveBeenCalledWith({
314
+ where: { id: AGENT, organizationId: ORG_A },
315
+ data: { enabled: false },
316
+ });
317
+ expect(agent?.enabled).toBe(false);
318
+ });
319
+
320
+ it("is null for another organization's agent", async () => {
321
+ (prisma.agent.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
322
+ await expect(
323
+ setAgentEnabled({ organizationId: ORG_B, agentId: AGENT, enabled: false }),
324
+ ).resolves.toBeNull();
325
+ expect(prisma.agent.findUnique).not.toHaveBeenCalled();
326
+ });
327
+ });
328
+
329
+ // ── The management routes' writes — the row they serve, from one transaction ──
330
+
331
+ describe("the served writes (route-facing)", () => {
332
+ const LIST_ROW = {
333
+ ...AGENT_ROW,
334
+ responsible: { userId: PERSON, user: { name: "Pat", email: "pat@example.test" } },
335
+ keyIssuedTo: { userId: OWNER, user: { name: "Olive" } },
336
+ };
337
+
338
+ beforeEach(() => {
339
+ (prisma.agent.findMany as jest.Mock).mockResolvedValue([LIST_ROW]);
340
+ (prisma.user.findMany as jest.Mock).mockResolvedValue([{ id: OWNER, name: "Olive" }]);
341
+ (prisma.agent.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
342
+ (prisma.agent.create as jest.Mock).mockImplementation(({ data }: { data: Record<string, unknown> }) =>
343
+ Promise.resolve({ ...AGENT_ROW, ...data, id: AGENT }),
344
+ );
345
+ });
346
+
347
+ it("each runs its write and its list-row read in one transaction, and serves the list row", async () => {
348
+ const created = await createServedAgent({
349
+ organizationId: ORG_A,
350
+ workspaceId: WS_A1,
351
+ responsibleUserId: PERSON,
352
+ createdByUserId: OWNER,
353
+ name: "Inbox helper",
354
+ });
355
+ const rekeyed = await replaceServedAgentKey({
356
+ organizationId: ORG_A,
357
+ agentId: AGENT,
358
+ issuedByUserId: OWNER,
359
+ });
360
+ const switched = await setServedAgentEnabled({
361
+ organizationId: ORG_A,
362
+ agentId: AGENT,
363
+ enabled: false,
364
+ });
365
+
366
+ expect(prisma.$transaction).toHaveBeenCalledTimes(3);
367
+ for (const result of [created, rekeyed, switched]) {
368
+ expect(result).toMatchObject({
369
+ ok: true,
370
+ agent: { id: AGENT, responsible: { userId: PERSON, email: "pat@example.test" } },
371
+ });
372
+ }
373
+ expect(created.ok && created.key).toMatch(KEY_SHAPE);
374
+ expect(rekeyed.ok && rekeyed.key).toMatch(KEY_SHAPE);
375
+ for (const call of (prisma.agent.findMany as jest.Mock).mock.calls) {
376
+ expect(call[0].where).toEqual({ id: AGENT, organizationId: ORG_A });
377
+ }
378
+ });
379
+
380
+ it("re-key and switch match the agent by organization and id only, whichever workspace it is bound to", async () => {
381
+ await replaceServedAgentKey({ organizationId: ORG_A, agentId: AGENT, issuedByUserId: OWNER });
382
+ await setServedAgentEnabled({ organizationId: ORG_A, agentId: AGENT, enabled: true });
383
+
384
+ for (const call of (prisma.agent.updateMany as jest.Mock).mock.calls) {
385
+ expect(call[0].where).toEqual({ id: AGENT, organizationId: ORG_A });
386
+ }
387
+ });
388
+
389
+ it("another organization's agent is not-found and no row is read", async () => {
390
+ (prisma.agent.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
391
+
392
+ await expect(
393
+ replaceServedAgentKey({ organizationId: ORG_B, agentId: AGENT, issuedByUserId: OWNER }),
394
+ ).resolves.toEqual({ ok: false, reason: "not-found" });
395
+ await expect(
396
+ setServedAgentEnabled({ organizationId: ORG_B, agentId: AGENT, enabled: false }),
397
+ ).resolves.toEqual({ ok: false, reason: "not-found" });
398
+ expect(prisma.agent.findMany).not.toHaveBeenCalled();
399
+ });
400
+ });
401
+
402
+ describe("listAgentsForOrganization", () => {
403
+ beforeEach(() => {
404
+ (prisma.agent.findMany as jest.Mock).mockResolvedValue([
405
+ {
406
+ ...AGENT_ROW,
407
+ responsible: { userId: PERSON, user: { name: "Pat", email: "pat@example.test" } },
408
+ keyIssuedTo: { userId: OWNER, user: { name: "Olive" } },
409
+ },
410
+ ]);
411
+ (prisma.user.findMany as jest.Mock).mockResolvedValue([{ id: OWNER, name: "Olive" }]);
412
+ });
413
+
414
+ it("lists the organization's agents, never selecting the digest", async () => {
415
+ const rows = await listAgentsForOrganization(ORG_A);
416
+ const args = (prisma.agent.findMany as jest.Mock).mock.calls[0][0];
417
+ expect(args.where).toEqual({ organizationId: ORG_A });
418
+ expect(args.select).not.toHaveProperty("credentialHash");
419
+ expect(rows).toEqual([
420
+ {
421
+ id: AGENT,
422
+ name: "Inbox helper",
423
+ workspaceId: WS_A1,
424
+ responsible: { userId: PERSON, name: "Pat", email: "pat@example.test" },
425
+ permissionLevel: "member",
426
+ enabled: true,
427
+ lastActiveAt: null,
428
+ createdAt: AGENT_ROW.createdAt,
429
+ keyPrefix: "ailk_agt_abcdefgh",
430
+ createdBy: { userId: OWNER, name: "Olive" },
431
+ keyIssuedTo: { userId: OWNER, name: "Olive" },
432
+ },
433
+ ]);
434
+ });
435
+
436
+ it("narrows to one person's agents inside the same organization", async () => {
437
+ await listAgentsForOrganization(ORG_A, PERSON);
438
+ expect((prisma.agent.findMany as jest.Mock).mock.calls[0][0].where).toEqual({
439
+ organizationId: ORG_A,
440
+ responsible: { userId: PERSON },
441
+ });
442
+ });
443
+ });
444
+
445
+ describe("findAgentContext", () => {
446
+ it("reads one agent by id with its person's name and address", async () => {
447
+ (prisma.agent.findUnique as jest.Mock).mockResolvedValue({
448
+ id: AGENT,
449
+ name: "Inbox helper",
450
+ workspaceId: WS_A1,
451
+ permissionLevel: "member",
452
+ responsible: { user: { name: "Pat", email: "pat@example.test" } },
453
+ });
454
+
455
+ const context = await findAgentContext(AGENT);
456
+
457
+ expect((prisma.agent.findUnique as jest.Mock).mock.calls[0][0].where).toEqual({ id: AGENT });
458
+ expect(context).toEqual({
459
+ agent: { id: AGENT, name: "Inbox helper", workspaceId: WS_A1, permissionLevel: "member" },
460
+ responsible: { name: "Pat", email: "pat@example.test" },
461
+ });
462
+ });
463
+
464
+ it("is null for an unknown agent", async () => {
465
+ (prisma.agent.findUnique as jest.Mock).mockResolvedValue(null);
466
+ await expect(findAgentContext(AGENT)).resolves.toBeNull();
467
+ });
468
+ });
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * The hub's spoke registry boundary (#7532, spec
5
5
  * docs/specs/7532-a-spoke-s-public-reads-spec.md D2, D7, AC-5): minting a key,
6
- * its digest, the one lookup the resolver makes, and the three operator
7
- * functions the `spokes` CLI calls.
6
+ * its digest, the two reads the resolver makes (the key lookup, and whether
7
+ * the deployment is a hub — #7633 D1), and the three operator functions the
8
+ * `spokes` CLI calls.
8
9
  *
9
10
  * External dependency boundary mocked:
10
11
  * - @working-theory/database (via the prisma singleton; __mocks__/prisma.ts)
@@ -19,6 +20,7 @@ import { prisma } from "../prisma.js";
19
20
  import {
20
21
  SPOKE_KEY_PREFIX,
21
22
  findSpokeByKeyHash,
23
+ hasRegisteredSpoke,
22
24
  hashSpokeKey,
23
25
  listSpokes,
24
26
  mintSpokeKey,
@@ -77,6 +79,34 @@ describe("findSpokeByKeyHash (D3 step 5)", () => {
77
79
  });
78
80
  });
79
81
 
82
+ describe("hasRegisteredSpoke (#7633 D1, AC-5)", () => {
83
+ it("is true when the registry holds a row, revoked or not — one findFirst with no where", async () => {
84
+ db.spoke.findFirst.mockResolvedValue({ id: S1 } as never);
85
+
86
+ await expect(hasRegisteredSpoke()).resolves.toBe(true);
87
+ expect(db.spoke.findFirst).toHaveBeenCalledTimes(1);
88
+ expect(db.spoke.findFirst).toHaveBeenCalledWith({ select: { id: true } });
89
+ });
90
+
91
+ it("is false when the registry is empty", async () => {
92
+ db.spoke.findFirst.mockResolvedValue(null);
93
+ await expect(hasRegisteredSpoke()).resolves.toBe(false);
94
+ expect(db.spoke.findFirst).toHaveBeenCalledWith({ select: { id: true } });
95
+ });
96
+
97
+ it("caches nothing: two calls are two reads", async () => {
98
+ db.spoke.findFirst.mockResolvedValue(null);
99
+ await hasRegisteredSpoke();
100
+ await hasRegisteredSpoke();
101
+ expect(db.spoke.findFirst).toHaveBeenCalledTimes(2);
102
+ });
103
+
104
+ it("propagates a failed read — never answers \"not a hub\"", async () => {
105
+ db.spoke.findFirst.mockRejectedValue(new Error("connection reset"));
106
+ await expect(hasRegisteredSpoke()).rejects.toThrow("connection reset");
107
+ });
108
+ });
109
+
80
110
  describe("registerSpoke (D7, AC-5)", () => {
81
111
  it("stores the key's digest and a 17-character prefix, never the key", async () => {
82
112
  db.workspace.findUnique.mockResolvedValue({ id: W1 } as never);