create-ailk 0.14.0 → 0.15.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 (95) hide show
  1. package/component-catalog.json +3 -3
  2. package/dist/add-module.d.ts +7 -0
  3. package/dist/add-module.js +34 -2
  4. package/dist/add-route.js +18 -2
  5. package/dist/apply-design.d.ts +12 -1
  6. package/dist/apply-design.js +36 -4
  7. package/dist/cli.js +23 -3
  8. package/dist/derive-architecture.d.ts +15 -2
  9. package/dist/derive-architecture.js +34 -6
  10. package/dist/design-package.js +3 -2
  11. package/dist/index.d.ts +1 -1
  12. package/dist/index.js +1 -1
  13. package/dist/lib/chrome-readers.d.ts +21 -0
  14. package/dist/lib/chrome-readers.js +100 -0
  15. package/dist/lib/extract-module-bundle.d.ts +11 -0
  16. package/dist/lib/extract-module-bundle.js +36 -6
  17. package/dist/lib/license-key-files.d.ts +6 -1
  18. package/dist/lib/license-key-files.js +9 -4
  19. package/dist/module-architecture.d.ts +9 -0
  20. package/dist/module-architecture.js +6 -0
  21. package/dist/parse-args.d.ts +22 -0
  22. package/dist/parse-args.js +73 -0
  23. package/dist/programmatic.d.ts +20 -0
  24. package/dist/programmatic.js +110 -9
  25. package/dist/site-chrome.d.ts +4 -0
  26. package/dist/site-chrome.js +34 -3
  27. package/dist/site-manifest.d.ts +12 -5
  28. package/dist/site-manifest.js +25 -11
  29. package/dist/sync-routes.js +11 -2
  30. package/dist/topology.d.ts +131 -0
  31. package/dist/topology.js +533 -0
  32. package/package.json +3 -3
  33. package/site-templates/designs/minimalism/DESIGN.md +3 -3
  34. package/templates/apps/api/CLAUDE.md +4 -2
  35. package/templates/apps/api/package.json +1 -0
  36. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
  37. package/templates/apps/api/src/lib/__mocks__/prisma.ts +10 -0
  38. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
  39. package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
  40. package/templates/apps/api/src/lib/agent-store.ts +95 -3
  41. package/templates/apps/api/src/lib/site.ts +37 -0
  42. package/templates/apps/api/src/lib/spoke-store.ts +18 -2
  43. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
  44. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
  45. package/templates/apps/api/src/middleware/auth.ts +3 -0
  46. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
  47. package/templates/apps/api/src/openapi/spec.ts +38 -2
  48. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
  49. package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
  50. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +275 -0
  51. package/templates/apps/api/src/routes/leads/index.ts +69 -6
  52. package/templates/apps/api/src/routes/webhooks/README.md +39 -0
  53. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
  54. package/templates/apps/api/src/routes/webhooks/stripe.ts +61 -1
  55. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +199 -0
  56. package/templates/apps/api/src/services/default-tenant.ts +41 -7
  57. package/templates/apps/api/src/services/lead-routing.ts +148 -29
  58. package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
  59. package/templates/apps/mcp/__tests__/index.test.ts +5 -5
  60. package/templates/apps/mcp/src/index.ts +6 -4
  61. package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
  62. package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
  63. package/templates/apps/web/CLAUDE.md +1 -0
  64. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  65. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  66. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
  67. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
  68. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
  69. package/templates/apps/web/app/[locale]/layout.tsx +71 -19
  70. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
  71. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
  72. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
  73. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +89 -1
  74. package/templates/apps/web/app/openapi.json/route.ts +69 -1
  75. package/templates/apps/web/instrumentation.ts +7 -4
  76. package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
  77. package/templates/apps/web/lib/__tests__/relay.test.ts +290 -0
  78. package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
  79. package/templates/apps/web/lib/closing-footer-path.ts +50 -0
  80. package/templates/apps/web/lib/relay.ts +206 -2
  81. package/templates/apps/web/lib/site-brand.tsx +22 -1
  82. package/templates/apps/web/lib/site-chrome.ts +3 -0
  83. package/templates/apps/web/lib/topology.generated.ts +23 -0
  84. package/templates/apps/web/package.json +1 -1
  85. package/templates/database/CHANGELOG.md +25 -0
  86. package/templates/database/__tests__/agents.db.test.ts +65 -0
  87. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
  88. package/templates/database/auth/schema.prisma +23 -0
  89. package/templates/database/inbox/schema.prisma +13 -0
  90. package/templates/database/jest.config.cjs +7 -11
  91. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
  92. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
  93. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
  94. package/templates/database/package.json +1 -1
  95. package/versions.json +34 -33
