create-ailk 0.14.0 → 0.16.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 (184) hide show
  1. package/README.md +124 -0
  2. package/component-catalog.json +3 -3
  3. package/dist/add-module.d.ts +7 -0
  4. package/dist/add-module.js +80 -3
  5. package/dist/add-route.js +26 -2
  6. package/dist/adopt.d.ts +74 -0
  7. package/dist/adopt.js +314 -0
  8. package/dist/apply-design.d.ts +12 -1
  9. package/dist/apply-design.js +36 -4
  10. package/dist/cli.js +103 -3
  11. package/dist/derive-architecture.d.ts +37 -2
  12. package/dist/derive-architecture.js +92 -7
  13. package/dist/design-package.js +3 -2
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/lib/chrome-readers.d.ts +21 -0
  17. package/dist/lib/chrome-readers.js +100 -0
  18. package/dist/lib/declared-env.d.ts +142 -0
  19. package/dist/lib/declared-env.js +985 -0
  20. package/dist/lib/env-catalog.d.ts +17 -0
  21. package/dist/lib/env-catalog.js +234 -0
  22. package/dist/lib/extract-module-bundle.d.ts +20 -0
  23. package/dist/lib/extract-module-bundle.js +53 -6
  24. package/dist/lib/license-key-files.d.ts +6 -1
  25. package/dist/lib/license-key-files.js +9 -4
  26. package/dist/lib/module-tier.d.ts +2 -0
  27. package/dist/lib/module-tier.js +4 -0
  28. package/dist/module-architecture.d.ts +9 -0
  29. package/dist/module-architecture.js +82 -0
  30. package/dist/parse-args.d.ts +27 -0
  31. package/dist/parse-args.js +89 -0
  32. package/dist/programmatic.d.ts +28 -0
  33. package/dist/programmatic.js +181 -11
  34. package/dist/seed-flows.d.ts +65 -0
  35. package/dist/seed-flows.js +350 -0
  36. package/dist/site-chrome.d.ts +4 -0
  37. package/dist/site-chrome.js +34 -3
  38. package/dist/site-manifest.d.ts +55 -5
  39. package/dist/site-manifest.js +121 -12
  40. package/dist/site-roster.d.ts +13 -0
  41. package/dist/site-roster.js +22 -8
  42. package/dist/spoke-checkout.d.ts +42 -0
  43. package/dist/spoke-checkout.js +359 -0
  44. package/dist/surfaces.js +7 -0
  45. package/dist/sync-routes.js +29 -5
  46. package/dist/topology.d.ts +246 -0
  47. package/dist/topology.js +942 -0
  48. package/package.json +3 -3
  49. package/site-templates/designs/minimalism/DESIGN.md +3 -3
  50. package/templates/.env.example +10 -0
  51. package/templates/apps/api/CLAUDE.md +21 -4
  52. package/templates/apps/api/package.json +1 -0
  53. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
  54. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
  55. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
  56. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
  57. package/templates/apps/api/src/bin/spokes.ts +62 -1
  58. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
  59. package/templates/apps/api/src/config/index.ts +35 -0
  60. package/templates/apps/api/src/lib/__mocks__/prisma.ts +99 -0
  61. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
  62. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
  63. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
  64. package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
  65. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
  66. package/templates/apps/api/src/lib/agent-store.ts +95 -3
  67. package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
  68. package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
  69. package/templates/apps/api/src/lib/site.ts +37 -0
  70. package/templates/apps/api/src/lib/spoke-store.ts +41 -3
  71. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
  72. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
  73. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
  74. package/templates/apps/api/src/middleware/auth.ts +129 -0
  75. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
  76. package/templates/apps/api/src/openapi/spec.ts +38 -2
  77. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
  78. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
  79. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
  80. package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
  81. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +282 -0
  82. package/templates/apps/api/src/routes/leads/index.ts +69 -6
  83. package/templates/apps/api/src/routes/webhooks/README.md +39 -0
  84. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
  85. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
  86. package/templates/apps/api/src/routes/webhooks/stripe.ts +133 -2
  87. package/templates/apps/api/src/server.ts +105 -0
  88. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
  89. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
  90. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +492 -0
  91. package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
  92. package/templates/apps/api/src/services/default-tenant.ts +41 -7
  93. package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
  94. package/templates/apps/api/src/services/lead-routing.ts +278 -30
  95. package/templates/apps/api/src/services/person.ts +541 -0
  96. package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
  97. package/templates/apps/mcp/__tests__/index.test.ts +5 -5
  98. package/templates/apps/mcp/src/index.ts +6 -4
  99. package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
  100. package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
  101. package/templates/apps/web/.env.example +58 -5
  102. package/templates/apps/web/CLAUDE.md +1 -0
  103. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
  106. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
  107. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
  110. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
  111. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
  112. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
  113. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  114. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  115. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
  116. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
  117. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
  118. package/templates/apps/web/app/[locale]/layout.tsx +83 -18
  119. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
  120. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
  121. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
  122. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
  123. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
  124. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
  125. package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
  126. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
  127. package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
  128. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +104 -1
  129. package/templates/apps/web/app/openapi.json/route.ts +72 -7
  130. package/templates/apps/web/auth.ts +29 -2
  131. package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
  132. package/templates/apps/web/components/signed-in-nav.tsx +17 -3
  133. package/templates/apps/web/instrumentation.ts +7 -4
  134. package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
  135. package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
  136. package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
  137. package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
  138. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
  139. package/templates/apps/web/lib/__tests__/relay.test.ts +317 -0
  140. package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
  141. package/templates/apps/web/lib/account-config.ts +130 -1
  142. package/templates/apps/web/lib/api-origin.generated.ts +11 -0
  143. package/templates/apps/web/lib/closing-footer-path.ts +50 -0
  144. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
  145. package/templates/apps/web/lib/hub-session/config.ts +104 -0
  146. package/templates/apps/web/lib/hub-session/index.ts +23 -0
  147. package/templates/apps/web/lib/hub-session/seal.ts +82 -0
  148. package/templates/apps/web/lib/hub-session/session.ts +180 -0
  149. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
  150. package/templates/apps/web/lib/listings-config.ts +31 -0
  151. package/templates/apps/web/lib/listings.tsx +7 -3
  152. package/templates/apps/web/lib/meeting-slugs.ts +11 -1
  153. package/templates/apps/web/lib/next-path.ts +35 -0
  154. package/templates/apps/web/lib/relay.ts +209 -3
  155. package/templates/apps/web/lib/server-api.ts +11 -1
  156. package/templates/apps/web/lib/site-brand.tsx +22 -1
  157. package/templates/apps/web/lib/site-chrome.ts +3 -0
  158. package/templates/apps/web/lib/topology.generated.ts +33 -0
  159. package/templates/apps/web/messages/en.json +32 -3
  160. package/templates/apps/web/package.json +1 -1
  161. package/templates/database/CHANGELOG.md +51 -0
  162. package/templates/database/__tests__/agents.db.test.ts +85 -0
  163. package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
  164. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
  165. package/templates/database/__tests__/member-area.db.test.ts +245 -0
  166. package/templates/database/__tests__/person.db.test.ts +584 -0
  167. package/templates/database/auth/schema.prisma +24 -0
  168. package/templates/database/config/schema.prisma +13 -0
  169. package/templates/database/inbox/schema.prisma +111 -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/migrations/20261010120000_person/migration.sql +106 -0
  175. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
  176. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
  177. package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
  178. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
  179. package/templates/database/package.json +1 -1
  180. package/templates/database/scripts/person-backfill.sql +132 -0
  181. package/templates/database/tenancy.ts +53 -0
  182. package/templates/tools/check-declared-env.ts +37 -0
  183. package/templates/tools/declared-env.ts +1163 -0
  184. package/versions.json +35 -34
