create-ailk 0.20.0 → 0.22.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 (222) hide show
  1. package/README.md +21 -1
  2. package/component-catalog.json +220 -8
  3. package/component-catalog.md +21 -9
  4. package/dist/add-module.js +9 -5
  5. package/dist/apply-design.d.ts +9 -0
  6. package/dist/apply-design.js +78 -31
  7. package/dist/derive-architecture.d.ts +8 -1
  8. package/dist/derive-architecture.js +38 -3
  9. package/dist/design-home.d.ts +54 -11
  10. package/dist/design-home.js +160 -22
  11. package/dist/design-package.js +7 -2
  12. package/dist/lib/design-checks.d.ts +6 -1
  13. package/dist/lib/design-checks.js +23 -2
  14. package/dist/lib/env-catalog.js +5 -1
  15. package/dist/module-architecture.js +4 -2
  16. package/dist/programmatic.js +63 -25
  17. package/dist/site-chrome.d.ts +8 -0
  18. package/dist/site-chrome.js +17 -1
  19. package/dist/site-roster.d.ts +4 -2
  20. package/dist/site-roster.js +7 -5
  21. package/dist/spoke-checkout.d.ts +39 -18
  22. package/dist/spoke-checkout.js +177 -236
  23. package/dist/starter-grid.js +16 -0
  24. package/dist/surfaces.js +30 -14
  25. package/dist/sync-routes.d.ts +28 -0
  26. package/dist/sync-routes.js +124 -1
  27. package/dist/template-record.d.ts +4 -3
  28. package/dist/template-record.js +6 -3
  29. package/dist/topology.js +25 -1
  30. package/dist/update.d.ts +6 -1
  31. package/dist/update.js +125 -18
  32. package/package.json +3 -3
  33. package/site-templates/_base/ailk-template.json +2 -2
  34. package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
  35. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  36. package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
  37. package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
  38. package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
  39. package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
  40. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
  41. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
  42. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
  43. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
  44. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
  45. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
  46. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
  47. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
  48. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
  49. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
  50. package/templates/README.md +26 -25
  51. package/templates/apps/api/CLAUDE.md +20 -7
  52. package/templates/apps/api/jest.config.cjs +1 -0
  53. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
  54. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
  55. package/templates/apps/api/src/lib/consent-store.ts +28 -0
  56. package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
  57. package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
  58. package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
  59. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
  60. package/templates/apps/api/src/middleware/auth.ts +13 -6
  61. package/templates/apps/api/src/openapi/spec.ts +30 -3
  62. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
  63. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
  64. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
  65. package/templates/apps/api/src/routes/consent/account.ts +266 -0
  66. package/templates/apps/api/src/routes/consent/index.ts +15 -7
  67. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
  68. package/templates/apps/api/src/routes/content/get-page.ts +4 -0
  69. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
  70. package/templates/apps/api/src/routes/leads/index.ts +161 -7
  71. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
  72. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
  73. package/templates/apps/api/src/routes/webhooks/stripe.ts +154 -19
  74. package/templates/apps/api/src/server.ts +28 -0
  75. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1038 -118
  76. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
  77. package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
  78. package/templates/apps/api/src/services/consent.ts +95 -0
  79. package/templates/apps/api/src/services/lead-routing.ts +337 -8
  80. package/templates/apps/api/src/services/magnet-completion.ts +242 -0
  81. package/templates/apps/api/src/services/person.ts +40 -8
  82. package/templates/apps/api/src/services/plan-offerings.ts +59 -0
  83. package/templates/apps/api/src/services/project-listings.ts +6 -1
  84. package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
  85. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +69 -61
  86. package/templates/apps/mcp/jest.config.cjs +1 -0
  87. package/templates/apps/web/.env.example +14 -0
  88. package/templates/apps/web/CLAUDE.md +139 -3
  89. package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
  90. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
  91. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
  92. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
  93. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
  94. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
  95. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
  96. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
  97. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
  98. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
  99. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
  100. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
  101. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
  102. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
  103. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
  104. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
  105. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
  106. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
  107. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
  114. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
  115. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
  116. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
  117. package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
  119. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
  120. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
  121. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
  122. package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
  123. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
  124. package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
  125. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +293 -0
  126. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +115 -0
  127. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +146 -1
  128. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +310 -0
  129. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +143 -0
  130. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +350 -0
  131. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +29 -3
  132. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  133. package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
  134. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
  135. package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
  136. package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
  137. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  138. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +24 -1
  139. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
  140. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
  141. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
  142. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +386 -0
  143. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +504 -24
  144. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +12 -3
  145. package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
  146. package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
  147. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +184 -41
  148. package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
  149. package/templates/apps/web/app/[locale]/contact/page.tsx +120 -16
  150. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  151. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +1 -1
  152. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
  153. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +106 -1
  154. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
  155. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
  156. package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
  157. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
  158. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  159. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  160. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  161. package/templates/apps/web/app/[locale]/page.tsx +13 -1
  162. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  163. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  164. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
  165. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  166. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  167. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  168. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  169. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
  170. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
  171. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
  172. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
  173. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  174. package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
  175. package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
  176. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
  177. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
  178. package/templates/apps/web/app/api/checkout/[offering]/route.ts +48 -0
  179. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +120 -0
  180. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +86 -0
  181. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
  182. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
  183. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +92 -0
  184. package/templates/apps/web/app/api/webhooks/stripe/route.ts +31 -0
  185. package/templates/apps/web/auth.ts +28 -2
  186. package/templates/apps/web/jest.config.cjs +1 -0
  187. package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
  188. package/templates/apps/web/lib/__tests__/checkout.test.ts +243 -0
  189. package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
  190. package/templates/apps/web/lib/__tests__/deck-page.test.ts +249 -0
  191. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +38 -19
  192. package/templates/apps/web/lib/account-invite.ts +37 -0
  193. package/templates/apps/web/lib/checkout-config.ts +27 -0
  194. package/templates/apps/web/lib/checkout.ts +93 -0
  195. package/templates/apps/web/lib/consent-policies.ts +22 -0
  196. package/templates/apps/web/lib/deck-page.ts +192 -0
  197. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
  198. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
  199. package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
  200. package/templates/apps/web/lib/sign-up-agreements.ts +447 -0
  201. package/templates/apps/web/lib/site-chrome.ts +2 -0
  202. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  203. package/templates/apps/web/messages/en.json +40 -12
  204. package/templates/apps/web/package.json +1 -0
  205. package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
  206. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
  207. package/templates/apps/web/src/lib/lead-write.ts +90 -0
  208. package/templates/apps/web/src/lib/rate-limit.ts +79 -0
  209. package/templates/database/CHANGELOG.md +47 -0
  210. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
  211. package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
  212. package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
  213. package/templates/database/__tests__/seed.test.ts +72 -2
  214. package/templates/database/inbox/schema.prisma +17 -2
  215. package/templates/database/jest.config.cjs +1 -0
  216. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
  217. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
  218. package/templates/database/package.json +1 -1
  219. package/templates/database/seed.ts +60 -0
  220. package/templates/tools/jest/pin-timezone.cjs +34 -0
  221. package/versions.json +34 -34
  222. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