@@ -27,13 +27,15 @@ export interface AgentCaller {
27
27
  workspaceId: string;
28
28
  /** The person's own role; the guard clamps the agent to `member`. */
29
29
  role: "owner" | "admin" | "member";
30
+ /** The agent's stored grants (#7864); the guard keeps those `role` holds. */
31
+ grants: string[];
30
32
  headers: { authorization: string };
31
33
  }
32
34
 
33
35
  export function buildAgentCaller(
34
36
  scope: { organizationId: string; workspaceId: string },
35
37
  overrides: Partial<
36
- Pick<AgentCaller, "agentId" | "userId" | "membershipId" | "role">
38
+ Pick<AgentCaller, "agentId" | "userId" | "membershipId" | "role" | "grants">
37
39
  > = {},
38
40
  ): AgentCaller & { key: string } {
39
41
  const key = mintAgentKey();
@@ -42,6 +44,7 @@ export function buildAgentCaller(
42
44
  userId: "user_00000000-0000-7000-8000-00000000a9e2",
43
45
  membershipId: "member_00000000-0000-7000-8000-00000000a9e3",
44
46
  role: "member",
47
+ grants: [],
45
48
  ...scope,
46
49
  ...overrides,
47
50
  key,
@@ -64,6 +67,7 @@ export function agentKeyLookup(
64
67
  workspaceId: caller.workspaceId,
65
68
  organizationId: caller.organizationId,
66
69
  permissionLevel: "member",
70
+ grants: caller.grants,
67
71
  enabled: true,
68
72
  keyIssuedToMembershipId: caller.membershipId,
69
73
  lastActiveAt: null,
@@ -70,8 +70,16 @@ export const prisma = {
70
70
  // #3591 — `lib/agent-store.ts`'s list names each agent's creator by id.
71
71
  findMany: jest.fn(),
72
72
  update: jest.fn(),
73
+ // #7803 — `activateWorkspace` records the remembered workspace on the
74
+ // caller's own row (tenant-context.ts, spec D3).
75
+ updateMany: jest.fn(),
73
76
  create: jest.fn(),
74
77
  },
78
+ // #7803 — `activateWorkspace` writes the session's active-workspace pointer
79
+ // in one `$transaction` with the `user.updateMany` above.
80
+ session: {
81
+ update: jest.fn(),
82
+ },
75
83
  apiToken: {
76
84
  create: jest.fn(),
77
85
  findMany: jest.fn(),
@@ -210,6 +218,8 @@ export const prisma = {
210
218
  findFirst: jest.fn(),
211
219
  findMany: jest.fn(),
212
220
  upsert: jest.fn(),
221
+ // #7844 D7 — `copyFlowDefinition`'s one create-only write.
222
+ create: jest.fn(),
213
223
  },
214
224
  // `deleteMany` — used by the FlowSession retention/cleanup sweep
215
225
  // (flow-engine.ts's `cleanupAbandonedSessions`, #3974 AC3).
@@ -13,7 +13,10 @@
13
13
  * transaction, and refuses (not found) a workspace of another
14
14
  * organization or a person with no membership there (SEC-3591-15);
15
15
  * - re-keying and switching are scoped by organization: another
16
- * organization's agent is "not found", and nothing is written.
16
+ * organization's agent is "not found", and nothing is written;
17
+ * - setting grants (#7864 D4, AC-4) collapses duplicates into D1 order,
18
+ * refuses a grant its person's role does not hold, and is scoped by
19
+ * organization like the other writes.
17
20
  *
18
21
  * Test fixtures never hold a key-shaped literal: every `ailk_agt_` value is
19
22
  * built at run time by `mintAgentKey()`, or the repo-wide secret scan flags it.
@@ -36,6 +39,7 @@ import {
36
39
  replaceServedAgentKey,
37
40
  setAgentEnabled,
38
41
  setServedAgentEnabled,
42
+ setServedAgentGrants,
39
43
  touchAgentLastActive,
40
44
  } from "../agent-store.js";
41
45
  import { prisma } from "../prisma.js";
@@ -71,6 +75,7 @@ const AGENT_ROW = {
71
75
  workspaceId: WS_A1,
72
76
  responsibleMembershipId: PERSON_MEMBERSHIP,
73
77
  permissionLevel: "member",
78
+ grants: [] as string[],
74
79
  enabled: true,
75
80
  lastActiveAt: null,
76
81
  createdAt: new Date("2026-10-04T00:00:00Z"),
@@ -129,6 +134,7 @@ describe("findAgentByKeyHash", () => {
129
134
  workspaceId: WS_A1,
130
135
  organizationId: ORG_A,
131
136
  permissionLevel: "member",
137
+ grants: ["content-edit"],
132
138
  enabled: true,
133
139
  keyIssuedToMembershipId: OWNER_MEMBERSHIP,
134
140
  lastActiveAt: null,
@@ -146,6 +152,7 @@ describe("findAgentByKeyHash", () => {
146
152
  workspaceId: true,
147
153
  organizationId: true,
148
154
  permissionLevel: true,
155
+ grants: true,
149
156
  enabled: true,
150
157
  keyIssuedToMembershipId: true,
151
158
  lastActiveAt: true,
@@ -157,6 +164,7 @@ describe("findAgentByKeyHash", () => {
157
164
  workspaceId: WS_A1,
158
165
  organizationId: ORG_A,
159
166
  permissionLevel: "member",
167
+ grants: ["content-edit"],
160
168
  enabled: true,
161
169
  keyIssuedToMembershipId: OWNER_MEMBERSHIP,
162
170
  lastActiveAt: null,
@@ -399,6 +407,110 @@ describe("the served writes (route-facing)", () => {
399
407
  });
400
408
  });
401
409
 
410
+ describe("setServedAgentGrants (#7864 D4, AC-4)", () => {
411
+ /** The row the transaction reads: the agent with its person's role. */
412
+ function agentWithRole(role: string, grants: string[] = []) {
413
+ return { organizationId: ORG_A, grants, responsible: { role } };
414
+ }
415
+ let listGrants: string[];
416
+
417
+ beforeEach(() => {
418
+ listGrants = [];
419
+ (prisma.agent.updateMany as jest.Mock).mockImplementation(
420
+ ({ data }: { data: { grants: string[] } }) => {
421
+ listGrants = data.grants;
422
+ return Promise.resolve({ count: 1 });
423
+ },
424
+ );
425
+ (prisma.agent.findMany as jest.Mock).mockImplementation(() =>
426
+ Promise.resolve([
427
+ {
428
+ ...AGENT_ROW,
429
+ grants: listGrants,
430
+ responsible: { userId: PERSON, user: { name: "Pat", email: "pat@example.test" } },
431
+ keyIssuedTo: { userId: OWNER, user: { name: "Olive" } },
432
+ },
433
+ ]),
434
+ );
435
+ (prisma.user.findMany as jest.Mock).mockResolvedValue([{ id: OWNER, name: "Olive" }]);
436
+ });
437
+
438
+ it("writes the set in D1 order with duplicates collapsed, in one transaction, and serves the list row", async () => {
439
+ (prisma.agent.findUnique as jest.Mock).mockResolvedValue(agentWithRole("admin", ["assets-write"]));
440
+
441
+ const result = await setServedAgentGrants({
442
+ organizationId: ORG_A,
443
+ agentId: AGENT,
444
+ grants: ["magnets-manage", "content-edit", "magnets-manage"],
445
+ });
446
+
447
+ expect(prisma.$transaction).toHaveBeenCalledTimes(1);
448
+ expect(prisma.agent.findUnique).toHaveBeenCalledWith({
449
+ where: { id: AGENT, organizationId: ORG_A },
450
+ select: { organizationId: true, grants: true, responsible: { select: { role: true } } },
451
+ });
452
+ expect(prisma.agent.updateMany).toHaveBeenCalledWith({
453
+ where: { id: AGENT, organizationId: ORG_A },
454
+ data: { grants: ["content-edit", "magnets-manage"] },
455
+ });
456
+ expect(result).toMatchObject({
457
+ ok: true,
458
+ agent: { id: AGENT, grants: ["content-edit", "magnets-manage"] },
459
+ previous: ["assets-write"],
460
+ });
461
+ });
462
+
463
+ it("an empty set removes every grant", async () => {
464
+ (prisma.agent.findUnique as jest.Mock).mockResolvedValue(
465
+ agentWithRole("owner", ["content-edit", "content-publish"]),
466
+ );
467
+ const result = await setServedAgentGrants({ organizationId: ORG_A, agentId: AGENT, grants: [] });
468
+ expect(prisma.agent.updateMany).toHaveBeenCalledWith({
469
+ where: { id: AGENT, organizationId: ORG_A },
470
+ data: { grants: [] },
471
+ });
472
+ expect(result).toMatchObject({ ok: true, agent: { grants: [] }, previous: ["content-edit", "content-publish"] });
473
+ });
474
+
475
+ it("refuses a grant its person's role does not hold, and writes nothing", async () => {
476
+ (prisma.agent.findUnique as jest.Mock).mockResolvedValue(agentWithRole("member"));
477
+ await expect(
478
+ setServedAgentGrants({ organizationId: ORG_A, agentId: AGENT, grants: ["content-edit"] }),
479
+ ).resolves.toEqual({ ok: false, reason: "exceeds-person" });
480
+ expect(prisma.agent.updateMany).not.toHaveBeenCalled();
481
+ expect(prisma.agent.findMany).not.toHaveBeenCalled();
482
+ });
483
+
484
+ it("is not-found for an unknown agent or one of another organization, and writes nothing", async () => {
485
+ (prisma.agent.findUnique as jest.Mock).mockResolvedValueOnce(null);
486
+ await expect(
487
+ setServedAgentGrants({ organizationId: ORG_A, agentId: AGENT, grants: ["content-edit"] }),
488
+ ).resolves.toEqual({ ok: false, reason: "not-found" });
489
+
490
+ (prisma.agent.findUnique as jest.Mock).mockResolvedValueOnce({
491
+ ...agentWithRole("owner"),
492
+ organizationId: ORG_B,
493
+ });
494
+ await expect(
495
+ setServedAgentGrants({ organizationId: ORG_A, agentId: AGENT, grants: ["content-edit"] }),
496
+ ).resolves.toEqual({ ok: false, reason: "not-found" });
497
+ expect(prisma.agent.updateMany).not.toHaveBeenCalled();
498
+ });
499
+
500
+ it("the list row serves only the five, in D1 order, whatever the row holds", async () => {
501
+ (prisma.agent.findMany as jest.Mock).mockResolvedValue([
502
+ {
503
+ ...AGENT_ROW,
504
+ grants: ["meeting-types-manage", "content-edit", "agents-manage"],
505
+ responsible: { userId: PERSON, user: { name: "Pat", email: "pat@example.test" } },
506
+ keyIssuedTo: null,
507
+ },
508
+ ]);
509
+ const [row] = await listAgentsForOrganization(ORG_A);
510
+ expect(row?.grants).toEqual(["content-edit", "meeting-types-manage"]);
511
+ });
512
+ });
513
+
402
514
  describe("listAgentsForOrganization", () => {
403
515
  beforeEach(() => {
404
516
  (prisma.agent.findMany as jest.Mock).mockResolvedValue([
@@ -423,6 +535,7 @@ describe("listAgentsForOrganization", () => {
423
535
  workspaceId: WS_A1,
424
536
  responsible: { userId: PERSON, name: "Pat", email: "pat@example.test" },
425
537
  permissionLevel: "member",
538
+ grants: [],
426
539
  enabled: true,
427
540
  lastActiveAt: null,
428
541
  createdAt: AGENT_ROW.createdAt,
@@ -17,6 +17,9 @@
17
17
  * (#6228): the assertion's first arm now skips on any OTHER adapter, so a
18
18
  * case that means to exercise the REAL resolution must say which adapter it
19
19
  * is booting under rather than inherit whatever the ambient env holds.
20
+ *
21
+ * `describe("site stamp (#7868)")` covers the Checkout `metadata.site` stamp
22
+ * and the webhook's foreign-site test (spec D2/D7).
20
23
  */
21
24
 
22
25
  // 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.
@@ -28,7 +31,9 @@ import {
28
31
  __resetSiteWorkspaceCacheForTests,
29
32
  assertSiteAliasResolves,
30
33
  currentSiteId,
34
+ isForeignSiteStamp,
31
35
  resolveSiteWorkspaceId,
36
+ siteStamp,
32
37
  } from "../site.js";
33
38
 
34
39
  const DEFAULT_ORG_ID = "org_00000000-0000-7000-8000-000000000001";
@@ -236,3 +241,52 @@ describe("assertSiteAliasResolves — the #6228 content-adapter carve-out (arm 0
236
241
  expect(prisma.workspace.findUnique).toHaveBeenCalled();
237
242
  });
238
243
  });
244
+
245
+ describe("site stamp (#7868)", () => {
246
+ // Stripe types metadata values as strings; a non-string arrives only from a
247
+ // malformed payload, so the fixture is cast at this one boundary.
248
+ const nonStringStamp: Record<string, unknown> = { site: 42 };
249
+
250
+ it("siteStamp() with AILK_SITE_ID unset stamps the default alias", () => {
251
+ delete process.env.AILK_SITE_ID;
252
+ expect(siteStamp()).toEqual({ site: "default" });
253
+ });
254
+
255
+ it("siteStamp() follows AILK_SITE_ID, read per call", () => {
256
+ process.env.AILK_SITE_ID = PINNED_WORKSPACE_ID;
257
+ expect(siteStamp()).toEqual({ site: PINNED_WORKSPACE_ID });
258
+ });
259
+
260
+ it("siteStamp() is empty when AILK_SITE_ID is the empty string — Stripe is never sent an empty value (SEC-7868-5)", () => {
261
+ process.env.AILK_SITE_ID = "";
262
+ expect(siteStamp()).toEqual({});
263
+ });
264
+
265
+ it.each([
266
+ ["undefined metadata", undefined],
267
+ ["null metadata", null],
268
+ ["no stamp", {}],
269
+ ["an empty stamp", { site: "" }],
270
+ ["the own stamp", { site: "default" }],
271
+ ["a non-string stamp", nonStringStamp as Record<string, string>],
272
+ ])("isForeignSiteStamp is false for %s (unstamped is own, roadmap D4)", (_label, metadata) => {
273
+ delete process.env.AILK_SITE_ID;
274
+ expect(isForeignSiteStamp(metadata)).toBe(false);
275
+ });
276
+
277
+ it("isForeignSiteStamp is true for another site's stamp", () => {
278
+ delete process.env.AILK_SITE_ID;
279
+ expect(isForeignSiteStamp({ site: "samhenry" })).toBe(true);
280
+ });
281
+
282
+ it("isForeignSiteStamp compares exactly — a case-folded own alias is foreign", () => {
283
+ delete process.env.AILK_SITE_ID;
284
+ expect(isForeignSiteStamp({ site: "Default" })).toBe(true);
285
+ });
286
+
287
+ it("isForeignSiteStamp reads the alias per call, not once at import", () => {
288
+ process.env.AILK_SITE_ID = PINNED_WORKSPACE_ID;
289
+ expect(isForeignSiteStamp({ site: PINNED_WORKSPACE_ID })).toBe(false);
290
+ expect(isForeignSiteStamp({ site: "default" })).toBe(true);
291
+ });
292
+ });
@@ -27,13 +27,25 @@
27
27
  * `findAgentByKeyHash` and `touchAgentLastActive` only;
28
28
  * - the management routes (`routes/workspaces/agents.ts`) call the rest.
29
29
  *
30
- * Nothing caches a row: a switch-off, a re-key or a removed membership takes
31
- * effect on the next request.
30
+ * GRANTS (#7864, spec docs/specs/7864-agent-key-grants-spec.md D2–D4). An
31
+ * agent's row carries the permissions an owner or admin granted it one by one
32
+ * — only the five `AGENT_GRANTABLE_PERMISSIONS`. `setServedAgentGrants` is
33
+ * the column's only writer, and refuses a grant its person's own role does
34
+ * not hold; the tenant guards apply the same rule on every request, so a
35
+ * grant stored before a demotion is held to the person's current role.
36
+ *
37
+ * Nothing caches a row: a switch-off, a re-key, a grant change or a removed
38
+ * membership takes effect on the next request.
32
39
  */
33
40
  import { createHash, randomBytes } from "node:crypto";
34
41
 
35
42
  import type { Id } from "@working-theory/ids";
36
- import type { AgentPermissionLevel } from "@working-theory/validation";
43
+ import {
44
+ AGENT_GRANTABLE_PERMISSIONS,
45
+ can,
46
+ type AgentGrantablePermission,
47
+ type AgentPermissionLevel,
48
+ } from "@working-theory/validation";
37
49
 
38
50
  import { prisma } from "./prisma.js";
39
51
 
@@ -71,6 +83,21 @@ export interface AgentGrant {
71
83
  organizationId: Id<"organization">;
72
84
  responsibleMembershipId: Id<"membership">;
73
85
  permissionLevel: AgentPermissionLevel;
86
+ /**
87
+ * The grants stored on the row (#7864 D3). The tenant guards keep only
88
+ * those its person's role also holds (`agentTenant`).
89
+ */
90
+ grants: readonly string[];
91
+ }
92
+
93
+ /**
94
+ * A stored grant set in `AGENT_GRANTABLE_PERMISSIONS` order, without
95
+ * duplicates and without anything outside the five.
96
+ */
97
+ function orderedGrants(
98
+ grants: readonly string[],
99
+ ): AgentGrantablePermission[] {
100
+ return AGENT_GRANTABLE_PERMISSIONS.filter((p) => grants.includes(p));
74
101
  }
75
102
 
76
103
  // ── The auth preHandler's three functions ────────────────────────────────────
@@ -80,6 +107,8 @@ export interface AgentKeyRow {
80
107
  workspaceId: string;
81
108
  organizationId: string;
82
109
  permissionLevel: AgentPermissionLevel;
110
+ /** `undefined` only from a test double; the auth preHandler reads it as none. */
111
+ grants: string[] | undefined;
83
112
  enabled: boolean;
84
113
  keyIssuedToMembershipId: string | null;
85
114
  lastActiveAt: Date | null;
@@ -97,6 +126,7 @@ export async function findAgentByKeyHash(
97
126
  workspaceId: true,
98
127
  organizationId: true,
99
128
  permissionLevel: true,
129
+ grants: true,
100
130
  enabled: true,
101
131
  keyIssuedToMembershipId: true,
102
132
  lastActiveAt: true,
@@ -180,6 +210,12 @@ interface SetAgentEnabledParams {
180
210
  enabled: boolean;
181
211
  }
182
212
 
213
+ interface SetAgentGrantsParams {
214
+ organizationId: string;
215
+ agentId: string;
216
+ grants: readonly AgentGrantablePermission[];
217
+ }
218
+
183
219
  /**
184
220
  * Add an agent for `responsibleUserId`, bound to `workspaceId`. The returned
185
221
  * `key` is the only copy that will ever exist: the caller shows it once.
@@ -280,6 +316,58 @@ export async function setServedAgentEnabled(
280
316
  });
281
317
  }
282
318
 
319
+ /** A grant set the agent's person's own role does not wholly hold. */
320
+ export type AgentGrantExceedsPerson = { ok: false; reason: "exceeds-person" };
321
+ export type ServedAgentGrants = ServedAgent & {
322
+ /** The grants stored before this write, in D1 order — for the audit row. */
323
+ previous: AgentGrantablePermission[];
324
+ };
325
+
326
+ /**
327
+ * Replace an agent's grants with `grants` (#7864 D4) and answer with its list
328
+ * row. One transaction: the agent is read by id inside the caller's
329
+ * organization, with its responsible person's role; any requested grant that
330
+ * role does not hold refuses the whole set (`exceeds-person`) and writes
331
+ * nothing. An agent of another organization, or none, is `not-found`.
332
+ */
333
+ export async function setServedAgentGrants(
334
+ params: SetAgentGrantsParams,
335
+ ): Promise<ServedAgentGrants | AgentNotFound | AgentGrantExceedsPerson> {
336
+ return prisma.$transaction(async (tx) => {
337
+ const current = await tx.agent.findUnique({
338
+ where: agentScope(params),
339
+ select: {
340
+ organizationId: true,
341
+ grants: true,
342
+ responsible: { select: { role: true } },
343
+ },
344
+ });
345
+ if (!current || current.organizationId !== params.organizationId) {
346
+ return { ok: false, reason: "not-found" } as const;
347
+ }
348
+ const grants = orderedGrants(params.grants);
349
+ if (!grants.every((grant) => can(current.responsible.role, grant))) {
350
+ return { ok: false, reason: "exceeds-person" } as const;
351
+ }
352
+ const { count } = await tx.agent.updateMany({
353
+ where: agentScope(params),
354
+ data: { grants },
355
+ });
356
+ if (count !== 1) return { ok: false, reason: "not-found" } as const;
357
+ const [agent] = await readAgentListRows(
358
+ { id: params.agentId, organizationId: params.organizationId },
359
+ tx,
360
+ );
361
+ return agent
362
+ ? ({
363
+ ok: true,
364
+ agent,
365
+ previous: orderedGrants(current.grants),
366
+ } as const)
367
+ : ({ ok: false, reason: "not-found" } as const);
368
+ });
369
+ }
370
+
283
371
  /** The list row of a just-written agent, read on the write's own client. */
284
372
  async function servedWith(
285
373
  db: AgentDb,
@@ -392,6 +480,8 @@ export interface AgentListRow {
392
480
  workspaceId: string;
393
481
  responsible: { userId: string; name: string | null; email: string };
394
482
  permissionLevel: AgentPermissionLevel;
483
+ /** The stored grants (#7864 D4), in `AGENT_GRANTABLE_PERMISSIONS` order. */
484
+ grants: AgentGrantablePermission[];
395
485
  enabled: boolean;
396
486
  lastActiveAt: Date | null;
397
487
  createdAt: Date;
@@ -431,6 +521,7 @@ async function readAgentListRows(
431
521
  name: true,
432
522
  workspaceId: true,
433
523
  permissionLevel: true,
524
+ grants: true,
434
525
  enabled: true,
435
526
  lastActiveAt: true,
436
527
  createdAt: true,
@@ -466,6 +557,7 @@ async function readAgentListRows(
466
557
  email: r.responsible.user.email,
467
558
  },
468
559
  permissionLevel: r.permissionLevel,
560
+ grants: orderedGrants(r.grants),
469
561
  enabled: r.enabled,
470
562
  lastActiveAt: r.lastActiveAt,
471
563
  createdAt: r.createdAt,
@@ -20,6 +20,14 @@
20
20
  * `currentSiteId()` async would edit every one of its dozen non-test call
21
21
  * sites across routes/services/bins, including the two route files NFR1
22
22
  * requires to stay byte-unchanged.
23
+ *
24
+ * The Checkout site stamp (#7868, spec D1/D2): `SITE_STAMP_KEY`,
25
+ * `siteStamp()` and `isForeignSiteStamp()` are the one definition of the
26
+ * `metadata.site` key every hub-created Checkout session carries and the
27
+ * Stripe webhook reads. The stamp is `currentSiteId()`, so writer and reader
28
+ * cannot disagree inside one deployment. A session is foreign only when its
29
+ * stamp is a non-empty string other than `currentSiteId()`; an unstamped
30
+ * session is the hub's own (roadmap D4).
23
31
  */
24
32
  import { resolvedAdapterKind } from "@working-theory/content-adapters";
25
33
  import {
@@ -35,6 +43,35 @@ export function currentSiteId(): string {
35
43
  return process.env.AILK_SITE_ID ?? "default";
36
44
  }
37
45
 
46
+ /** The Checkout session metadata key naming the site a session belongs to (#7868 D1). */
47
+ export const SITE_STAMP_KEY = "site";
48
+
49
+ /**
50
+ * The metadata a hub-created Checkout session carries (#7868 D1/D2):
51
+ * `{ [SITE_STAMP_KEY]: currentSiteId() }`, read per call. Empty when the alias
52
+ * is `""` (an `AILK_SITE_ID` set to the empty string), so Stripe is never sent
53
+ * an empty value (SEC-7868-5) — and the webhook treats that deployment's
54
+ * sessions as unstamped, exactly as today.
55
+ */
56
+ export function siteStamp(): Record<string, string> {
57
+ const site = currentSiteId();
58
+ return site === "" ? {} : { [SITE_STAMP_KEY]: site };
59
+ }
60
+
61
+ /**
62
+ * True only when `metadata[SITE_STAMP_KEY]` is a non-empty string that is not
63
+ * `currentSiteId()` — a session another site on the shared Stripe account
64
+ * opened (#7868 D2). An absent, empty or non-string stamp is the hub's own
65
+ * (roadmap D4: Payment Links, invoices, the portal and pre-stamp sessions
66
+ * carry none). Exact, case-sensitive comparison, as the spokes' own check.
67
+ */
68
+ export function isForeignSiteStamp(
69
+ metadata: Record<string, string> | null | undefined,
70
+ ): boolean {
71
+ const stamp: unknown = metadata?.[SITE_STAMP_KEY];
72
+ return typeof stamp === "string" && stamp !== "" && stamp !== currentSiteId();
73
+ }
74
+
38
75
  /**
39
76
  * The resolved workspace id, memoized after the first SUCCESSFUL resolution
40
77
  * ("resolves the alias once at startup", ownership brief § Layer 2 / #6155
@@ -18,11 +18,13 @@
18
18
  * the hub — read that workspace's enabled meeting types and public content,
19
19
  * and write leads and bookings into it.
20
20
  *
21
- * Two kinds of caller, kept apart:
21
+ * Three kinds of caller, kept apart:
22
22
  * - the public resolver (`services/public-site-resolver.ts`) calls
23
23
  * `hashSpokeKey`, `findSpokeByKeyHash` and `hasRegisteredSpoke` only;
24
24
  * - the operator CLI (`bin/spokes.ts`) calls `registerSpoke`, `revokeSpoke`
25
- * and `listSpokes`. No route imports them.
25
+ * and `listSpokes`. No route imports them;
26
+ * - the operator flow-copy service (`services/flow-copy.ts`, run only by
27
+ * `bin/flows-copy.ts`) calls `hasLiveSpokeForWorkspace` only (#7844 D7).
26
28
  *
27
29
  * Nothing here caches a row: a revocation takes effect on the next request
28
30
  * (NFR4). The resolver holds `hasRegisteredSpoke`'s answer itself (#7633 D3).
@@ -73,6 +75,20 @@ export async function hasRegisteredSpoke(): Promise<boolean> {
73
75
  return Boolean(row);
74
76
  }
75
77
 
78
+ /**
79
+ * Whether at least one unrevoked spoke is bound to this workspace (#7844 D7):
80
+ * the flow-copy tool's target check. Selects `id` only.
81
+ */
82
+ export async function hasLiveSpokeForWorkspace(
83
+ workspaceId: string,
84
+ ): Promise<boolean> {
85
+ const row = await prisma.spoke.findFirst({
86
+ where: { workspaceId, revokedAt: null },
87
+ select: { id: true },
88
+ });
89
+ return Boolean(row);
90
+ }
91
+
76
92
  // ── Operator functions — `bin/spokes.ts` only ────────────────────────────────
77
93
 
78
94
  export interface SpokeListRow {
@@ -194,6 +194,8 @@ const WS_A1 = "wksp_00000000-0000-7000-8000-0000000000a1";
194
194
 
195
195
  type AgentFixture = {
196
196
  id: string;
197
+ /** Absent → the row carries none (the double omits the column). */
198
+ grants?: string[];
197
199
  enabled?: boolean;
198
200
  keyIssuedToMembershipId?: string | null;
199
201
  lastActiveAt?: Date | null;
@@ -215,6 +217,7 @@ function installAgents(agents: Array<AgentFixture & { key: string }>): void {
215
217
  workspaceId: WS_A1,
216
218
  organizationId: ORG_A,
217
219
  permissionLevel: "member",
220
+ ...(a.grants === undefined ? {} : { grants: a.grants }),
218
221
  enabled: a.enabled ?? true,
219
222
  keyIssuedToMembershipId:
220
223
  a.keyIssuedToMembershipId === undefined ? ADMIN_MEMBERSHIP : a.keyIssuedToMembershipId,
@@ -298,6 +301,7 @@ describe("auth preHandler — agent branch (#3591)", () => {
298
301
  organizationId: ORG_A,
299
302
  responsibleMembershipId: PERSON_MEMBERSHIP,
300
303
  permissionLevel: "member",
304
+ grants: [],
301
305
  },
302
306
  actor: { type: "agent", agentId: AGENT_A, onBehalfOfUserId: PERSON },
303
307
  sessionTenant: null,
@@ -530,6 +530,7 @@ describe("defaultTenantPreHandler — agents (#3591 D5, D7)", () => {
530
530
  workspaceId: string;
531
531
  organizationId: string;
532
532
  responsibleMembershipId: string;
533
+ grants: string[];
533
534
  }> = {},
534
535
  ) {
535
536
  return {
@@ -539,6 +540,7 @@ describe("defaultTenantPreHandler — agents (#3591 D5, D7)", () => {
539
540
  responsibleMembershipId: (overrides.responsibleMembershipId ??
540
541
  OPERATOR_MEMBERSHIP_ID) as Id<"membership">,
541
542
  permissionLevel: "member" as const,
543
+ grants: overrides.grants ?? [],
542
544
  };
543
545
  }
544
546
 
@@ -582,6 +584,26 @@ describe("defaultTenantPreHandler — agents (#3591 D5, D7)", () => {
582
584
  await app.close();
583
585
  });
584
586
 
587
+ it("a granted agent of the owner-operator carries its grants on the tenant; a forged one is dropped (#7864 D3)", async () => {
588
+ mockSingleTenantWorkspace();
589
+ mockOperatorMembership();
590
+ const app = buildAgentApp(
591
+ agentBinding({ grants: ["magnets-manage", "agents-manage", "content-edit"] }),
592
+ );
593
+
594
+ const res = await app.inject({ method: "GET", url: "/scoped" });
595
+
596
+ expect(res.statusCode).toBe(200);
597
+ expect(res.json().tenant).toEqual({
598
+ userId: USER_ID,
599
+ organizationId: DEFAULT_ORG_ID,
600
+ workspaceId: DEFAULT_WORKSPACE_ID,
601
+ role: "member",
602
+ grants: ["content-edit", "magnets-manage"],
603
+ });
604
+ await app.close();
605
+ });
606
+
585
607
  it("reaches the grant through the operator's own reads: the same membership compound key", async () => {
586
608
  mockSingleTenantWorkspace();
587
609
  mockOperatorMembership();
@@ -354,6 +354,9 @@ async function authenticateAgent(
354
354
  organizationId: row.organizationId as Id<"organization">,
355
355
  responsibleMembershipId: row.responsible.id as Id<"membership">,
356
356
  permissionLevel: row.permissionLevel,
357
+ // #7864 D3 — the one place a missing value becomes none; the tenant
358
+ // guards keep only the grants its person's role also holds.
359
+ grants: row.grants ?? [],
357
360
  };
358
361
  bindActor(request, reply, {
359
362
  type: "agent",
@@ -222,6 +222,29 @@ describe("GET /api/openapi.json", () => {
222
222
  },
223
223
  );
224
224
 
225
+ it("documents /v1/leads/submit's optional Idempotency-Key header and its 422 (#7869)", async () => {
226
+ const res = await app.inject({
227
+ method: "GET",
228
+ url: "/v1/openapi.json",
229
+ });
230
+
231
+ const spec = JSON.parse(res.body);
232
+ const post = spec.paths["/v1/leads/submit"].post;
233
+ expect(post.parameters).toEqual(
234
+ expect.arrayContaining([
235
+ expect.objectContaining({
236
+ name: "Idempotency-Key",
237
+ in: "header",
238
+ required: false,
239
+ }),
240
+ ]),
241
+ );
242
+ expect(
243
+ post.responses["422"].content["application/json"].schema.properties.error
244
+ .enum,
245
+ ).toEqual(["idempotency_key_reused"]);
246
+ });
247
+
225
248
  it("no longer describes the API as read-only", async () => {
226
249
  const res = await app.inject({
227
250
  method: "GET",