create-ailk 0.13.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 (188) hide show
  1. package/README.md +12 -4
  2. package/component-catalog.json +250 -36
  3. package/component-catalog.md +10 -6
  4. package/dist/add-module.d.ts +7 -0
  5. package/dist/add-module.js +34 -2
  6. package/dist/add-route.js +18 -2
  7. package/dist/apply-design.d.ts +32 -2
  8. package/dist/apply-design.js +155 -22
  9. package/dist/cli.js +24 -3
  10. package/dist/derive-architecture.d.ts +15 -2
  11. package/dist/derive-architecture.js +34 -6
  12. package/dist/design-compile.d.ts +70 -2
  13. package/dist/design-compile.js +97 -1
  14. package/dist/design-home.d.ts +74 -0
  15. package/dist/design-home.js +256 -0
  16. package/dist/design-package.d.ts +39 -2
  17. package/dist/design-package.js +224 -9
  18. package/dist/design-presets.d.ts +17 -1
  19. package/dist/design-presets.js +17 -5
  20. package/dist/design-scaffold.d.ts +6 -25
  21. package/dist/design-scaffold.js +26 -74
  22. package/dist/index.d.ts +2 -1
  23. package/dist/index.js +1 -1
  24. package/dist/lib/chrome-readers.d.ts +21 -0
  25. package/dist/lib/chrome-readers.js +100 -0
  26. package/dist/lib/design-checks.d.ts +12 -1
  27. package/dist/lib/design-checks.js +38 -2
  28. package/dist/lib/extract-module-bundle.d.ts +11 -0
  29. package/dist/lib/extract-module-bundle.js +36 -6
  30. package/dist/lib/license-key-files.d.ts +6 -1
  31. package/dist/lib/license-key-files.js +9 -4
  32. package/dist/lib/module-license-gate.d.ts +9 -0
  33. package/dist/lib/module-license-gate.js +18 -0
  34. package/dist/module-architecture.d.ts +9 -0
  35. package/dist/module-architecture.js +6 -0
  36. package/dist/parse-args.d.ts +22 -0
  37. package/dist/parse-args.js +73 -0
  38. package/dist/programmatic.d.ts +20 -0
  39. package/dist/programmatic.js +180 -35
  40. package/dist/site-chrome.d.ts +42 -0
  41. package/dist/site-chrome.js +140 -2
  42. package/dist/site-fonts.d.ts +21 -0
  43. package/dist/site-fonts.js +54 -0
  44. package/dist/site-manifest.d.ts +12 -5
  45. package/dist/site-manifest.js +25 -11
  46. package/dist/site-roster.d.ts +16 -0
  47. package/dist/site-roster.js +81 -0
  48. package/dist/starter-grid.js +15 -0
  49. package/dist/surfaces.js +12 -0
  50. package/dist/sync-routes.js +11 -2
  51. package/dist/team-profiles.d.ts +29 -0
  52. package/dist/team-profiles.js +43 -0
  53. package/dist/topology.d.ts +131 -0
  54. package/dist/topology.js +533 -0
  55. package/package.json +3 -3
  56. package/release-scope.json +1 -1
  57. package/site-templates/_base/content/en/TeamList/team.mdx +68 -0
  58. package/site-templates/_base/content/en/TeamMember/first-member.mdx +22 -0
  59. package/site-templates/_base/content/en/TeamMember/second-member.mdx +22 -0
  60. package/site-templates/_base/content/en/TeamMember/third-member.mdx +22 -0
  61. package/site-templates/designs/minimalism/DESIGN.md +166 -36
  62. package/site-templates/designs/minimalism/home/local-business-service.mdx +119 -0
  63. package/site-templates/designs/minimalism/home/organization-product.mdx +132 -0
  64. package/site-templates/designs/minimalism/home/organization-service.mdx +131 -0
  65. package/site-templates/designs/minimalism/home/person-service.mdx +101 -0
  66. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +34 -1
  67. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +20 -0
  68. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +34 -1
  69. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +22 -1
  70. package/site-templates/marketing-site-free/ailk-template.json +1 -1
  71. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +34 -1
  72. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -7
  73. package/site-templates/shapes/local-business-service/ailk-template.json +1 -1
  74. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +34 -1
  75. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -7
  76. package/site-templates/shapes/organization-service/ailk-template.json +1 -1
  77. package/templates/README.md +2 -0
  78. package/templates/apps/api/CLAUDE.md +6 -1
  79. package/templates/apps/api/package.json +1 -0
  80. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
  81. package/templates/apps/api/src/lib/__mocks__/prisma.ts +10 -0
  82. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
  83. package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
  84. package/templates/apps/api/src/lib/agent-store.ts +95 -3
  85. package/templates/apps/api/src/lib/site.ts +37 -0
  86. package/templates/apps/api/src/lib/spoke-store.ts +18 -2
  87. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
  88. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
  89. package/templates/apps/api/src/middleware/auth.ts +3 -0
  90. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
  91. package/templates/apps/api/src/openapi/spec.ts +38 -2
  92. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
  93. package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
  94. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +275 -0
  95. package/templates/apps/api/src/routes/leads/index.ts +69 -6
  96. package/templates/apps/api/src/routes/webhooks/README.md +39 -0
  97. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
  98. package/templates/apps/api/src/routes/webhooks/stripe.ts +61 -1
  99. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +199 -0
  100. package/templates/apps/api/src/services/default-tenant.ts +41 -7
  101. package/templates/apps/api/src/services/lead-routing.ts +148 -29
  102. package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
  103. package/templates/apps/mcp/__tests__/index.test.ts +5 -5
  104. package/templates/apps/mcp/package.json +1 -1
  105. package/templates/apps/mcp/src/index.ts +6 -4
  106. package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
  107. package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
  108. package/templates/apps/web/CLAUDE.md +12 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +10 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/settings/__tests__/theme-modes.test.ts +42 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +8 -0
  112. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  113. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  114. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
  115. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  116. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +14 -1
  117. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
  118. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +163 -0
  119. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +68 -0
  120. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +4 -4
  121. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +15 -3
  122. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +17 -6
  123. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +14 -1
  124. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +2 -2
  125. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +4 -6
  126. package/templates/apps/web/app/[locale]/layout.tsx +212 -57
  127. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
  128. package/templates/apps/web/app/[locale]/page.tsx +37 -0
  129. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  130. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +4 -6
  131. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
  132. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +15 -2
  133. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +43 -6
  134. package/templates/apps/web/app/[locale]/services/page.tsx +14 -2
  135. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +2 -2
  136. package/templates/apps/web/app/[locale]/team/[slug]/page.tsx +45 -0
  137. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +155 -0
  138. package/templates/apps/web/app/[locale]/team/page.tsx +49 -0
  139. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +2 -2
  140. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +14 -1
  141. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
  142. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +2 -2
  143. package/templates/apps/web/app/not-found.tsx +47 -0
  144. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +89 -1
  145. package/templates/apps/web/app/openapi.json/route.ts +69 -1
  146. package/templates/apps/web/instrumentation.ts +7 -4
  147. package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
  148. package/templates/apps/web/lib/__tests__/profile-back-link.test.ts +95 -0
  149. package/templates/apps/web/lib/__tests__/relay.test.ts +290 -0
  150. package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
  151. package/templates/apps/web/lib/__tests__/site-theme-head.test.ts +70 -0
  152. package/templates/apps/web/lib/__tests__/site-theme.test.ts +64 -1
  153. package/templates/apps/web/lib/closing-footer-path.ts +50 -0
  154. package/templates/apps/web/lib/profile-back-link.ts +51 -0
  155. package/templates/apps/web/lib/relay.ts +206 -2
  156. package/templates/apps/web/lib/site-brand.tsx +22 -1
  157. package/templates/apps/web/lib/site-chrome.ts +15 -0
  158. package/templates/apps/web/lib/site-fonts.ts +15 -0
  159. package/templates/apps/web/lib/site-logo.tsx +39 -1
  160. package/templates/apps/web/lib/site-theme-head.ts +33 -0
  161. package/templates/apps/web/lib/site-theme.ts +13 -1
  162. package/templates/apps/web/lib/topology.generated.ts +23 -0
  163. package/templates/apps/web/messages/en.json +7 -1
  164. package/templates/apps/web/package.json +1 -1
  165. package/templates/database/CHANGELOG.md +32 -0
  166. package/templates/database/__tests__/agents.db.test.ts +65 -0
  167. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
  168. package/templates/database/auth/schema.prisma +23 -0
  169. package/templates/database/inbox/schema.prisma +13 -0
  170. package/templates/database/jest.config.cjs +7 -11
  171. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
  172. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
  173. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
  174. package/templates/database/package.json +1 -1
  175. package/versions.json +35 -34
  176. package/site-templates/designs/minimalism/sample/README.md +0 -48
  177. package/site-templates/designs/minimalism/sample/content/en/HomePage/home.mdx +0 -244
  178. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-1.svg +0 -7
  179. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-2.svg +0 -7
  180. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-3.svg +0 -7
  181. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-4.svg +0 -7
  182. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-5.svg +0 -7
  183. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-6.svg +0 -7
  184. package/site-templates/designs/minimalism/sample/public/hero-backdrop.svg +0 -17
  185. package/site-templates/designs/minimalism/sample/public/logo-placeholder-1.svg +0 -6
  186. package/site-templates/designs/minimalism/sample/public/logo-placeholder-2.svg +0 -6
  187. package/site-templates/designs/minimalism/sample/public/logo-placeholder-3.svg +0 -6
  188. package/site-templates/designs/minimalism/sample/public/wordmark-placeholder.svg +0 -28