@@ -328,3 +328,108 @@ describe("AC-5 — PII boundary", () => {
328
328
  }
329
329
  });
330
330
  });
331
+
332
+ // ── #852 D6 — the hub checkout's Person (AC-3) ──────────────────────────────
333
+ //
334
+ // After the stamp, `recordCheckoutPerson` reads the checkout row THIS session
335
+ // completed, then its lead, then finds or creates the Person — with
336
+ // `session.customer` as the customer id only on a live-mode event. Observed
337
+ // through the shared mock's delegates: the `person.findFirst` keyed on a
338
+ // customer id is the customer-id step, so its absence means no id was passed.
339
+ describe("the hub checkout's Person (#852 D6, AC-3)", () => {
340
+ const WORKSPACE = "wksp_00000000-0000-7000-8000-000000000852";
341
+ const LEAD = "lead_00000000-0000-7000-8000-000000000852";
342
+ const personFindFirst = () => jest.mocked(prisma.person.findFirst);
343
+ const personCalls = () =>
344
+ [prisma.person.findFirst, prisma.person.createMany, prisma.person.updateMany].reduce(
345
+ (n, fn) => n + jest.mocked(fn).mock.calls.length,
346
+ 0,
347
+ );
348
+ const personLines = () =>
349
+ logCalls.filter(
350
+ (c) => typeof c[0] === "object" && c[0] !== null && String((c[0] as { outcome?: string }).outcome).startsWith("person_"),
351
+ );
352
+ const liveEvent = (livemode: boolean, object: Record<string, unknown> = {}) => ({
353
+ ...sessionEvent("checkout.session.completed", { customer: "cus_9", ...object }),
354
+ livemode,
355
+ });
356
+
357
+ beforeEach(() => {
358
+ (prisma.flowCheckout.findFirst as jest.Mock).mockResolvedValue({ leadId: LEAD, workspaceId: WORKSPACE });
359
+ (prisma.lead.findFirst as jest.Mock).mockResolvedValue({ email: EMAIL });
360
+ });
361
+
362
+ it("a paid live-mode hub session finds the Person of its checkout's lead with session.customer", async () => {
363
+ personFindFirst().mockResolvedValueOnce(null); // no Person carries cus_9 yet
364
+ mockVerifyWebhookSignature.mockReturnValue(liveEvent(true));
365
+ expect((await post()).statusCode).toBe(200);
366
+
367
+ expect(prisma.flowCheckout.findFirst).toHaveBeenCalledWith({
368
+ where: { id: FLOW_CHECKOUT_ID, stripeSessionId: SESSION_ID, status: "completed" },
369
+ select: { leadId: true, workspaceId: true },
370
+ });
371
+ expect(prisma.lead.findFirst).toHaveBeenCalledWith({
372
+ where: { id: LEAD, workspaceId: WORKSPACE },
373
+ select: { email: true },
374
+ });
375
+ expect(personFindFirst()).toHaveBeenCalledWith(
376
+ expect.objectContaining({ where: { workspaceId: WORKSPACE, billingCustomerId: "cus_9" } }),
377
+ );
378
+ expect(personFindFirst()).toHaveBeenCalledWith(
379
+ expect.objectContaining({ where: { workspaceId: WORKSPACE, email: EMAIL } }),
380
+ );
381
+ // The stamp settles before the Person step reads the row it completed.
382
+ expect((prisma.flowCheckout.updateMany as jest.Mock).mock.invocationCallOrder[0]!).toBeLessThan(
383
+ (prisma.flowCheckout.findFirst as jest.Mock).mock.invocationCallOrder[0]!,
384
+ );
385
+ });
386
+
387
+ it("the same session in test mode passes no customer id", async () => {
388
+ mockVerifyWebhookSignature.mockReturnValue(liveEvent(false));
389
+ expect((await post()).statusCode).toBe(200);
390
+
391
+ expect(personFindFirst()).toHaveBeenCalledWith(
392
+ expect.objectContaining({ where: { workspaceId: WORKSPACE, email: EMAIL } }),
393
+ );
394
+ for (const [args] of personFindFirst().mock.calls) {
395
+ expect((args as { where: Record<string, unknown> }).where).not.toHaveProperty("billingCustomerId");
396
+ }
397
+ });
398
+
399
+ it("a session naming a checkout another session completed makes no person call and logs person_skipped", async () => {
400
+ // The read is keyed on THIS session's id, so another session's row is no row.
401
+ (prisma.flowCheckout.findFirst as jest.Mock).mockResolvedValue(null);
402
+ mockVerifyWebhookSignature.mockReturnValue(liveEvent(true));
403
+ expect((await post()).statusCode).toBe(200);
404
+
405
+ expect(personCalls()).toBe(0);
406
+ expect(personLines().map((c) => (c[0] as { outcome: string }).outcome)).toEqual(["person_skipped"]);
407
+ });
408
+
409
+ it.each([
410
+ ["a foreign-site session", { metadata: { flowCheckoutId: FLOW_CHECKOUT_ID, site: "wksp_other-spoke" } }],
411
+ ["an unpaid session", { payment_status: "unpaid" }],
412
+ ["a session with no flowCheckoutId", { metadata: { flowSlug: "ailk-reco" } }],
413
+ ])("%s makes no person call", async (_label, object) => {
414
+ mockVerifyWebhookSignature.mockReturnValue(liveEvent(true, object));
415
+ expect((await post()).statusCode).toBe(200);
416
+ expect(prisma.flowCheckout.findFirst).not.toHaveBeenCalled();
417
+ expect(personCalls()).toBe(0);
418
+ });
419
+
420
+ it("a Person step that throws still answers 200 and logs person_failed with no email or customer id", async () => {
421
+ personFindFirst().mockRejectedValueOnce(new Error(`relation people down for ${EMAIL} cus_9`));
422
+ mockVerifyWebhookSignature.mockReturnValue(liveEvent(true));
423
+ expect((await post()).statusCode).toBe(200);
424
+
425
+ const lines = personLines();
426
+ expect(lines.map((c) => c[0])).toEqual([
427
+ { eventIdPrefix: "evt_test", flowCheckoutIdPrefix: FLOW_CHECKOUT_ID.substring(0, 8), outcome: "person_failed" },
428
+ ]);
429
+ for (const line of logCalls) {
430
+ const text = JSON.stringify(line);
431
+ expect(text).not.toContain(EMAIL);
432
+ expect(text).not.toContain("cus_9");
433
+ }
434
+ });
435
+ });
@@ -0,0 +1,366 @@
1
+ /**
2
+ * POST /api/webhooks/stripe — the hub's site filter (#7868).
3
+ *
4
+ * A Checkout session stamped `metadata.site` for ANOTHER site (a spoke on the
5
+ * shared Stripe account) produces no hub side effect on
6
+ * `checkout.session.completed` or `.expired`, and answers 200. An unstamped
7
+ * session, an empty stamp, and the hub's own stamp are handled exactly as
8
+ * today (roadmap D4). Spec D3/D7, AC-2 … AC-4.
9
+ *
10
+ * The stripe-flow-checkout.test.ts harness: the commerce claim mocked present,
11
+ * the prisma manual mock, `verifyWebhookSignature` mocked at the package
12
+ * boundary, the idempotency store reset per test. The seven existing
13
+ * `stripe*.test.ts` suites are the regression floor and are not edited.
14
+ */
15
+ import type { FastifyInstance } from "fastify";
16
+ import Fastify from "fastify";
17
+
18
+ const mockTrack = jest.fn().mockResolvedValue(undefined);
19
+ jest.mock("@working-theory/license", () => ({
20
+ checkLicense: jest.fn().mockResolvedValue({
21
+ tier: "pro",
22
+ plan: "pro-monthly",
23
+ features: ["commerce"],
24
+ expiresAt: null,
25
+ revoked: false,
26
+ }),
27
+ }));
28
+
29
+ jest.mock("@working-theory/analytics/server", () => ({
30
+ track: (...args: unknown[]) => mockTrack(...args),
31
+ }));
32
+
33
+ // 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.
34
+ jest.mock("../../../lib/prisma.js");
35
+
36
+ const mockVerifyWebhookSignature = jest.fn();
37
+ class MockStripeWebhookSignatureError extends Error {
38
+ constructor(message: string) {
39
+ super(message);
40
+ this.name = "StripeWebhookSignatureError";
41
+ }
42
+ }
43
+ jest.mock("@working-theory/stripe-client", () => ({
44
+ verifyWebhookSignature: (...args: unknown[]) => mockVerifyWebhookSignature(...args),
45
+ StripeWebhookSignatureError: MockStripeWebhookSignatureError,
46
+ assertSandboxKey: jest.fn(),
47
+ createCheckoutSession: jest.fn(),
48
+ getStripeClient: jest.fn(),
49
+ STRIPE_API_VERSION: "2024-06-20",
50
+ StripeConfigError: class StripeConfigError extends Error {},
51
+ UnknownProductCodeError: class UnknownProductCodeError extends Error {},
52
+ WEBHOOK_TOLERANCE_SECONDS: 300,
53
+ }));
54
+
55
+ import * as configModule from "../../../config/index.js";
56
+ import { stripeWebhookHandler } from "../stripe.js";
57
+ import { __resetIdempotencyStore } from "../../../lib/webhook-idempotency.js";
58
+ import { prisma } from "../../../lib/prisma.js";
59
+
60
+ // ── Helpers ──────────────────────────────────────────────────────────────────
61
+
62
+ const SESSION_ID = "cs_test_session_12345678901234567890";
63
+ const CUSTOMER_ID = "cus_test_12345678901234567890";
64
+ const EMAIL = "spoke-buyer@example.com";
65
+ const AMOUNT_TOTAL = 24917;
66
+ const USER_ID = "usr_buyer_7868";
67
+ const ORG_ID = "org_buyer_7868";
68
+
69
+ /**
70
+ * A session that reaches every one of the four `completed` tasks if it is
71
+ * dispatched: telemetry, the billing bind (customer + `ailk_user_id`), the
72
+ * recommender stamp (`flowCheckoutId`) and the request-paid stamp
73
+ * (`payment_link`, paid).
74
+ */
75
+ function sessionEvent(
76
+ type: "checkout.session.completed" | "checkout.session.expired",
77
+ metadata: Record<string, unknown> = {},
78
+ ) {
79
+ return {
80
+ id: "evt_test_12345678abcdef",
81
+ type,
82
+ data: {
83
+ object: {
84
+ id: SESSION_ID,
85
+ payment_status: "paid",
86
+ payment_link: "plink_test_1234567890",
87
+ amount_total: AMOUNT_TOTAL,
88
+ currency: "usd",
89
+ customer: CUSTOMER_ID,
90
+ customer_details: { email: EMAIL },
91
+ metadata: {
92
+ flowCheckoutId: "flchk_test_1",
93
+ flowSlug: "ailk-reco",
94
+ offerKey: "pro",
95
+ ailk_user_id: USER_ID,
96
+ ...metadata,
97
+ },
98
+ },
99
+ },
100
+ };
101
+ }
102
+
103
+ type LogCall = [unknown, string?];
104
+ const logCalls: LogCall[] = [];
105
+
106
+ async function buildTestServer(): Promise<FastifyInstance> {
107
+ const server = Fastify({ logger: false });
108
+ await server.register(async (webhooksScope) => {
109
+ webhooksScope.addContentTypeParser("application/json", { parseAs: "buffer" }, (_req, body, done) => {
110
+ done(null, body);
111
+ });
112
+ webhooksScope.addHook("onRequest", async (request) => {
113
+ for (const level of ["info", "warn", "error"] as const) {
114
+ const original = request.log[level].bind(request.log);
115
+ request.log[level] = ((...args: unknown[]) => {
116
+ logCalls.push(args as LogCall);
117
+ return (original as (...a: unknown[]) => void)(...args);
118
+ }) as typeof request.log.info;
119
+ }
120
+ });
121
+ webhooksScope.post("/v1/webhooks/stripe", stripeWebhookHandler);
122
+ });
123
+ await server.ready();
124
+ return server;
125
+ }
126
+
127
+ const VALID_BODY = Buffer.from(JSON.stringify({ type: "test" }));
128
+ const HEADERS = { "content-type": "application/json", "stripe-signature": "t=1234,v1=abc" };
129
+ const ORIGINAL_AILK_SITE_ID = process.env.AILK_SITE_ID;
130
+
131
+ let server: FastifyInstance;
132
+
133
+ beforeEach(async () => {
134
+ jest.clearAllMocks();
135
+ logCalls.length = 0;
136
+ __resetIdempotencyStore();
137
+ delete process.env.AILK_SITE_ID;
138
+ jest.replaceProperty(configModule, "config", {
139
+ ...configModule.config,
140
+ STRIPE_WEBHOOK_SECRET: "whsec_test_secret",
141
+ });
142
+ (prisma.flowCheckout.findUnique as jest.Mock).mockResolvedValue({ amountMinor: AMOUNT_TOTAL });
143
+ (prisma.flowCheckout.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
144
+ (prisma.callback.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
145
+ (prisma.user.findFirst as jest.Mock).mockResolvedValue(null);
146
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(null);
147
+ (prisma.organization.findFirst as jest.Mock).mockResolvedValue(null);
148
+ (prisma.organization.update as jest.Mock).mockResolvedValue({});
149
+ server = await buildTestServer();
150
+ });
151
+
152
+ afterEach(async () => {
153
+ await server.close();
154
+ jest.restoreAllMocks();
155
+ if (ORIGINAL_AILK_SITE_ID === undefined) delete process.env.AILK_SITE_ID;
156
+ else process.env.AILK_SITE_ID = ORIGINAL_AILK_SITE_ID;
157
+ });
158
+
159
+ async function post() {
160
+ return server.inject({ method: "POST", url: "/v1/webhooks/stripe", payload: VALID_BODY, headers: HEADERS });
161
+ }
162
+
163
+ function foreignSiteLogs() {
164
+ return logCalls.filter(
165
+ ([obj]) => typeof obj === "object" && obj !== null && (obj as Record<string, unknown>)["outcome"] === "foreign_site",
166
+ );
167
+ }
168
+
169
+ function expectNoHubSideEffect() {
170
+ expect(mockTrack).not.toHaveBeenCalled();
171
+ expect(prisma.flowCheckout.findUnique).not.toHaveBeenCalled();
172
+ expect(prisma.flowCheckout.updateMany).not.toHaveBeenCalled();
173
+ expect(prisma.callback.updateMany).not.toHaveBeenCalled();
174
+ expect(prisma.user.findFirst).not.toHaveBeenCalled();
175
+ expect(prisma.user.findUnique).not.toHaveBeenCalled();
176
+ expect(prisma.user.update).not.toHaveBeenCalled();
177
+ expect(prisma.user.updateMany).not.toHaveBeenCalled();
178
+ expect(prisma.organization.findFirst).not.toHaveBeenCalled();
179
+ expect(prisma.organization.update).not.toHaveBeenCalled();
180
+ }
181
+
182
+ function expectHandledAsToday() {
183
+ expect(mockTrack).toHaveBeenCalledTimes(1);
184
+ expect(mockTrack).toHaveBeenCalledWith(
185
+ "purchase-completed",
186
+ expect.objectContaining({ amount: AMOUNT_TOTAL, eventType: "checkout.session.completed" }),
187
+ );
188
+ expect(prisma.flowCheckout.updateMany).toHaveBeenCalledTimes(1);
189
+ expect(prisma.callback.updateMany).toHaveBeenCalledTimes(1);
190
+ expect(prisma.user.findFirst).toHaveBeenCalledWith({
191
+ where: { billingProvider: "stripe", billingCustomerId: CUSTOMER_ID },
192
+ });
193
+ expect(foreignSiteLogs()).toHaveLength(0);
194
+ }
195
+
196
+ // ── Case 1 — a foreign `completed` produces no hub side effect (AC-2) ───────
197
+
198
+ describe("case 1 — a completed session stamped for another site", () => {
199
+ it("answers 200 and runs none of the four tasks", async () => {
200
+ mockVerifyWebhookSignature.mockReturnValue(
201
+ sessionEvent("checkout.session.completed", { site: "samhenry" }),
202
+ );
203
+ const res = await post();
204
+
205
+ expect(res.statusCode).toBe(200);
206
+ expect(JSON.parse(res.body)).toEqual({ received: true });
207
+ expectNoHubSideEffect();
208
+ });
209
+
210
+ it("logs one foreign_site line carrying the stamp and the own site, and no buyer field or amount", async () => {
211
+ mockVerifyWebhookSignature.mockReturnValue(
212
+ sessionEvent("checkout.session.completed", { site: "samhenry" }),
213
+ );
214
+ await post();
215
+
216
+ const lines = foreignSiteLogs();
217
+ expect(lines).toHaveLength(1);
218
+ expect(lines[0]![0]).toEqual({
219
+ eventType: "checkout.session.completed",
220
+ eventIdPrefix: "evt_test",
221
+ outcome: "foreign_site",
222
+ stamp: "samhenry",
223
+ ownSite: "default",
224
+ });
225
+
226
+ const logged = JSON.stringify(logCalls);
227
+ expect(logged).not.toContain(EMAIL);
228
+ expect(logged).not.toContain(String(AMOUNT_TOTAL));
229
+ expect(logged).not.toContain(USER_ID);
230
+ });
231
+
232
+ it("logs exactly the first 64 characters of a long foreign stamp", async () => {
233
+ const longStamp = "s".repeat(36) + "t".repeat(64);
234
+ expect(longStamp).toHaveLength(100);
235
+ mockVerifyWebhookSignature.mockReturnValue(
236
+ sessionEvent("checkout.session.completed", { site: longStamp }),
237
+ );
238
+ const res = await post();
239
+
240
+ expect(res.statusCode).toBe(200);
241
+ expectNoHubSideEffect();
242
+ const lines = foreignSiteLogs();
243
+ expect(lines).toHaveLength(1);
244
+ expect((lines[0]![0] as Record<string, unknown>)["stamp"]).toBe(longStamp.slice(0, 64));
245
+ });
246
+ });
247
+
248
+ // ── Case 2 — a foreign session never reaches the Organization write ─────────
249
+
250
+ describe("case 2 — a foreign session carrying metadata.organizationId", () => {
251
+ it("writes no Organization", async () => {
252
+ mockVerifyWebhookSignature.mockReturnValue(
253
+ sessionEvent("checkout.session.completed", { site: "samhenry", organizationId: ORG_ID }),
254
+ );
255
+ const res = await post();
256
+
257
+ expect(res.statusCode).toBe(200);
258
+ expectNoHubSideEffect();
259
+ });
260
+ });
261
+
262
+ // ── Case 3 — a foreign `expired` stamps no FlowCheckout (AC-3) ──────────────
263
+
264
+ describe("case 3 — an expired session stamped for another site", () => {
265
+ it("answers 200 and never expires a hub FlowCheckout", async () => {
266
+ mockVerifyWebhookSignature.mockReturnValue(
267
+ sessionEvent("checkout.session.expired", { site: "samhenry" }),
268
+ );
269
+ const res = await post();
270
+
271
+ expect(res.statusCode).toBe(200);
272
+ expect(JSON.parse(res.body)).toEqual({ received: true });
273
+ expect(prisma.flowCheckout.updateMany).not.toHaveBeenCalled();
274
+ const lines = foreignSiteLogs();
275
+ expect(lines).toHaveLength(1);
276
+ expect(lines[0]![0]).toMatchObject({
277
+ eventType: "checkout.session.expired",
278
+ stamp: "samhenry",
279
+ ownSite: "default",
280
+ });
281
+ });
282
+ });
283
+
284
+ // ── Cases 4–7 — own and unstamped sessions are handled as today (AC-4) ──────
285
+
286
+ describe("cases 4–7 — an unstamped, own-stamped or empty-stamped session is the hub's own", () => {
287
+ it("case 4 — an unstamped session runs every task, as today", async () => {
288
+ mockVerifyWebhookSignature.mockReturnValue(sessionEvent("checkout.session.completed"));
289
+ const res = await post();
290
+
291
+ expect(res.statusCode).toBe(200);
292
+ expectHandledAsToday();
293
+ });
294
+
295
+ it("case 5 — a session stamped with the default alias, AILK_SITE_ID unset, runs every task", async () => {
296
+ mockVerifyWebhookSignature.mockReturnValue(
297
+ sessionEvent("checkout.session.completed", { site: "default" }),
298
+ );
299
+ const res = await post();
300
+
301
+ expect(res.statusCode).toBe(200);
302
+ expectHandledAsToday();
303
+ });
304
+
305
+ it("case 6 — under AILK_SITE_ID=wksp_hub, a wksp_hub stamp is own", async () => {
306
+ process.env.AILK_SITE_ID = "wksp_hub";
307
+ mockVerifyWebhookSignature.mockReturnValue(
308
+ sessionEvent("checkout.session.completed", { site: "wksp_hub" }),
309
+ );
310
+ const res = await post();
311
+
312
+ expect(res.statusCode).toBe(200);
313
+ expectHandledAsToday();
314
+ });
315
+
316
+ it("case 6 — under AILK_SITE_ID=wksp_hub, a default stamp is foreign", async () => {
317
+ process.env.AILK_SITE_ID = "wksp_hub";
318
+ mockVerifyWebhookSignature.mockReturnValue(
319
+ sessionEvent("checkout.session.completed", { site: "default" }),
320
+ );
321
+ const res = await post();
322
+
323
+ expect(res.statusCode).toBe(200);
324
+ expectNoHubSideEffect();
325
+ const lines = foreignSiteLogs();
326
+ expect(lines).toHaveLength(1);
327
+ expect(lines[0]![0]).toMatchObject({ stamp: "default", ownSite: "wksp_hub" });
328
+ });
329
+
330
+ it("case 7 — an empty stamp is unstamped, and runs every task", async () => {
331
+ mockVerifyWebhookSignature.mockReturnValue(
332
+ sessionEvent("checkout.session.completed", { site: "" }),
333
+ );
334
+ const res = await post();
335
+
336
+ expect(res.statusCode).toBe(200);
337
+ expectHandledAsToday();
338
+ });
339
+ });
340
+
341
+ // ── Case 8 — the event-id dedup is unchanged ────────────────────────────────
342
+
343
+ describe("case 8 — a foreign event delivered twice", () => {
344
+ it("is marked seen, so the redelivery takes the duplicate short-circuit", async () => {
345
+ mockVerifyWebhookSignature.mockReturnValue(
346
+ sessionEvent("checkout.session.completed", { site: "samhenry" }),
347
+ );
348
+ const first = await post();
349
+ expect(first.statusCode).toBe(200);
350
+ expect(foreignSiteLogs()).toHaveLength(1);
351
+
352
+ // Each delivery's lines are read on their own: the harness re-wraps the
353
+ // shared request logger per request, so a second request logs each line
354
+ // more than once.
355
+ logCalls.length = 0;
356
+ const second = await post();
357
+
358
+ expect(second.statusCode).toBe(200);
359
+ expect(JSON.parse(second.body)).toEqual({ received: true });
360
+ expect(foreignSiteLogs()).toHaveLength(0);
361
+ expect(logCalls.map(([, msg]) => msg)).toContain(
362
+ "duplicate stripe webhook event — idempotent short-circuit",
363
+ );
364
+ expectNoHubSideEffect();
365
+ });
366
+ });
@@ -31,6 +31,15 @@
31
31
  * Successful dispatch always 200s — including unknown event types.
32
32
  * Telemetry failures are caught, logged, and do NOT change the response.
33
33
  *
34
+ * Site filter (#7868):
35
+ * The Stripe account is shared with spoke sites, so this endpoint receives
36
+ * their Checkout sessions too. A `checkout.session.completed` or `.expired`
37
+ * whose `metadata.site` is a non-empty string naming another site
38
+ * (`isForeignSiteStamp`, lib/site.ts) takes no action: it logs
39
+ * `outcome: "foreign_site"` and answers 200. An unstamped session is the
40
+ * hub's own (roadmap D4) and is handled as before. The hub stamps its own
41
+ * sessions through `siteStamp()`. See README.md § Site filter (#7868).
42
+ *
34
43
  * Billing upsert (customer.created / checkout.session.completed):
35
44
  * Billing-entity routing (org-scoped billing, #3601, spec D2): a checkout
36
45
  * carrying metadata.organizationId links the Organization (multi-tenant
@@ -61,6 +70,12 @@
61
70
  * `"no_payment_required"` (a 100%-off promo code, #4862) — anything else
62
71
  * logs `outcome: "unpaid"` and leaves the row `created`.
63
72
  *
73
+ * Checkout Person (checkout.session.completed, #852 D6):
74
+ * Chained after that stamp, `recordFlowCheckoutPerson` finds or creates the
75
+ * Person of the checkout row THIS session completed (matched on its own
76
+ * `stripeSessionId`), with `session.customer` as the customer id only on a
77
+ * live-mode event. Logs an outcome kind only; never fails the 200.
78
+ *
64
79
  * Dunning (invoice.payment_failed / customer.subscription.updated, #6034):
65
80
  * Records a per-subscriber lifecycle state in a `UserData` row behind a
66
81
  * reserved-key guard (routes/auth/user.ts). `invoice.payment_failed` arms
@@ -83,7 +98,11 @@ import {
83
98
  LICENSE_UNDETERMINED_RETRY_AFTER_SECONDS,
84
99
  LICENSE_UNDETERMINED_STATUS,
85
100
  } from "../../lib/require-commerce-claim.js";
86
- import { currentSiteId } from "../../lib/site.js";
101
+ import {
102
+ currentSiteId,
103
+ isForeignSiteStamp,
104
+ SITE_STAMP_KEY,
105
+ } from "../../lib/site.js";
87
106
  import {
88
107
  StripeWebhookSignatureError,
89
108
  toSubscriptionLifecycleStatus,
@@ -98,6 +117,10 @@ import {
98
117
  type DunningEntry,
99
118
  type DunningRecord,
100
119
  } from "../../services/dunning-policy.js";
120
+ import {
121
+ recordCheckoutPerson,
122
+ type CheckoutPersonOutcome,
123
+ } from "../../services/person.js";
101
124
 
102
125
  const TELEMETRY_TIMEOUT_MS = 2000;
103
126
  const DUNNING_WRITE_MAX_ATTEMPTS = 3;
@@ -299,6 +322,14 @@ export const stripeWebhookHandler = async (
299
322
  }
300
323
  case "checkout.session.completed": {
301
324
  const session = event.data.object as CheckoutSessionObject;
325
+ if (isForeignSiteStamp(session.metadata)) {
326
+ logForeignSiteSkip(request, {
327
+ stamp: session.metadata?.[SITE_STAMP_KEY],
328
+ eventType: event.type,
329
+ eventIdPrefix,
330
+ });
331
+ break;
332
+ }
302
333
  const props = {
303
334
  amount: session.amount_total ?? undefined,
304
335
  currency: session.currency ?? undefined,
@@ -338,11 +369,19 @@ export const stripeWebhookHandler = async (
338
369
  await Promise.all([
339
370
  fireTelemetryWithTimeout(request, "purchase-completed", props),
340
371
  billingTask,
372
+ // #852 D6 — the Person step runs only after the stamp has settled,
373
+ // so it reads the row this very session completed.
341
374
  stampFlowCheckout(request, {
342
375
  session,
343
376
  eventIdPrefix,
344
377
  outcome: "completed",
345
- }),
378
+ }).then(() =>
379
+ recordFlowCheckoutPerson(request, {
380
+ session,
381
+ livemode: event.livemode === true,
382
+ eventIdPrefix,
383
+ }),
384
+ ),
346
385
  stampRequestPaid(request, { session, eventIdPrefix }),
347
386
  ]);
348
387
  break;
@@ -363,6 +402,14 @@ export const stripeWebhookHandler = async (
363
402
  }
364
403
  case "checkout.session.expired": {
365
404
  const session = event.data.object as CheckoutSessionObject;
405
+ if (isForeignSiteStamp(session.metadata)) {
406
+ logForeignSiteSkip(request, {
407
+ stamp: session.metadata?.[SITE_STAMP_KEY],
408
+ eventType: event.type,
409
+ eventIdPrefix,
410
+ });
411
+ break;
412
+ }
366
413
  if (!session.metadata?.["flowCheckoutId"]) {
367
414
  // Not a recommender session — the same low-noise line the default
368
415
  // arm emitted for this event before #4838 handled it.
@@ -432,6 +479,37 @@ type CheckoutSessionObject = {
432
479
  metadata?: Record<string, string> | null;
433
480
  };
434
481
 
482
+ /**
483
+ * The one log line for a session another site stamped (#7868 D3). Carries the
484
+ * foreign stamp (first 64 characters) and this site's own alias, so a spoke
485
+ * event correctly skipped can be told from a hub event whose alias drifted —
486
+ * site labels only: it takes the stamp, never the session, so no buyer field
487
+ * or amount can reach it.
488
+ */
489
+ function logForeignSiteSkip(
490
+ request: FastifyRequest,
491
+ {
492
+ stamp,
493
+ eventType,
494
+ eventIdPrefix,
495
+ }: {
496
+ stamp: string | undefined;
497
+ eventType: string;
498
+ eventIdPrefix: string;
499
+ },
500
+ ): void {
501
+ request.log.info(
502
+ {
503
+ eventType,
504
+ eventIdPrefix,
505
+ outcome: "foreign_site",
506
+ stamp: stamp?.slice(0, 64),
507
+ ownSite: currentSiteId(),
508
+ },
509
+ "checkout session stamped for another site — skipped",
510
+ );
511
+ }
512
+
435
513
  /**
436
514
  * Minimal projected shape of a Stripe Invoice object for the dunning arms
437
515
  * (#6034 FR-1). `subscription` and `customer` are EXPANDABLE Stripe fields —
@@ -563,6 +641,59 @@ async function stampFlowCheckout(
563
641
  }
564
642
  }
565
643
 
644
+ /**
645
+ * The hub checkout's Person (#852 D6): after `stampFlowCheckout`, find or
646
+ * create the Person of the completed checkout's lead
647
+ * (`services/person.ts`'s `recordCheckoutPerson`). Runs only for a paid (or
648
+ * nothing-due) session of THIS site — the foreign-site filter has already
649
+ * returned — that carries `metadata.flowCheckoutId`. The customer id's ONE
650
+ * source is `session.customer`, written onto the Person only for a
651
+ * live-mode event (SEC-852-A). A replay matches the same completed row, so a
652
+ * delivery whose Person step failed is repaired by the next one.
653
+ *
654
+ * Logs `{ eventIdPrefix, flowCheckoutIdPrefix, outcome }` only — never the
655
+ * email or the customer id. Never throws: the response stays 200.
656
+ */
657
+ async function recordFlowCheckoutPerson(
658
+ request: FastifyRequest,
659
+ {
660
+ session,
661
+ livemode,
662
+ eventIdPrefix,
663
+ }: {
664
+ session: CheckoutSessionObject;
665
+ livemode: boolean;
666
+ eventIdPrefix: string;
667
+ },
668
+ ): Promise<void> {
669
+ const flowCheckoutId = session.metadata?.["flowCheckoutId"];
670
+ if (!flowCheckoutId) return;
671
+ if (
672
+ session.payment_status !== "paid" &&
673
+ session.payment_status !== "no_payment_required"
674
+ ) {
675
+ return;
676
+ }
677
+ const flowCheckoutIdPrefix = flowCheckoutId.substring(0, 8);
678
+ let outcome: CheckoutPersonOutcome;
679
+ try {
680
+ outcome = await recordCheckoutPerson({
681
+ flowCheckoutId,
682
+ stripeSessionId: session.id,
683
+ customer: session.customer,
684
+ livemode,
685
+ });
686
+ } catch {
687
+ outcome = "person_failed";
688
+ }
689
+ const line = { eventIdPrefix, flowCheckoutIdPrefix, outcome };
690
+ if (outcome === "person_failed") {
691
+ request.log.error(line, "flow checkout person failed — continuing");
692
+ } else {
693
+ request.log.info(line, "flow checkout person");
694
+ }
695
+ }
696
+
566
697
  /**
567
698
  * Stamp `paidAt` on a request-and-confirm callback row (#5970 AC-3).
568
699
  *