@@ -0,0 +1,649 @@
1
+ /**
2
+ * POST /v1/webhooks/stripe — the buyer's terms grant (#8765 D13, D14; spec
3
+ * docs/specs/8765-sign-up-refuses-without-the-spec.md AC-8).
4
+ *
5
+ * A `checkout.session.completed` whose Stripe terms box was accepted, and
6
+ * whose session carries the server-written `metadata.ailk_terms` stamp at the
7
+ * current `account_terms` version, writes ONE `checkout` grant for the
8
+ * buyer's Person. Absent consent, a missing or stale stamp, and an
9
+ * unresolvable buyer each log one `checkout-consent` line and write nothing;
10
+ * the order always completes (200).
11
+ *
12
+ * Harness: the signed-event harness of `stripe-livemode-live.test.ts` (the
13
+ * commerce claim present, `stripeKeyMode` controlled per test, the request
14
+ * logger captured), over the manual Prisma mock armed with
15
+ * `consent-ledger-double.ts` so the REAL `services/consent.ts` and
16
+ * `lib/consent-store.ts` run. The Person table is a small in-test store
17
+ * honouring `(workspaceId, email)`; the replay read is answered from the
18
+ * ledger double's rows. A proposal's Person is the mock's `piece.findFirst`
19
+ * answer, and a flow checkout's lead the `flowCheckout` / `lead` reads.
20
+ */
21
+ import type { FastifyInstance } from "fastify";
22
+ import Fastify from "fastify";
23
+
24
+ jest.mock("@working-theory/license", () => ({
25
+ checkLicense: jest.fn().mockResolvedValue({
26
+ tier: "pro",
27
+ plan: "pro-monthly",
28
+ features: ["commerce"],
29
+ expiresAt: null,
30
+ revoked: false,
31
+ }),
32
+ }));
33
+
34
+ jest.mock("@working-theory/analytics/server", () => ({
35
+ track: jest.fn().mockResolvedValue(undefined),
36
+ }));
37
+
38
+ // 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.
39
+ jest.mock("../../../lib/prisma.js");
40
+
41
+ const mockVerifyWebhookSignature = jest.fn();
42
+ jest.mock("@working-theory/stripe-client", () => ({
43
+ ...jest.requireActual("@working-theory/stripe-client"),
44
+ verifyWebhookSignature: (...args: unknown[]) =>
45
+ mockVerifyWebhookSignature(...args),
46
+ }));
47
+
48
+ let mockKeyMode: "live" | "test" = "live";
49
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
50
+ jest.mock("../../../lib/stripe.js", () => ({
51
+ ...jest.requireActual("../../../lib/stripe.js"),
52
+ stripeKeyMode: () => mockKeyMode,
53
+ }));
54
+
55
+ const SITE_WS = "wksp_0190f3a2-7c1d-7e4a-9b2c-0000000000a1";
56
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
57
+ jest.mock("../../../lib/site.js", () => ({
58
+ ...jest.requireActual("../../../lib/site.js"),
59
+ resolveSiteWorkspaceId: jest.fn(async () => SITE_WS),
60
+ }));
61
+
62
+ import {
63
+ ACCOUNT_TERMS_PURPOSE_ID,
64
+ consentPurposeById,
65
+ resolveConsentStatement,
66
+ SITE_CONSENT_CONFIG_DEFAULTS,
67
+ } from "@working-theory/validation";
68
+
69
+ import * as configModule from "../../../config/index.js";
70
+ import {
71
+ armConsentLedger,
72
+ type ConsentLedgerDouble,
73
+ } from "../../../lib/__tests__/consent-ledger-double.js";
74
+ import { prisma } from "../../../lib/prisma.js";
75
+ import { recordCheckoutConsent } from "../../../services/consent.js";
76
+ import { __resetIdempotencyStore } from "../../../lib/webhook-idempotency.js";
77
+ import { stripeWebhookHandler } from "../stripe.js";
78
+
79
+ const ACCOUNT_TERMS = consentPurposeById(
80
+ SITE_CONSENT_CONFIG_DEFAULTS,
81
+ ACCOUNT_TERMS_PURPOSE_ID,
82
+ )!;
83
+ const VERSION = ACCOUNT_TERMS.policyVersion;
84
+ const STAMP = `${ACCOUNT_TERMS_PURPOSE_ID}@${VERSION}`;
85
+
86
+ const PROPOSAL_WS = "wksp_0190f3a2-7c1d-7e4a-9b2c-0000000000a2";
87
+ const PIECE_ID = "piece_0190f3a2-7c1d-7e4a-9b2c-0000000000b1";
88
+ const SESSION_ID = "cs_live_a1b2c3d4e5f6";
89
+ const PAYER_EMAIL = "b@x.example";
90
+ const U1_EMAIL = "u1@verified.example";
91
+
92
+ // ── The Person store ──────────────────────────────────────────────────────
93
+
94
+ type PersonRow = {
95
+ id: string;
96
+ workspaceId: string;
97
+ email: string;
98
+ name: string | null;
99
+ userId: string | null;
100
+ billingCustomerId: string | null;
101
+ };
102
+ let people: PersonRow[] = [];
103
+ let personSeq = 0;
104
+
105
+ function nextPersonId(): string {
106
+ personSeq += 1;
107
+ return `person_0190f3a2-7c1d-7e4a-9b2c-${String(personSeq).padStart(12, "0")}`;
108
+ }
109
+
110
+ function matches(row: Record<string, unknown>, where: Record<string, unknown>) {
111
+ return Object.entries(where).every(([k, v]) => row[k] === v);
112
+ }
113
+
114
+ function armPeople(): void {
115
+ (prisma.person.findFirst as jest.Mock).mockImplementation(
116
+ async ({ where }: { where: Record<string, unknown> }) =>
117
+ people.find((p) => matches(p, where)) ?? null,
118
+ );
119
+ (prisma.person.createMany as jest.Mock).mockImplementation(
120
+ async ({ data }: { data: Array<Partial<PersonRow>> }) => {
121
+ let count = 0;
122
+ for (const d of data) {
123
+ if (
124
+ people.some(
125
+ (p) => p.workspaceId === d.workspaceId && p.email === d.email,
126
+ )
127
+ ) {
128
+ continue;
129
+ }
130
+ people.push({
131
+ id: nextPersonId(),
132
+ workspaceId: d.workspaceId!,
133
+ email: d.email!,
134
+ name: d.name ?? null,
135
+ userId: null,
136
+ billingCustomerId: null,
137
+ });
138
+ count += 1;
139
+ }
140
+ return { count };
141
+ },
142
+ );
143
+ (prisma.person.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
144
+ }
145
+
146
+ // ── The ledger, plus the replay read the double does not model ────────────
147
+
148
+ let double: ConsentLedgerDouble;
149
+
150
+ function armLedger(): void {
151
+ double = armConsentLedger(prisma);
152
+ const ledgerRead = (prisma.$queryRaw as jest.Mock).getMockImplementation()!;
153
+ (prisma.$queryRaw as jest.Mock).mockImplementation(
154
+ (strings: TemplateStringsArray, ...values: unknown[]) => {
155
+ const sql = strings.join("?");
156
+ // recordCheckoutConsent's replay read: this Person's checkout row of
157
+ // this session, filtered by (workspace, person, purpose) first.
158
+ if (sql.includes(`"source" = 'checkout'`)) {
159
+ const [workspaceId, personId, purposeId, sessionId] =
160
+ values as string[];
161
+ return Promise.resolve(
162
+ double.ledger
163
+ .filter(
164
+ (r) =>
165
+ r.workspaceId === workspaceId &&
166
+ r.personId === personId &&
167
+ r.purposeId === purposeId &&
168
+ r.source === "checkout" &&
169
+ r.sourceRef === sessionId,
170
+ )
171
+ .map(() => ({ found: 1 })),
172
+ );
173
+ }
174
+ return ledgerRead(strings, ...values);
175
+ },
176
+ );
177
+ }
178
+
179
+ // ── The request log ───────────────────────────────────────────────────────
180
+
181
+ type LogCall = [unknown, string?];
182
+ const logCalls: LogCall[] = [];
183
+
184
+ function consentLines(): Array<Record<string, unknown>> {
185
+ return logCalls
186
+ .map(([obj]) => obj)
187
+ .filter(
188
+ (obj): obj is Record<string, unknown> =>
189
+ typeof obj === "object" &&
190
+ obj !== null &&
191
+ (obj as Record<string, unknown>)["event"] === "checkout-consent",
192
+ );
193
+ }
194
+
195
+ async function buildTestServer(): Promise<FastifyInstance> {
196
+ const server = Fastify({ logger: false });
197
+ await server.register(async (webhooksScope) => {
198
+ webhooksScope.addContentTypeParser(
199
+ "application/json",
200
+ { parseAs: "buffer" },
201
+ (_req, body, done) => {
202
+ done(null, body);
203
+ },
204
+ );
205
+ // With `logger: false` every request shares one logger object, so it is
206
+ // wrapped once, not once per request (which would repeat each line).
207
+ const wrapped = new WeakSet<object>();
208
+ webhooksScope.addHook("onRequest", async (request) => {
209
+ if (wrapped.has(request.log)) return;
210
+ wrapped.add(request.log);
211
+ for (const level of ["info", "warn", "error"] as const) {
212
+ const original = request.log[level].bind(request.log);
213
+ request.log[level] = ((...args: unknown[]) => {
214
+ logCalls.push(args as LogCall);
215
+ return (original as (...a: unknown[]) => void)(...args);
216
+ }) as typeof request.log.info;
217
+ }
218
+ });
219
+ webhooksScope.post("/v1/webhooks/stripe", stripeWebhookHandler);
220
+ });
221
+ await server.ready();
222
+ return server;
223
+ }
224
+
225
+ // ── Events ────────────────────────────────────────────────────────────────
226
+
227
+ function completed(
228
+ eventId: string,
229
+ session: Record<string, unknown> = {},
230
+ livemode = true,
231
+ ): Record<string, unknown> {
232
+ return {
233
+ id: eventId,
234
+ type: "checkout.session.completed",
235
+ livemode,
236
+ data: {
237
+ object: {
238
+ id: SESSION_ID,
239
+ payment_status: "paid",
240
+ amount_total: 4900,
241
+ currency: "usd",
242
+ payment_link: null,
243
+ consent: { terms_of_service: "accepted" },
244
+ customer_details: { email: PAYER_EMAIL },
245
+ metadata: { ailk_terms: STAMP },
246
+ ...session,
247
+ },
248
+ },
249
+ };
250
+ }
251
+
252
+ /** The manual mock, seen as the host of the `auditLog` delegate it lacks. */
253
+ function auditHost(): { auditLog: { create: jest.Mock } } {
254
+ // eslint-disable-next-line no-restricted-syntax -- the manual Prisma mock declares no auditLog delegate; this suite attaches one so the real audit writer runs
255
+ return prisma as unknown as { auditLog: { create: jest.Mock } };
256
+ }
257
+
258
+ let server: FastifyInstance;
259
+
260
+ beforeEach(async () => {
261
+ jest.clearAllMocks();
262
+ logCalls.length = 0;
263
+ people = [];
264
+ personSeq = 0;
265
+ mockKeyMode = "live";
266
+ __resetIdempotencyStore();
267
+ jest.replaceProperty(configModule, "config", {
268
+ ...configModule.config,
269
+ STRIPE_WEBHOOK_SECRET: "whsec_test_secret",
270
+ });
271
+ armPeople();
272
+ armLedger();
273
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(null);
274
+ (prisma.user.findFirst as jest.Mock).mockResolvedValue(null);
275
+ (prisma.piece.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
276
+ // No proposal Person and no completed flow checkout unless a test arranges
277
+ // one (clearAllMocks keeps a previous test's implementation).
278
+ (prisma.piece.findFirst as jest.Mock).mockResolvedValue(null);
279
+ (prisma.flowCheckout.findFirst as jest.Mock).mockResolvedValue(null);
280
+ (prisma.lead.findFirst as jest.Mock).mockResolvedValue(null);
281
+ // The proposal stamp's audit writer (`services/audit-log.ts`) writes through
282
+ // `auditLog.create`, which the manual mock does not carry.
283
+ auditHost().auditLog = { create: jest.fn().mockResolvedValue({}) };
284
+ server = await buildTestServer();
285
+ });
286
+
287
+ afterEach(async () => {
288
+ await server.close();
289
+ });
290
+
291
+ async function post(event: Record<string, unknown>) {
292
+ mockVerifyWebhookSignature.mockReturnValue(event);
293
+ return server.inject({
294
+ method: "POST",
295
+ url: "/v1/webhooks/stripe",
296
+ payload: Buffer.from(JSON.stringify({ type: "test" })),
297
+ headers: {
298
+ "content-type": "application/json",
299
+ "stripe-signature": "t=1234,v1=abc",
300
+ },
301
+ });
302
+ }
303
+
304
+ function personOf(workspaceId: string, email: string): PersonRow | undefined {
305
+ return people.find((p) => p.workspaceId === workspaceId && p.email === email);
306
+ }
307
+
308
+ // ── A standard session ────────────────────────────────────────────────────
309
+
310
+ describe("a standard live session whose terms box was accepted", () => {
311
+ it("writes one dated checkout grant for the payer's Person in the site workspace", async () => {
312
+ const res = await post(completed("evt_consent_standard"));
313
+ expect(res.statusCode).toBe(200);
314
+ expect(res.json()).toEqual({ received: true });
315
+
316
+ const person = personOf(SITE_WS, PAYER_EMAIL);
317
+ expect(person).toBeDefined();
318
+ expect(double.ledger).toHaveLength(1);
319
+ const { statement } = resolveConsentStatement(ACCOUNT_TERMS, "en");
320
+ expect(double.ledger[0]).toMatchObject({
321
+ workspaceId: SITE_WS,
322
+ subjectType: "person",
323
+ subjectId: person!.id,
324
+ personId: person!.id,
325
+ purposeId: "account_terms",
326
+ granted: true,
327
+ source: "checkout",
328
+ sourceRef: SESSION_ID,
329
+ policyVersion: VERSION,
330
+ locale: "en",
331
+ statement,
332
+ });
333
+ expect(VERSION).toBe(2);
334
+ expect(double.ledger[0]!.createdAt).toBeInstanceOf(Date);
335
+
336
+ // One transaction whose first statement is the session's advisory lock.
337
+ expect(prisma.$transaction).toHaveBeenCalledTimes(1);
338
+ expect(prisma.$executeRaw).toHaveBeenCalledTimes(1);
339
+ const [, lockKey] = (prisma.$executeRaw as jest.Mock).mock.calls[0] as [
340
+ TemplateStringsArray,
341
+ string,
342
+ ];
343
+ expect(lockKey).toBe(`ailk:consent:${SITE_WS}:checkout:${SESSION_ID}`);
344
+
345
+ expect(consentLines()).toEqual([
346
+ {
347
+ event: "checkout-consent",
348
+ outcome: "recorded",
349
+ eventIdPrefix: "evt_cons",
350
+ },
351
+ ]);
352
+ });
353
+
354
+ it("the same event replayed — in-process and after a restart — still leaves one row", async () => {
355
+ const event = completed("evt_consent_replay");
356
+ await post(event);
357
+ expect(double.ledger).toHaveLength(1);
358
+
359
+ // In-process: the idempotency store short-circuits.
360
+ expect((await post(event)).statusCode).toBe(200);
361
+ expect(double.ledger).toHaveLength(1);
362
+
363
+ // After a restart (the in-memory store cleared): the ledger read refuses.
364
+ __resetIdempotencyStore();
365
+ logCalls.length = 0;
366
+ expect((await post(event)).statusCode).toBe(200);
367
+ expect(double.ledger).toHaveLength(1);
368
+ expect(consentLines()).toEqual([
369
+ {
370
+ event: "checkout-consent",
371
+ outcome: "unchanged",
372
+ eventIdPrefix: "evt_cons",
373
+ },
374
+ ]);
375
+ });
376
+
377
+ it("a signed-in buyer's session (metadata ailk_user_id u1) names the Person of u1's verified email", async () => {
378
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue({
379
+ id: "u1",
380
+ email: "U1@Verified.example",
381
+ emailVerified: true,
382
+ billingProvider: null,
383
+ billingCustomerId: null,
384
+ });
385
+ const res = await post(
386
+ completed("evt_consent_buyer", {
387
+ metadata: { ailk_terms: STAMP, ailk_user_id: "u1" },
388
+ }),
389
+ );
390
+ expect(res.statusCode).toBe(200);
391
+
392
+ const buyer = personOf(SITE_WS, U1_EMAIL);
393
+ expect(buyer).toBeDefined();
394
+ expect(personOf(SITE_WS, PAYER_EMAIL)).toBeUndefined();
395
+ expect(double.ledger).toHaveLength(1);
396
+ expect(double.ledger[0]).toMatchObject({
397
+ personId: buyer!.id,
398
+ source: "checkout",
399
+ });
400
+ });
401
+
402
+ it("a test-mode standard session with an email and no buyer writes no row and creates no Person", async () => {
403
+ mockKeyMode = "test";
404
+ const res = await post(completed("evt_consent_testmode", {}, false));
405
+ expect(res.statusCode).toBe(200);
406
+ expect(double.ledger).toHaveLength(0);
407
+ expect(people).toHaveLength(0);
408
+ expect(prisma.person.createMany).not.toHaveBeenCalled();
409
+ expect(consentLines()).toEqual([
410
+ {
411
+ event: "checkout-consent",
412
+ outcome: "person_unresolved",
413
+ eventIdPrefix: "evt_cons",
414
+ },
415
+ ]);
416
+ });
417
+ });
418
+
419
+ // ── Proposal and flow sessions ────────────────────────────────────────────
420
+
421
+ describe("a proposal or flow session", () => {
422
+ it("a proposal session → the row names the proposal's Person and workspace", async () => {
423
+ const PROPOSAL_PERSON = "person_0190f3a2-7c1d-7e4a-9b2c-0000000000c1";
424
+ (prisma.piece.findFirst as jest.Mock).mockImplementation(
425
+ async ({ where }: { where: Record<string, unknown> }) =>
426
+ where["id"] === PIECE_ID &&
427
+ where["workspaceId"] === PROPOSAL_WS &&
428
+ where["kind"] === "proposal"
429
+ ? { personId: PROPOSAL_PERSON }
430
+ : null,
431
+ );
432
+ const res = await post(
433
+ completed("evt_consent_proposal_person", {
434
+ metadata: {
435
+ ailk_terms: STAMP,
436
+ ailk_proposal: PIECE_ID,
437
+ ailk_workspace: PROPOSAL_WS,
438
+ },
439
+ }),
440
+ );
441
+ expect(res.statusCode).toBe(200);
442
+ expect(prisma.piece.findFirst).toHaveBeenCalledWith({
443
+ where: { id: PIECE_ID, workspaceId: PROPOSAL_WS, kind: "proposal" },
444
+ select: { personId: true },
445
+ });
446
+ expect(double.ledger).toHaveLength(1);
447
+ expect(double.ledger[0]).toMatchObject({
448
+ workspaceId: PROPOSAL_WS,
449
+ personId: PROPOSAL_PERSON,
450
+ subjectId: PROPOSAL_PERSON,
451
+ source: "checkout",
452
+ sourceRef: SESSION_ID,
453
+ });
454
+ // The payer's typed email minted no Person.
455
+ expect(people).toHaveLength(0);
456
+ });
457
+
458
+ it("a flow session → the row names the flow checkout's Person", async () => {
459
+ const FLOW_WS = "wksp_0190f3a2-7c1d-7e4a-9b2c-0000000000a3";
460
+ const FLOW_SESSION = "cs_live_flow00002";
461
+ const LEAD_EMAIL = "lead@flow.example";
462
+ (prisma.flowCheckout.findFirst as jest.Mock).mockImplementation(
463
+ async ({ where }: { where: Record<string, unknown> }) =>
464
+ where["id"] === "fco_00000001" &&
465
+ where["stripeSessionId"] === FLOW_SESSION &&
466
+ where["status"] === "completed"
467
+ ? { leadId: "lead_00000001", workspaceId: FLOW_WS }
468
+ : null,
469
+ );
470
+ (prisma.lead.findFirst as jest.Mock).mockResolvedValue({
471
+ email: LEAD_EMAIL,
472
+ });
473
+ const res = await post(
474
+ completed("evt_consent_flow_person", {
475
+ id: FLOW_SESSION,
476
+ metadata: { ailk_terms: STAMP, flowCheckoutId: "fco_00000001" },
477
+ }),
478
+ );
479
+ expect(res.statusCode).toBe(200);
480
+
481
+ const flowPerson = personOf(FLOW_WS, LEAD_EMAIL);
482
+ expect(flowPerson).toBeDefined();
483
+ expect(personOf(SITE_WS, PAYER_EMAIL)).toBeUndefined();
484
+ expect(people).toHaveLength(1);
485
+ expect(double.ledger).toHaveLength(1);
486
+ expect(double.ledger[0]).toMatchObject({
487
+ workspaceId: FLOW_WS,
488
+ personId: flowPerson!.id,
489
+ source: "checkout",
490
+ sourceRef: FLOW_SESSION,
491
+ });
492
+ });
493
+
494
+ it("an accepted proposal or flow session never falls through to the payer's email", async () => {
495
+ await post(
496
+ completed("evt_consent_proposal", {
497
+ metadata: {
498
+ ailk_terms: STAMP,
499
+ ailk_proposal: PIECE_ID,
500
+ ailk_workspace: PROPOSAL_WS,
501
+ },
502
+ }),
503
+ );
504
+ await post(
505
+ completed("evt_consent_flow", {
506
+ id: "cs_live_flow00001",
507
+ metadata: { ailk_terms: STAMP, flowCheckoutId: "fco_nope" },
508
+ }),
509
+ );
510
+ expect(personOf(SITE_WS, PAYER_EMAIL)).toBeUndefined();
511
+ expect(
512
+ double.ledger.filter(
513
+ (r) => r.personId !== null && r.workspaceId === SITE_WS,
514
+ ),
515
+ ).toEqual([]);
516
+ });
517
+ });
518
+
519
+ // ── Unstamped sessions ────────────────────────────────────────────────────
520
+
521
+ describe("a session without the current terms stamp", () => {
522
+ it.each([
523
+ ["no metadata.ailk_terms", { metadata: {} }],
524
+ ["no metadata at all", { metadata: null }],
525
+ [
526
+ "an old version (account_terms@1)",
527
+ {
528
+ metadata: { ailk_terms: `${ACCOUNT_TERMS_PURPOSE_ID}@${VERSION - 1}` },
529
+ },
530
+ ],
531
+ [
532
+ "other terms (wt_platform_terms)",
533
+ { metadata: { ailk_terms: `wt_platform_terms@${VERSION}` } },
534
+ ],
535
+ ["a Payment Link session", { payment_link: "plink_1abc", metadata: {} }],
536
+ ])("%s → 200, no row, one terms_unstamped line", async (_label, session) => {
537
+ const res = await post(completed("evt_consent_unstamped", session));
538
+ expect(res.statusCode).toBe(200);
539
+ expect(double.ledger).toHaveLength(0);
540
+ expect(people).toHaveLength(0);
541
+ expect(consentLines()).toEqual([
542
+ {
543
+ event: "checkout-consent",
544
+ outcome: "terms_unstamped",
545
+ eventIdPrefix: "evt_cons",
546
+ },
547
+ ]);
548
+ });
549
+ });
550
+
551
+ // ── Absent consent ────────────────────────────────────────────────────────
552
+
553
+ describe("a session whose terms box was not accepted", () => {
554
+ it.each([
555
+ ["consent: null", { consent: null }],
556
+ ["consent absent", { consent: undefined }],
557
+ [
558
+ "terms_of_service: declined",
559
+ { consent: { terms_of_service: "declined" } },
560
+ ],
561
+ ])("%s → 200, no row, one consent_absent line", async (_label, session) => {
562
+ const res = await post(completed("evt_consent_absent", session));
563
+ expect(res.statusCode).toBe(200);
564
+ expect(double.ledger).toHaveLength(0);
565
+ expect(consentLines()).toEqual([
566
+ {
567
+ event: "checkout-consent",
568
+ outcome: "consent_absent",
569
+ eventIdPrefix: "evt_cons",
570
+ },
571
+ ]);
572
+ });
573
+
574
+ it("the other stamps still run: the proposal stamp's audit row is written", async () => {
575
+ const res = await post(
576
+ completed("evt_consent_absent_proposal", {
577
+ consent: null,
578
+ metadata: {
579
+ ailk_terms: STAMP,
580
+ ailk_proposal: PIECE_ID,
581
+ ailk_workspace: PROPOSAL_WS,
582
+ },
583
+ }),
584
+ );
585
+ expect(res.statusCode).toBe(200);
586
+ expect(double.ledger).toHaveLength(0);
587
+ const auditCreate = auditHost().auditLog.create;
588
+ expect(auditCreate).toHaveBeenCalledTimes(1);
589
+ expect(auditCreate.mock.calls[0]![0]).toMatchObject({
590
+ data: {
591
+ workspaceId: PROPOSAL_WS,
592
+ action: "people.proposal.paid",
593
+ targetId: PIECE_ID,
594
+ },
595
+ });
596
+ expect(consentLines().map((l) => l["outcome"])).toEqual(["consent_absent"]);
597
+ });
598
+ });
599
+
600
+ // ── The stamp's version binds the write ───────────────────────────────────
601
+
602
+ describe("recordCheckoutConsent", () => {
603
+ it("writes nothing when the stamped version is not the site registry's", async () => {
604
+ expect(
605
+ await recordCheckoutConsent({
606
+ workspaceId: SITE_WS,
607
+ personId: "person_0190f3a2-7c1d-7e4a-9b2c-0000000000d1",
608
+ sessionId: SESSION_ID,
609
+ policyVersion: VERSION + 1,
610
+ }),
611
+ ).toBe("version_changed");
612
+ expect(double.ledger).toHaveLength(0);
613
+ expect(prisma.$transaction).not.toHaveBeenCalled();
614
+ });
615
+ });
616
+
617
+ // ── Log hygiene ───────────────────────────────────────────────────────────
618
+
619
+ describe("the log", () => {
620
+ it("no line carries the email or a full Person id", async () => {
621
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue({
622
+ id: "u1",
623
+ email: U1_EMAIL,
624
+ emailVerified: true,
625
+ billingProvider: null,
626
+ billingCustomerId: null,
627
+ });
628
+ await post(completed("evt_consent_log_a"));
629
+ await post(
630
+ completed("evt_consent_log_b", {
631
+ id: "cs_live_second0001",
632
+ metadata: { ailk_terms: STAMP, ailk_user_id: "u1" },
633
+ }),
634
+ );
635
+ await post(completed("evt_consent_log_c", { consent: null }));
636
+ expect(people.length).toBeGreaterThan(0);
637
+ const text = JSON.stringify(logCalls);
638
+ expect(text).not.toContain(PAYER_EMAIL);
639
+ expect(text).not.toContain(U1_EMAIL);
640
+ for (const p of people) expect(text).not.toContain(p.id);
641
+ for (const line of consentLines()) {
642
+ expect(Object.keys(line).sort()).toEqual([
643
+ "event",
644
+ "eventIdPrefix",
645
+ "outcome",
646
+ ]);
647
+ }
648
+ });
649
+ });