@@ -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",
@@ -751,7 +751,25 @@ export function buildOpenApiSpec(
751
751
  "only), or, on a deployment with no database, the site operator's inbox " +
752
752
  "by email (`persisted: \"email\"`); with a database, the response's " +
753
753
  "`email` field reports, best-effort, whether the operator was notified. " +
754
- "A lead is never reported captured when nothing stored or delivered it.",
754
+ "A lead is never reported captured when nothing stored or delivered it. " +
755
+ "An optional `Idempotency-Key` header makes a retry safe: with a " +
756
+ "database, a repeat of a stored key in the same workspace with the same " +
757
+ "`email`, `source` and `payload` writes nothing and answers the first " +
758
+ "lead's id with a 201; with anything different it is a 422. Keys are " +
759
+ "scoped per workspace, are first-write-wins, and should be unguessable " +
760
+ "(a UUID, a Stripe Checkout Session id). Without a database there is no " +
761
+ "stored lead to match, so the key has no effect.",
762
+ parameters: [
763
+ {
764
+ name: "Idempotency-Key",
765
+ in: "header",
766
+ required: false,
767
+ description:
768
+ "An unguessable value identifying this lead write, so a retry " +
769
+ "answers the first lead instead of writing a second (#7869).",
770
+ schema: { type: "string", pattern: "^[\\x21-\\x7E]{16,255}$" },
771
+ },
772
+ ],
755
773
  requestBody: {
756
774
  required: true,
757
775
  content: {
@@ -770,13 +788,31 @@ export function buildOpenApiSpec(
770
788
  },
771
789
  },
772
790
  "400": {
773
- description: "Validation error — invalid request body",
791
+ description:
792
+ "Validation error — invalid request body, or a malformed Idempotency-Key",
774
793
  content: {
775
794
  "application/json": {
776
795
  schema: { $ref: "#/components/schemas/InvalidInputResponse" },
777
796
  },
778
797
  },
779
798
  },
799
+ "422": {
800
+ description:
801
+ "The Idempotency-Key was already used in this workspace for a lead " +
802
+ "with a different email, source or payload; nothing was written",
803
+ content: {
804
+ "application/json": {
805
+ schema: {
806
+ type: "object",
807
+ required: ["error", "message"],
808
+ properties: {
809
+ error: { type: "string", enum: ["idempotency_key_reused"] },
810
+ message: { type: "string" },
811
+ },
812
+ },
813
+ },
814
+ },
815
+ },
780
816
  "503": {
781
817
  description:
782
818
  "Lead capture unavailable — persistence failed, delivery failed, or " +
@@ -1080,7 +1080,8 @@ describe("POST /v1/checkout/sessions — signed-in buyer stamp (#6866)", () => {
1080
1080
  expect(res.statusCode).toBe(201);
1081
1081
  const arg = mockCreateCheckoutSession.mock.calls[0][0];
1082
1082
  expect(arg).not.toHaveProperty("buyer");
1083
- expect(arg).not.toHaveProperty("metadata");
1083
+ // #7868 D4 — the only metadata is the hub's own site stamp.
1084
+ expect(arg.metadata).toEqual({ site: "default" });
1084
1085
  expect(arg).not.toHaveProperty("ailk_user_id");
1085
1086
  });
1086
1087
 
@@ -1093,7 +1094,8 @@ describe("POST /v1/checkout/sessions — signed-in buyer stamp (#6866)", () => {
1093
1094
 
1094
1095
  const arg = mockCreateCheckoutSession.mock.calls[0][0];
1095
1096
  expect(arg.buyer.userId).toBe("usr_buyer");
1096
- expect(arg).not.toHaveProperty("metadata");
1097
+ // #7868 D4 — the only metadata is the hub's own site stamp.
1098
+ expect(arg.metadata).toEqual({ site: "default" });
1097
1099
  });
1098
1100
 
1099
1101
  it("#6866 R5: with no cookie, the call is exactly today's argument object", async () => {
@@ -1109,6 +1111,8 @@ describe("POST /v1/checkout/sessions — signed-in buyer stamp (#6866)", () => {
1109
1111
  successUrl: VALID_BODY.successUrl,
1110
1112
  cancelUrl: VALID_BODY.cancelUrl,
1111
1113
  quantity: 1,
1114
+ // #7868 D4 — the hub's own site stamp (AILK_SITE_ID unset).
1115
+ metadata: { site: "default" },
1112
1116
  });
1113
1117
  expect(prisma.session.findUnique).not.toHaveBeenCalled();
1114
1118
  expect(prisma.user.findUnique).not.toHaveBeenCalled();
@@ -1116,6 +1120,50 @@ describe("POST /v1/checkout/sessions — signed-in buyer stamp (#6866)", () => {
1116
1120
  expect(createdLog()).not.toHaveProperty("identity");
1117
1121
  });
1118
1122
 
1123
+ it("#7868 D4: the site stamp follows AILK_SITE_ID", async () => {
1124
+ const original = process.env.AILK_SITE_ID;
1125
+ process.env.AILK_SITE_ID = "wksp_hub";
1126
+ try {
1127
+ const res = await post({});
1128
+
1129
+ expect(res.statusCode).toBe(201);
1130
+ expect(mockCreateCheckoutSession.mock.calls[0][0].metadata).toEqual({
1131
+ site: "wksp_hub",
1132
+ });
1133
+ } finally {
1134
+ if (original === undefined) delete process.env.AILK_SITE_ID;
1135
+ else process.env.AILK_SITE_ID = original;
1136
+ }
1137
+ });
1138
+
1139
+ it("#7868 D4: the anonymous retry after a stale customer carries the site stamp too", async () => {
1140
+ givenUser({ billingProvider: "stripe", billingCustomerId: "cus_bound" });
1141
+ mockCreateCheckoutSession.mockRejectedValueOnce(new UnknownCustomerError());
1142
+
1143
+ const res = await signedIn();
1144
+
1145
+ expect(res.statusCode).toBe(201);
1146
+ expect(mockCreateCheckoutSession).toHaveBeenCalledTimes(2);
1147
+ expect(mockCreateCheckoutSession.mock.calls[0][0].metadata).toEqual({
1148
+ site: "default",
1149
+ });
1150
+ expect(mockCreateCheckoutSession.mock.calls[1][0].metadata).toEqual({
1151
+ site: "default",
1152
+ });
1153
+ });
1154
+
1155
+ it("#7868 D4: a signed-in call carries both the buyer and the site stamp", async () => {
1156
+ const res = await signedIn();
1157
+
1158
+ expect(res.statusCode).toBe(201);
1159
+ const arg = mockCreateCheckoutSession.mock.calls[0][0];
1160
+ expect(arg.buyer).toEqual({
1161
+ userId: "usr_buyer",
1162
+ email: "buyer@example.com",
1163
+ });
1164
+ expect(arg.metadata).toEqual({ site: "default" });
1165
+ });
1166
+
1119
1167
  it.each([
1120
1168
  [
1121
1169
  "an unknown session",
@@ -22,6 +22,11 @@
22
22
  * identifies a buyer. Any lookup failure, and a pinned customer Stripe
23
23
  * does not know, degrades to today's anonymous Checkout — never an error.
24
24
  * The stamped id is never echoed or logged.
25
+ * - Site stamp (#7868): every session carries `metadata.site` from
26
+ * `siteStamp()` (lib/site.ts) — on the signed-in try and the anonymous
27
+ * retry alike — so the Stripe webhook can tell this site's sessions from
28
+ * a spoke's on a shared account. The body schema has no metadata field,
29
+ * so no caller can set or override the stamp.
25
30
  * - X-Internal-Auth gate: when OSS_API_INTERNAL_SECRET env var is set,
26
31
  * the X-Internal-Auth header must match; requests without a matching header
27
32
  * are rejected with 401. When the env var is unset, no gate is applied
@@ -55,6 +60,7 @@ import type { FastifyReply, FastifyRequest } from "fastify";
55
60
  import { z } from "zod";
56
61
 
57
62
  import { config } from "../../config/index.js";
63
+ import { siteStamp } from "../../lib/site.js";
58
64
  import {
59
65
  assertSandboxKey,
60
66
  createCheckoutSession,
@@ -306,6 +312,7 @@ export async function checkoutSessionsHandler(
306
312
  // Omitted when the caller says nothing — Stripe's default (no promo-code
307
313
  // field) is unchanged for every existing caller.
308
314
  ...(allowPromotionCodes !== undefined && { allowPromotionCodes }),
315
+ metadata: siteStamp(),
309
316
  };
310
317
  // Built only from the session cookie — never from `parsed.data` (#6866 D5).
311
318
  let { buyer, identity } = await resolveBuyer(request);
@@ -54,6 +54,11 @@ jest.mock("@working-theory/license", () => ({
54
54
  }),
55
55
  }));
56
56
 
57
+ // #7869 — the Idempotency-Key suite asserts a replay records no new capture
58
+ // event; `track` is fire-and-forget and never throws, so mocking it changes
59
+ // nothing for the cases above.
60
+ jest.mock("@working-theory/analytics/server", () => ({ track: jest.fn() }));
61
+
57
62
  import {
58
63
  leadSubmitRoute,
59
64
  leadSubscribeRoute,
@@ -1336,3 +1341,273 @@ describe("POST /v1/leads/submit — #6730 delivery modes", () => {
1336
1341
  await expect(fs.access(fileEnv.getCaptureFilePath())).rejects.toThrow();
1337
1342
  });
1338
1343
  });
1344
+
1345
+ // ── #7869 — Idempotency-Key on submit (spec D1, D3–D7, D10 cases 1–8) ─────────
1346
+ //
1347
+ // `inbox` delivery (DATABASE_URL set), the sentinel workspace resolvable. The
1348
+ // app logs into `logLines` so case 8 can prove the key reaches no log line.
1349
+ describe("POST /v1/leads/submit — Idempotency-Key (#7869)", () => {
1350
+ let app: FastifyInstance;
1351
+ let logLines: string[];
1352
+ const KEY = "fixture-retry-0000-0000-0002";
1353
+ const STORED_ID = "lead_00000000-0000-7000-8000-00000000a7a7";
1354
+ const NEW_ID = "lead_00000000-0000-7000-8000-00000000b8b8";
1355
+ const sendLeadNotification = jest.requireMock("@working-theory/email")
1356
+ .sendLeadNotification as jest.Mock;
1357
+ const track = jest.requireMock("@working-theory/analytics/server")
1358
+ .track as jest.Mock;
1359
+
1360
+ const body = {
1361
+ kind: "submit",
1362
+ source: "contact",
1363
+ email: "visitor@example.com",
1364
+ payload: { message: "hello", name: "Jamie" },
1365
+ };
1366
+ const send = (
1367
+ headers: Record<string, string> = {},
1368
+ url = "/v1/leads/submit",
1369
+ payload: Record<string, unknown> = body,
1370
+ ) => app.inject({ method: "POST", url, headers, payload });
1371
+ const keyLookups = () =>
1372
+ jest
1373
+ .mocked(prisma.lead.findUnique)
1374
+ .mock.calls.filter(
1375
+ (c) => "workspaceId_idempotencyKey" in (c[0]?.where ?? {}),
1376
+ );
1377
+ const keyIsNowhere = (resBody: string) => {
1378
+ expect(resBody).not.toContain(KEY);
1379
+ for (const line of logLines) expect(line).not.toContain(KEY);
1380
+ };
1381
+
1382
+ beforeEach(async () => {
1383
+ jest.clearAllMocks();
1384
+ stateSentinelWorkspaceExists();
1385
+ process.env.DATABASE_URL = "postgres://stub";
1386
+ process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
1387
+ process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
1388
+ (prisma.lead.create as jest.Mock).mockResolvedValue({ id: NEW_ID });
1389
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
1390
+
1391
+ logLines = [];
1392
+ const fastify = require("fastify")({
1393
+ logger: {
1394
+ level: "info",
1395
+ stream: { write: (msg: string) => logLines.push(msg) },
1396
+ },
1397
+ });
1398
+ await fastify.register(leadSubmitRoute);
1399
+ await fastify.register(leadSubscribeRoute);
1400
+ await fastify.register(leadCallbackRoute);
1401
+ app = fastify;
1402
+ });
1403
+
1404
+ afterEach(async () => {
1405
+ await app.close();
1406
+ delete process.env.DATABASE_URL;
1407
+ delete process.env.AILK_LEAD_NOTIFICATION_TO;
1408
+ delete process.env.AILK_LEAD_NOTIFICATION_FROM;
1409
+ });
1410
+
1411
+ it("case 1 — no header: one create with no idempotencyKey, no key lookup, 201", async () => {
1412
+ const res = await send();
1413
+
1414
+ expect(res.statusCode).toBe(201);
1415
+ expect(prisma.lead.create).toHaveBeenCalledTimes(1);
1416
+ expect(
1417
+ jest.mocked(prisma.lead.create).mock.calls[0]?.[0].data,
1418
+ ).not.toHaveProperty("idempotencyKey");
1419
+ expect(keyLookups()).toHaveLength(0);
1420
+ });
1421
+
1422
+ it("case 2 — a key with no stored row: scoped lookup, the create carries the key, 201", async () => {
1423
+ const res = await send({ "Idempotency-Key": KEY });
1424
+
1425
+ expect(res.statusCode).toBe(201);
1426
+ expect(res.json()).toEqual({
1427
+ id: NEW_ID,
1428
+ persisted: "db",
1429
+ email: { ok: true, messageId: "msg-test" },
1430
+ });
1431
+ expect(keyLookups()[0]?.[0]).toEqual({
1432
+ where: {
1433
+ workspaceId_idempotencyKey: {
1434
+ workspaceId: RESOLVED_WORKSPACE_ID,
1435
+ idempotencyKey: KEY,
1436
+ },
1437
+ },
1438
+ select: { id: true, email: true, source: true, payload: true },
1439
+ });
1440
+ expect(
1441
+ jest.mocked(prisma.lead.create).mock.calls[0]?.[0].data,
1442
+ ).toMatchObject({
1443
+ idempotencyKey: KEY,
1444
+ });
1445
+ keyIsNowhere(res.body);
1446
+ });
1447
+
1448
+ it("case 3 — a stored row with the same email, source and payload (keys reordered) replays: 201, the stored id, no create, no notification, no track, no capture line", async () => {
1449
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue({
1450
+ id: STORED_ID,
1451
+ email: body.email,
1452
+ source: body.source,
1453
+ payload: { name: "Jamie", message: "hello" },
1454
+ });
1455
+
1456
+ const res = await send({ "Idempotency-Key": KEY });
1457
+
1458
+ expect(res.statusCode).toBe(201);
1459
+ expect(res.json()).toEqual({
1460
+ id: STORED_ID,
1461
+ persisted: "db",
1462
+ email: { skipped: true, reason: "idempotent_replay" },
1463
+ });
1464
+ expect(Object.keys(res.json()).sort()).toEqual([
1465
+ "email",
1466
+ "id",
1467
+ "persisted",
1468
+ ]);
1469
+ expect(prisma.lead.create).not.toHaveBeenCalled();
1470
+ expect(sendLeadNotification).not.toHaveBeenCalled();
1471
+ expect(track).not.toHaveBeenCalled();
1472
+ await expect(fs.access(fileEnv.getCaptureFilePath())).rejects.toThrow();
1473
+ const routed = logLines.find((l) => l.includes("lead routed"));
1474
+ expect(JSON.parse(routed!)).toMatchObject({ replayed: true });
1475
+ keyIsNowhere(res.body);
1476
+ });
1477
+
1478
+ it.each([
1479
+ ["email", { email: "other@example.com" }],
1480
+ ["source", { source: "footer" }],
1481
+ ["payload", { payload: { message: "a different message", name: "Jamie" } }],
1482
+ ])(
1483
+ "case 4 — a stored row with a different %s is 422 idempotency_key_reused, no create, one warn log",
1484
+ async (_field, override) => {
1485
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue({
1486
+ id: STORED_ID,
1487
+ email: body.email,
1488
+ source: body.source,
1489
+ payload: body.payload,
1490
+ ...override,
1491
+ });
1492
+
1493
+ const res = await send({ "Idempotency-Key": KEY });
1494
+
1495
+ expect(res.statusCode).toBe(422);
1496
+ expect(res.json()).toEqual({
1497
+ error: "idempotency_key_reused",
1498
+ message: "This Idempotency-Key was used for a different lead.",
1499
+ });
1500
+ expect(prisma.lead.create).not.toHaveBeenCalled();
1501
+ const warns = logLines
1502
+ .map((l) => JSON.parse(l) as Record<string, unknown>)
1503
+ .filter((l) => l["outcome"] === "idempotency_conflict");
1504
+ expect(warns).toHaveLength(1);
1505
+ expect(warns[0]).toMatchObject({
1506
+ level: 40,
1507
+ kind: "submit",
1508
+ siteId: RESOLVED_WORKSPACE_ID,
1509
+ });
1510
+ keyIsNowhere(res.body);
1511
+ },
1512
+ );
1513
+
1514
+ it.each([
1515
+ ["empty", ""],
1516
+ ["15 characters", "a".repeat(15)],
1517
+ ["256 characters", "a".repeat(256)],
1518
+ ["a space", "abcdefghij klmnopqrs"],
1519
+ ["a non-ASCII character", "abcdefghijéklmnopqrs"],
1520
+ ])(
1521
+ "case 5 — a malformed key (%s) is 400 invalid_input with no lead read or write",
1522
+ async (_label, key) => {
1523
+ const res = await app.inject({
1524
+ method: "POST",
1525
+ url: "/v1/leads/submit",
1526
+ headers: { "idempotency-key": key },
1527
+ payload: body,
1528
+ });
1529
+
1530
+ expect(res.statusCode).toBe(400);
1531
+ expect(res.json()).toEqual({
1532
+ error: "invalid_input",
1533
+ message: "Idempotency-Key must be 16 to 255 visible ASCII characters.",
1534
+ });
1535
+ expect(prisma.lead.findUnique).not.toHaveBeenCalled();
1536
+ expect(prisma.lead.create).not.toHaveBeenCalled();
1537
+ },
1538
+ );
1539
+
1540
+ it("case 6 — the same key stored only in another workspace is a miss here: create, 201 with the new id", async () => {
1541
+ jest.mocked(prisma.lead.findUnique).mockImplementation((async (args: {
1542
+ where: { workspaceId_idempotencyKey?: { workspaceId: string } };
1543
+ }) =>
1544
+ args.where.workspaceId_idempotencyKey?.workspaceId ===
1545
+ FOREIGN_WORKSPACE_ID
1546
+ ? {
1547
+ id: STORED_ID,
1548
+ email: body.email,
1549
+ source: body.source,
1550
+ payload: body.payload,
1551
+ }
1552
+ : null) as never);
1553
+
1554
+ const res = await send({ "Idempotency-Key": KEY });
1555
+
1556
+ expect(res.statusCode).toBe(201);
1557
+ expect(res.json().id).toBe(NEW_ID);
1558
+ expect(prisma.lead.create).toHaveBeenCalledTimes(1);
1559
+ });
1560
+
1561
+ it("case 8 — the key reaches no response body and no log line on a fresh write, a replay or a conflict", async () => {
1562
+ const stored = {
1563
+ id: STORED_ID,
1564
+ email: body.email,
1565
+ source: body.source,
1566
+ payload: body.payload,
1567
+ };
1568
+ (prisma.lead.findUnique as jest.Mock)
1569
+ .mockResolvedValueOnce(null) // fresh write
1570
+ .mockResolvedValueOnce(stored) // replay
1571
+ .mockResolvedValueOnce({ ...stored, email: "other@example.com" }); // conflict
1572
+
1573
+ const statuses: number[] = [];
1574
+ for (let i = 0; i < 3; i++) {
1575
+ const res = await send({ "Idempotency-Key": KEY });
1576
+ statuses.push(res.statusCode);
1577
+ keyIsNowhere(res.body);
1578
+ }
1579
+
1580
+ expect(statuses).toEqual([201, 201, 422]);
1581
+ expect(logLines.length).toBeGreaterThan(0);
1582
+ });
1583
+
1584
+ it("case 7 — subscribe and callback ignore the header", async () => {
1585
+ (prisma.subscriber.create as jest.Mock).mockResolvedValue({
1586
+ id: "subscr_1",
1587
+ });
1588
+ (prisma.callback.create as jest.Mock).mockResolvedValue({ id: "callbk_1" });
1589
+
1590
+ const sub = await send(
1591
+ { "Idempotency-Key": "bad" },
1592
+ "/v1/leads/subscribe",
1593
+ {
1594
+ kind: "subscribe",
1595
+ email: "sub@example.com",
1596
+ source: "footer",
1597
+ },
1598
+ );
1599
+ const cb = await send({ "Idempotency-Key": KEY }, "/v1/leads/callback", {
1600
+ kind: "callback",
1601
+ name: "Alice",
1602
+ phone: "+15551234567",
1603
+ payload: {},
1604
+ });
1605
+
1606
+ expect(sub.statusCode).toBe(201);
1607
+ expect(cb.statusCode).toBe(201);
1608
+ expect(keyLookups()).toHaveLength(0);
1609
+ expect(
1610
+ jest.mocked(prisma.callback.create).mock.calls[0]?.[0].data,
1611
+ ).not.toHaveProperty("idempotencyKey");
1612
+ });
1613
+ });