create-ailk 0.8.0 → 0.10.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 (238) hide show
  1. package/README.md +71 -3
  2. package/component-catalog.json +279 -45
  3. package/dist/add-module.d.ts +20 -0
  4. package/dist/add-module.js +43 -7
  5. package/dist/add-route.d.ts +56 -0
  6. package/dist/add-route.js +286 -0
  7. package/dist/apply-design-md.d.ts +7 -0
  8. package/dist/apply-design-md.js +50 -0
  9. package/dist/apply-design.d.ts +64 -0
  10. package/dist/apply-design.js +273 -0
  11. package/dist/claude-md.d.ts +111 -0
  12. package/dist/claude-md.js +334 -0
  13. package/dist/cli.js +104 -5
  14. package/dist/derive-architecture.js +41 -36
  15. package/dist/design-compile.d.ts +71 -0
  16. package/dist/design-compile.js +98 -0
  17. package/dist/design-package.d.ts +83 -0
  18. package/dist/design-package.js +468 -0
  19. package/dist/design-presets.d.ts +86 -0
  20. package/dist/design-presets.js +642 -0
  21. package/dist/design-scaffold.d.ts +76 -0
  22. package/dist/design-scaffold.js +220 -0
  23. package/dist/index.d.ts +2 -1
  24. package/dist/index.js +4 -1
  25. package/dist/legacy-nav-renderers.d.ts +45 -0
  26. package/dist/legacy-nav-renderers.js +545 -0
  27. package/dist/lib/auth-secret-file.d.ts +19 -0
  28. package/dist/lib/auth-secret-file.js +112 -0
  29. package/dist/lib/design-checks.d.ts +78 -0
  30. package/dist/lib/design-checks.js +174 -0
  31. package/dist/lib/license-key-files.d.ts +9 -0
  32. package/dist/lib/license-key-files.js +2 -2
  33. package/dist/lib/module-license-gate.d.ts +48 -32
  34. package/dist/lib/module-license-gate.js +117 -8
  35. package/dist/lib/render-verdict.d.ts +52 -0
  36. package/dist/lib/render-verdict.js +77 -0
  37. package/dist/lib/scaffold-render-sweep.d.ts +105 -0
  38. package/dist/lib/scaffold-render-sweep.js +236 -0
  39. package/dist/module-architecture.d.ts +19 -16
  40. package/dist/module-architecture.js +67 -23
  41. package/dist/parse-args.d.ts +12 -2
  42. package/dist/parse-args.js +74 -13
  43. package/dist/programmatic.d.ts +30 -6
  44. package/dist/programmatic.js +216 -19
  45. package/dist/site-chrome.d.ts +19 -9
  46. package/dist/site-chrome.js +23 -13
  47. package/dist/site-manifest.d.ts +25 -0
  48. package/dist/site-manifest.js +254 -0
  49. package/dist/site-roster.d.ts +41 -0
  50. package/dist/site-roster.js +141 -8
  51. package/dist/starter-grid.d.ts +16 -63
  52. package/dist/starter-grid.js +102 -84
  53. package/dist/surfaces.d.ts +41 -9
  54. package/dist/surfaces.js +200 -83
  55. package/dist/template-manifest.d.ts +30 -13
  56. package/dist/template-manifest.js +53 -28
  57. package/package.json +6 -3
  58. package/release-scope.json +25 -0
  59. package/site-templates/_base/content/en/UseCase/persona-1.mdx +7 -6
  60. package/site-templates/_base/content/en/UseCase/persona-2.mdx +7 -6
  61. package/site-templates/_base/content/en/UseCase/persona-3.mdx +7 -6
  62. package/site-templates/designs/minimalism/DESIGN.md +98 -6
  63. package/site-templates/designs/minimalism/sample/README.md +48 -0
  64. package/site-templates/{minimalism-site → designs/minimalism/sample}/content/en/HomePage/home.mdx +1 -1
  65. package/site-templates/designs/standard/DESIGN.md +98 -6
  66. package/site-templates/marketing-site/DESIGN.md +98 -6
  67. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +60 -27
  68. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +50 -30
  69. package/site-templates/marketing-site-free/DESIGN.md +98 -6
  70. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +60 -24
  71. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +44 -27
  72. package/site-templates/marketing-site-free/ailk-template.json +1 -1
  73. package/site-templates/saas-product/DESIGN.md +98 -6
  74. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +127 -18
  75. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +74 -20
  76. package/site-templates/shapes/local-business-service/ailk-template.json +1 -1
  77. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +127 -18
  78. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +74 -20
  79. package/site-templates/shapes/organization-service/ailk-template.json +1 -1
  80. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +127 -18
  81. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +75 -21
  82. package/site-templates/shapes/person-service/ailk-template.json +1 -1
  83. package/site-templates/shapes/person-service/content/en/AboutPage/about.mdx +1 -1
  84. package/site-templates/shapes/person-service/content/en/HomePage/home.mdx +2 -2
  85. package/site-templates/shapes/person-service/content/en/PortfolioList/portfolio.mdx +4 -2
  86. package/site-templates/shapes/person-service/content/en/ServicesList/services.mdx +1 -1
  87. package/templates/.claude/agents/web.md +10 -8
  88. package/templates/.claude/rules/architecture.md +17 -7
  89. package/templates/.claude/skills/README.md +3 -2
  90. package/templates/.claude/skills/add-auth/SKILL.md +142 -90
  91. package/templates/.claude/skills/add-auth/references/env-vars.md +32 -17
  92. package/templates/.claude/skills/add-auth/references/oauth-app-setup.md +17 -8
  93. package/templates/.claude/skills/add-page/SKILL.md +241 -238
  94. package/templates/.claude/skills/add-page/references/page-types.md +94 -86
  95. package/templates/.claude/skills/add-page/scripts/site-pages.mjs +87 -0
  96. package/templates/.claude/skills/add-page/templates/comparison-page.mdx.tmpl +99 -0
  97. package/templates/.claude/skills/add-page/templates/content.mdx.tmpl +8 -18
  98. package/templates/.claude/skills/add-page/templates/flow-landing-page.mdx.tmpl +43 -0
  99. package/templates/.claude/skills/add-page/templates/report-page.mdx.tmpl +52 -0
  100. package/templates/.claude/skills/add-page/templates/reports-list.mdx.tmpl +38 -0
  101. package/templates/.claude/skills/check-site/SKILL.md +51 -18
  102. package/templates/.claude/skills/check-site/references/common-errors.md +12 -9
  103. package/templates/.claude/skills/check-site/references/findings-glossary.md +4 -4
  104. package/templates/.claude/skills/check-site/references/severity-levels.md +3 -3
  105. package/templates/.claude/skills/fill-page/SKILL.md +26 -7
  106. package/templates/.claude/skills/fill-page/profiles/plumbing.yaml +1 -1
  107. package/templates/.claude/skills/setup-deploy/SKILL.md +75 -38
  108. package/templates/.claude/skills/update-page/SKILL.md +117 -0
  109. package/templates/.claude/skills/update-page/scripts/update-page.ts +640 -0
  110. package/templates/.claude/skills/update-page/templates/redirects.mjs.tmpl +31 -0
  111. package/templates/.claude/skills/upgrade-plan/SKILL.md +12 -8
  112. package/templates/.env.example +23 -2
  113. package/templates/.github/workflows/ci.yml +4 -0
  114. package/templates/.prettierignore +1 -0
  115. package/templates/CLAUDE.md +146 -103
  116. package/templates/DESIGN.md +98 -6
  117. package/templates/README.md +50 -38
  118. package/templates/apps/api/.env.example +2 -1
  119. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +2 -0
  120. package/templates/apps/api/src/__tests__/server.test.ts +116 -0
  121. package/templates/apps/api/src/lib/stripe.ts +9 -0
  122. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +140 -0
  123. package/templates/apps/api/src/middleware/auth.ts +54 -0
  124. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +5 -2
  125. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +628 -0
  126. package/templates/apps/api/src/routes/checkout/sessions.ts +222 -24
  127. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -1
  128. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +5 -1
  129. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +521 -20
  130. package/templates/apps/api/src/routes/webhooks/stripe.ts +124 -32
  131. package/templates/apps/api/src/server.ts +28 -0
  132. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +90 -15
  133. package/templates/apps/api/src/services/checkout-buyer.ts +72 -0
  134. package/templates/apps/api/src/services/revalidation.ts +60 -15
  135. package/templates/apps/mcp/__tests__/tools/create_project.test.ts +30 -24
  136. package/templates/apps/mcp/src/tools/purchase.ts +4 -2
  137. package/templates/apps/web/.env.example +13 -0
  138. package/templates/apps/web/CLAUDE.md +22 -28
  139. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +1 -1
  140. package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +24 -0
  141. package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +17 -6
  142. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +110 -0
  143. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -0
  144. package/templates/apps/web/app/[locale]/[slug]/page.tsx +1 -1
  145. package/templates/apps/web/app/[locale]/alternatives/__tests__/page.test.tsx +161 -0
  146. package/templates/apps/web/app/[locale]/alternatives/page.tsx +104 -0
  147. package/templates/apps/web/app/[locale]/{use-cases → for}/[slug]/page.tsx +4 -3
  148. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +73 -0
  149. package/templates/apps/web/app/[locale]/{use-cases → for}/page.tsx +8 -7
  150. package/templates/apps/web/app/[locale]/glossary/page.tsx +1 -1
  151. package/templates/apps/web/app/[locale]/layout.tsx +35 -19
  152. package/templates/apps/web/app/[locale]/newsletter/page.tsx +1 -1
  153. package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +1 -1
  154. package/templates/apps/web/app/[locale]/portfolio/page.tsx +1 -1
  155. package/templates/apps/web/app/[locale]/vs/[slug]/__tests__/page.test.tsx +112 -0
  156. package/templates/apps/web/app/[locale]/vs/[slug]/page.tsx +70 -0
  157. package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +236 -44
  158. package/templates/apps/web/app/api/revalidate/route.ts +78 -36
  159. package/templates/apps/web/lib/__tests__/site-brand.test.ts +79 -0
  160. package/templates/apps/web/lib/__tests__/site-theme.test.ts +104 -11
  161. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +21 -4
  162. package/templates/apps/web/lib/locale-guard.ts +18 -0
  163. package/templates/apps/web/lib/meet-placeholder.ts +5 -0
  164. package/templates/apps/web/lib/nav-config.ts +14 -13
  165. package/templates/apps/web/lib/site-brand.tsx +13 -9
  166. package/templates/apps/web/lib/site-logo.tsx +36 -0
  167. package/templates/apps/web/lib/site-theme.ts +46 -26
  168. package/templates/apps/web/messages/en.json +23 -9
  169. package/templates/apps/web/middleware.ts +28 -4
  170. package/templates/content/en/BookCall/book-a-call.mdx +80 -0
  171. package/templates/content/en/BookCall/onsite-estimate.mdx +56 -0
  172. package/templates/content/en/BookCall/onsite-service.mdx +56 -0
  173. package/templates/content/en/BookCall/start-project.mdx +56 -0
  174. package/templates/content/en/ContactPage/contact.mdx +40 -1
  175. package/templates/content/en/HomePage/home.mdx +2 -6
  176. package/templates/content/en/LandingPage/meet.mdx +59 -0
  177. package/templates/content/en/UseCasesList/{use-cases.mdx → for.mdx} +19 -18
  178. package/templates/database/CHANGELOG.md +7 -0
  179. package/templates/database/package.json +1 -1
  180. package/templates/package.json +4 -1
  181. package/templates/tools/check-claude-md.ts +128 -0
  182. package/versions.json +33 -33
  183. package/dist/modules/commerce/0.6.0.tar.gz +0 -0
  184. package/dist/modules/commerce/0.6.1.tar.gz +0 -0
  185. package/dist/modules/docs-blog/0.6.0.tar.gz +0 -0
  186. package/dist/modules/docs-blog/0.6.1.tar.gz +0 -0
  187. package/dist/modules/marketing-site/0.6.1.tar.gz +0 -0
  188. package/dist/modules/minimalism-site/0.6.1.tar.gz +0 -0
  189. package/dist/modules/multi-tenant/0.6.0.tar.gz +0 -0
  190. package/dist/modules/multi-tenant/0.6.1.tar.gz +0 -0
  191. package/dist/modules/saas-product/0.6.1.tar.gz +0 -0
  192. package/dist/modules/scheduling/0.6.0.tar.gz +0 -0
  193. package/dist/modules/scheduling/0.6.1.tar.gz +0 -0
  194. package/dist/modules/signal-collectors/0.6.0.tar.gz +0 -0
  195. package/dist/modules/signal-collectors/0.6.1.tar.gz +0 -0
  196. package/dist/modules/simple-service/0.6.1.tar.gz +0 -0
  197. package/dist/modules/workspace/0.6.0.tar.gz +0 -0
  198. package/dist/modules/workspace/0.6.1.tar.gz +0 -0
  199. package/site-templates/_base/content/en/BookCall/book-call.mdx +0 -52
  200. package/site-templates/minimalism-site/DESIGN.md +0 -162
  201. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +0 -506
  202. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +0 -458
  203. package/site-templates/minimalism-site/ailk-template.json +0 -23
  204. package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +0 -110
  205. package/site-templates/minimalism-site/content/en/BookCall/book-call.mdx +0 -52
  206. package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +0 -34
  207. package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +0 -57
  208. package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +0 -77
  209. package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +0 -73
  210. package/site-templates/minimalism-site/content/en/PurchasePolicyPage/purchase-policy.mdx +0 -123
  211. package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +0 -52
  212. package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +0 -52
  213. package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +0 -52
  214. package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +0 -52
  215. package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +0 -38
  216. package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +0 -38
  217. package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +0 -38
  218. package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +0 -73
  219. package/site-templates/minimalism-site/flows/referral.flow.json +0 -71
  220. package/site-templates/minimalism-site/provenance.json +0 -4
  221. package/templates/.claude/skills/add-page/templates/route.tsx.tmpl +0 -28
  222. package/templates/.claude/skills/setup-deploy/references/env-vars-template.md +0 -175
  223. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +0 -8
  224. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +0 -12
  225. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +0 -12
  226. package/templates/apps/web/app/[locale]/book-a-call/page.tsx +0 -50
  227. package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +0 -72
  228. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-1.svg +0 -0
  229. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-2.svg +0 -0
  230. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-3.svg +0 -0
  231. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-4.svg +0 -0
  232. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-5.svg +0 -0
  233. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-6.svg +0 -0
  234. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/hero-backdrop.svg +0 -0
  235. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-1.svg +0 -0
  236. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-2.svg +0 -0
  237. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-3.svg +0 -0
  238. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/wordmark-placeholder.svg +0 -0
@@ -128,6 +128,13 @@ describe("POST /api/webhooks/stripe", () => {
128
128
  afterEach(async () => {
129
129
  await server.close();
130
130
  jest.restoreAllMocks();
131
+ // clearAllMocks keeps queued values: a case that asserts a read must NOT
132
+ // happen (#6852) would otherwise leak its unconsumed value into the next.
133
+ (prisma.user.findFirst as jest.Mock).mockReset();
134
+ (prisma.user.findUnique as jest.Mock).mockReset();
135
+ (prisma.user.update as jest.Mock).mockReset();
136
+ (prisma.organization.findFirst as jest.Mock).mockReset();
137
+ (prisma.organization.update as jest.Mock).mockReset();
131
138
  });
132
139
 
133
140
  // ── Case 1: Missing config returns 503 ──────────────────────────────────
@@ -483,6 +490,66 @@ describe("POST /api/webhooks/stripe", () => {
483
490
  expect(response.statusCode).toBe(404);
484
491
  });
485
492
 
493
+ // ── Log capture (shared by the #6113 and #6852 blocks) ─────────────────────
494
+
495
+ type LogLine = {
496
+ level: number;
497
+ msg?: string;
498
+ outcome?: string;
499
+ userId?: string;
500
+ eventType?: string;
501
+ };
502
+ const WARN = 40;
503
+ const INFO = 30;
504
+ const ERROR = 50;
505
+
506
+ // A log-capture instance built the way the "does not log customer email…"
507
+ // case builds one — the default buildTestServer() captures no logs.
508
+ async function withLogCapture(
509
+ event: Record<string, unknown>,
510
+ ): Promise<{ statusCode: number; logLines: LogLine[] }> {
511
+ const logLines: LogLine[] = [];
512
+ const logCapture = Fastify({
513
+ logger: {
514
+ level: "info",
515
+ stream: {
516
+ write(msg: string) {
517
+ logLines.push(JSON.parse(msg) as LogLine);
518
+ },
519
+ },
520
+ },
521
+ });
522
+ await logCapture.register(async (scope) => {
523
+ scope.addContentTypeParser(
524
+ "application/json",
525
+ { parseAs: "buffer" },
526
+ (_req, body, done) => {
527
+ done(null, body);
528
+ },
529
+ );
530
+ scope.post("/v1/webhooks/stripe", stripeWebhookHandler);
531
+ });
532
+ await logCapture.ready();
533
+ try {
534
+ mockVerifyWebhookSignature.mockReturnValue(event);
535
+ const response = await logCapture.inject({
536
+ method: "POST",
537
+ url: "/v1/webhooks/stripe",
538
+ payload: VALID_BODY,
539
+ headers: {
540
+ "content-type": "application/json",
541
+ "stripe-signature": VALID_SIGNATURE,
542
+ },
543
+ });
544
+ return { statusCode: response.statusCode, logLines };
545
+ } finally {
546
+ await logCapture.close();
547
+ }
548
+ }
549
+
550
+ const withOutcome = (lines: LogLine[], outcome: string) =>
551
+ lines.filter((l) => l.outcome === outcome);
552
+
486
553
  // ── Billing upsert tests (AC bullets 4-6) ──────────────────────────────────
487
554
 
488
555
  // AC bullet 4: customer.created → upsert; re-fire same event = no second write
@@ -495,6 +562,7 @@ describe("POST /api/webhooks/stripe", () => {
495
562
  object: {
496
563
  id: customerId,
497
564
  email: "alice@example.com",
565
+ metadata: { ailk_user_id: "usr_1" },
498
566
  },
499
567
  },
500
568
  };
@@ -507,7 +575,7 @@ describe("POST /api/webhooks/stripe", () => {
507
575
 
508
576
  // First fire: typed composite match misses (user not yet linked)
509
577
  (prisma.user.findFirst as jest.Mock).mockResolvedValueOnce(null);
510
- // email fallback finds the user
578
+ // the verified user signal resolves the user by id
511
579
  (prisma.user.findUnique as jest.Mock).mockResolvedValueOnce({
512
580
  id: "usr_1",
513
581
  email: "alice@example.com",
@@ -553,8 +621,9 @@ describe("POST /api/webhooks/stripe", () => {
553
621
  expect(prisma.user.update).toHaveBeenCalledTimes(1);
554
622
  });
555
623
 
556
- // AC bullet 5: checkout.session.completed + existing User matched by email → billing fields populated
557
- it("populates billingProvider/Id on checkout.session.completed for user matched by email", async () => {
624
+ // AC bullet 5 (inverted by #6852): the Checkout email is payer-typed and
625
+ // unverified, so an email match alone never makes a first binding.
626
+ it("does not bind a never-bound User matched only by email (#6852)", async () => {
558
627
  const customerId = "cus_test_checkout_xyz";
559
628
  const fakeCheckout = {
560
629
  id: "evt_checkout_002",
@@ -570,23 +639,16 @@ describe("POST /api/webhooks/stripe", () => {
570
639
  },
571
640
  },
572
641
  };
573
- const updatedUser = {
574
- id: "usr_2",
575
- email: "bob@example.com",
576
- billingProvider: "stripe",
577
- billingCustomerId: customerId,
578
- };
579
642
 
580
643
  // Typed composite lookup misses (no prior link)
581
644
  (prisma.user.findFirst as jest.Mock).mockResolvedValueOnce(null);
582
- // Email fallback finds user
645
+ // Would find the user by email — if the handler still asked
583
646
  (prisma.user.findUnique as jest.Mock).mockResolvedValueOnce({
584
647
  id: "usr_2",
585
648
  email: "bob@example.com",
586
649
  billingProvider: null,
587
650
  billingCustomerId: null,
588
651
  });
589
- (prisma.user.update as jest.Mock).mockResolvedValueOnce(updatedUser);
590
652
 
591
653
  mockVerifyWebhookSignature.mockReturnValue(fakeCheckout);
592
654
  const response = await server.inject({
@@ -600,15 +662,8 @@ describe("POST /api/webhooks/stripe", () => {
600
662
  });
601
663
 
602
664
  expect(response.statusCode).toBe(200);
603
- expect(prisma.user.update).toHaveBeenCalledWith(
604
- expect.objectContaining({
605
- where: { id: "usr_2" },
606
- data: expect.objectContaining({
607
- billingProvider: "stripe",
608
- billingCustomerId: customerId,
609
- }),
610
- }),
611
- );
665
+ expect(prisma.user.findUnique).not.toHaveBeenCalled();
666
+ expect(prisma.user.update).not.toHaveBeenCalled();
612
667
  });
613
668
 
614
669
  // AC bullet 6: event with neither typed match nor email match → 200 + warning, no User row created
@@ -645,4 +700,450 @@ describe("POST /api/webhooks/stripe", () => {
645
700
  expect(prisma.user.update).not.toHaveBeenCalled();
646
701
  expect(prisma.user.create).not.toHaveBeenCalled();
647
702
  });
703
+
704
+ // ── #6113: a bound customer is never re-pointed (now on the signal, #6852) ──
705
+
706
+ describe("signal against an already-bound User (#6113)", () => {
707
+ function checkoutCompleted(customer: string, userId: string) {
708
+ return {
709
+ id: "evt_6113_checkout",
710
+ type: "checkout.session.completed",
711
+ data: {
712
+ object: {
713
+ id: "cs_test_6113",
714
+ amount_total: 4999,
715
+ currency: "usd",
716
+ customer,
717
+ customer_details: { email: "victim@example.com" },
718
+ metadata: { productId: "pro", ailk_user_id: userId },
719
+ },
720
+ },
721
+ };
722
+ }
723
+
724
+ function customerCreated(id: string, userId: string) {
725
+ return {
726
+ id: "evt_6113_customer",
727
+ type: "customer.created",
728
+ data: {
729
+ object: {
730
+ id,
731
+ email: "victim@example.com",
732
+ metadata: { ailk_user_id: userId },
733
+ },
734
+ },
735
+ };
736
+ }
737
+
738
+ const expectFirstReadById = (id: string) =>
739
+ expect((prisma.user.findUnique as jest.Mock).mock.calls[0]?.[0]).toEqual(
740
+ expect.objectContaining({ where: { id } }),
741
+ );
742
+
743
+ const victimRow = {
744
+ id: "usr_victim",
745
+ email: "victim@example.com",
746
+ billingProvider: "stripe",
747
+ billingCustomerId: "cus_victim",
748
+ };
749
+
750
+ // T6a
751
+ it("does not re-point a bound billingCustomerId from a Checkout naming the victim", async () => {
752
+ (prisma.user.findFirst as jest.Mock).mockResolvedValueOnce(null);
753
+ (prisma.user.findUnique as jest.Mock).mockResolvedValueOnce(victimRow);
754
+
755
+ const { statusCode, logLines } = await withLogCapture(
756
+ checkoutCompleted("cus_attacker", "usr_victim"),
757
+ );
758
+
759
+ expect(statusCode).toBe(200);
760
+ expectFirstReadById("usr_victim");
761
+ expect(prisma.user.update).not.toHaveBeenCalled();
762
+ const conflicts = withOutcome(logLines, "billing_identity_conflict");
763
+ expect(conflicts).toHaveLength(1);
764
+ expect(conflicts[0]).toMatchObject({ level: WARN, userId: "usr_victim" });
765
+ const logText = JSON.stringify(logLines);
766
+ expect(logText).not.toContain("victim@example.com");
767
+ expect(logText).not.toContain("cus_attacker");
768
+ expect(logText).not.toContain("cus_victim");
769
+ });
770
+
771
+ // T6b (the customer.created branch)
772
+ it("does not re-point a bound billingCustomerId from customer.created naming the victim", async () => {
773
+ (prisma.user.findFirst as jest.Mock).mockResolvedValueOnce(null);
774
+ (prisma.user.findUnique as jest.Mock).mockResolvedValueOnce(victimRow);
775
+
776
+ const { statusCode, logLines } = await withLogCapture(
777
+ customerCreated("cus_attacker", "usr_victim"),
778
+ );
779
+
780
+ expect(statusCode).toBe(200);
781
+ expectFirstReadById("usr_victim");
782
+ expect(prisma.user.update).not.toHaveBeenCalled();
783
+ const conflicts = withOutcome(logLines, "billing_identity_conflict");
784
+ expect(conflicts).toHaveLength(1);
785
+ expect(conflicts[0]).toMatchObject({ level: WARN, userId: "usr_victim" });
786
+ });
787
+
788
+ // T6c (a lost race to a different customer is a refusal, not a DB failure)
789
+ it("guards the write and treats a P2025 lost to another customer as a conflict", async () => {
790
+ (prisma.user.findFirst as jest.Mock).mockResolvedValueOnce(null);
791
+ (prisma.user.findUnique as jest.Mock)
792
+ .mockResolvedValueOnce({
793
+ id: "usr_race",
794
+ billingProvider: null,
795
+ billingCustomerId: null,
796
+ })
797
+ .mockResolvedValueOnce({
798
+ billingProvider: "stripe",
799
+ billingCustomerId: "cus_other",
800
+ });
801
+ (prisma.user.update as jest.Mock).mockRejectedValueOnce(
802
+ Object.assign(new Error("not found"), { code: "P2025" }),
803
+ );
804
+
805
+ const { statusCode, logLines } = await withLogCapture(
806
+ checkoutCompleted("cus_new", "usr_race"),
807
+ );
808
+
809
+ expect(statusCode).toBe(200);
810
+ expectFirstReadById("usr_race");
811
+ expect(prisma.user.update).toHaveBeenCalledWith(
812
+ expect.objectContaining({
813
+ where: { id: "usr_race", billingCustomerId: null },
814
+ }),
815
+ );
816
+ const conflicts = withOutcome(logLines, "billing_identity_conflict");
817
+ expect(conflicts).toHaveLength(1);
818
+ expect(conflicts[0]).toMatchObject({ level: WARN, userId: "usr_race" });
819
+ expect(
820
+ logLines.filter(
821
+ (l) =>
822
+ l.level === ERROR &&
823
+ l.msg?.includes("billing identity upsert failed"),
824
+ ),
825
+ ).toHaveLength(0);
826
+ });
827
+
828
+ // T6d (the same id under another provider is still "already bound")
829
+ it("does not rewrite a row holding the same id under a different provider", async () => {
830
+ (prisma.user.findFirst as jest.Mock).mockResolvedValueOnce(null);
831
+ (prisma.user.findUnique as jest.Mock).mockResolvedValueOnce({
832
+ id: "usr_same",
833
+ billingProvider: null,
834
+ billingCustomerId: "cus_same",
835
+ });
836
+
837
+ const { statusCode, logLines } = await withLogCapture(
838
+ customerCreated("cus_same", "usr_same"),
839
+ );
840
+
841
+ expect(statusCode).toBe(200);
842
+ expectFirstReadById("usr_same");
843
+ expect(prisma.user.update).not.toHaveBeenCalled();
844
+ expect(withOutcome(logLines, "billing_identity_conflict")).toHaveLength(
845
+ 1,
846
+ );
847
+ });
848
+
849
+ // T6e (a race lost to a sibling event for the SAME customer is a no-op)
850
+ it("treats a P2025 lost to a sibling event for the same customer as already linked", async () => {
851
+ (prisma.user.findFirst as jest.Mock).mockResolvedValueOnce(null);
852
+ (prisma.user.findUnique as jest.Mock)
853
+ .mockResolvedValueOnce({
854
+ id: "usr_sib",
855
+ billingProvider: null,
856
+ billingCustomerId: null,
857
+ })
858
+ .mockResolvedValueOnce({
859
+ billingProvider: "stripe",
860
+ billingCustomerId: "cus_new",
861
+ });
862
+ (prisma.user.update as jest.Mock).mockRejectedValueOnce(
863
+ Object.assign(new Error("not found"), { code: "P2025" }),
864
+ );
865
+
866
+ const { statusCode, logLines } = await withLogCapture(
867
+ checkoutCompleted("cus_new", "usr_sib"),
868
+ );
869
+
870
+ expect(statusCode).toBe(200);
871
+ expectFirstReadById("usr_sib");
872
+ expect(withOutcome(logLines, "billing_identity_conflict")).toHaveLength(
873
+ 0,
874
+ );
875
+ const linked = withOutcome(logLines, "billing_identity_already_linked");
876
+ expect(linked).toHaveLength(1);
877
+ expect(linked[0]).toMatchObject({ level: INFO });
878
+ expect(logLines.filter((l) => l.level === ERROR)).toHaveLength(0);
879
+ });
880
+
881
+ // T6f (the pre-check sees the sibling's binding already committed)
882
+ it("treats a signal-matched row already bound to this customer as a no-op", async () => {
883
+ (prisma.user.findFirst as jest.Mock).mockResolvedValueOnce(null);
884
+ (prisma.user.findUnique as jest.Mock).mockResolvedValueOnce({
885
+ id: "usr_sib",
886
+ billingProvider: "stripe",
887
+ billingCustomerId: "cus_new",
888
+ });
889
+
890
+ const { statusCode, logLines } = await withLogCapture(
891
+ checkoutCompleted("cus_new", "usr_sib"),
892
+ );
893
+
894
+ expect(statusCode).toBe(200);
895
+ expectFirstReadById("usr_sib");
896
+ expect(prisma.user.update).not.toHaveBeenCalled();
897
+ expect(withOutcome(logLines, "billing_identity_conflict")).toHaveLength(
898
+ 0,
899
+ );
900
+ });
901
+ });
902
+
903
+ // ── #6852: a first binding needs a server-stamped user id, never an email ──
904
+
905
+ describe("verified user signal (#6852)", () => {
906
+ function checkoutCompleted(
907
+ customer: string,
908
+ metadata: Record<string, unknown>,
909
+ extra: Record<string, unknown> = {},
910
+ ) {
911
+ return {
912
+ id: "evt_6852_checkout",
913
+ type: "checkout.session.completed",
914
+ data: {
915
+ object: {
916
+ id: "cs_test_6852",
917
+ amount_total: 4999,
918
+ currency: "usd",
919
+ customer,
920
+ customer_details: { email: "victim@example.com" },
921
+ metadata,
922
+ ...extra,
923
+ },
924
+ },
925
+ };
926
+ }
927
+
928
+ function customerCreated(
929
+ id: string,
930
+ email: string | null,
931
+ metadata?: Record<string, string>,
932
+ ) {
933
+ return {
934
+ id: "evt_6852_customer",
935
+ type: "customer.created",
936
+ data: { object: { id, email, ...(metadata ? { metadata } : {}) } },
937
+ };
938
+ }
939
+
940
+ const neverBound = (id: string) => ({
941
+ id,
942
+ billingProvider: null,
943
+ billingCustomerId: null,
944
+ });
945
+
946
+ const findUniqueWheres = () =>
947
+ (prisma.user.findUnique as jest.Mock).mock.calls.map(
948
+ (c) => (c[0] as { where: Record<string, unknown> }).where,
949
+ );
950
+
951
+ beforeEach(() => {
952
+ (prisma.user.findFirst as jest.Mock).mockResolvedValue(null);
953
+ });
954
+
955
+ // T1 (AC1 — checkout.session.completed)
956
+ it("T1: a Checkout under the victim's email with no signal binds nothing", async () => {
957
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(
958
+ neverBound("usr_victim"),
959
+ );
960
+
961
+ const { statusCode, logLines } = await withLogCapture(
962
+ checkoutCompleted("cus_attacker", { productId: "pro" }),
963
+ );
964
+
965
+ expect(statusCode).toBe(200);
966
+ expect(prisma.user.findUnique).not.toHaveBeenCalled();
967
+ expect(prisma.user.update).not.toHaveBeenCalled();
968
+ const unverified = withOutcome(logLines, "billing_identity_unverified");
969
+ expect(unverified).toHaveLength(1);
970
+ expect(unverified[0]).toMatchObject({
971
+ level: WARN,
972
+ eventType: "checkout.session.completed",
973
+ });
974
+ const logText = JSON.stringify(logLines);
975
+ expect(logText).not.toContain("victim@example.com");
976
+ expect(logText).not.toContain("cus_attacker");
977
+ });
978
+
979
+ // T2 (AC1 — customer.created)
980
+ it("T2: customer.created under the victim's email with no signal binds nothing", async () => {
981
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(
982
+ neverBound("usr_victim"),
983
+ );
984
+
985
+ const { statusCode, logLines } = await withLogCapture(
986
+ customerCreated("cus_attacker", "victim@example.com"),
987
+ );
988
+
989
+ expect(statusCode).toBe(200);
990
+ expect(prisma.user.findUnique).not.toHaveBeenCalled();
991
+ expect(prisma.user.update).not.toHaveBeenCalled();
992
+ expect(withOutcome(logLines, "billing_identity_unverified")).toHaveLength(
993
+ 1,
994
+ );
995
+ });
996
+
997
+ // T3 (AC1 — a signal naming a different user binds that user, never the email's)
998
+ it("T3: a signal naming another user binds that user, never the email's", async () => {
999
+ (prisma.user.findUnique as jest.Mock).mockResolvedValueOnce(
1000
+ neverBound("usr_attacker"),
1001
+ );
1002
+
1003
+ const { statusCode } = await withLogCapture(
1004
+ checkoutCompleted("cus_attacker", { ailk_user_id: "usr_attacker" }),
1005
+ );
1006
+
1007
+ expect(statusCode).toBe(200);
1008
+ expect(findUniqueWheres()).toEqual([{ id: "usr_attacker" }]);
1009
+ expect(prisma.user.update).toHaveBeenCalledTimes(1);
1010
+ expect(prisma.user.update).toHaveBeenCalledWith(
1011
+ expect.objectContaining({
1012
+ where: { id: "usr_attacker", billingCustomerId: null },
1013
+ }),
1014
+ );
1015
+ });
1016
+
1017
+ // T4 (AC1 — client_reference_id is payer-settable, never a signal)
1018
+ it("T4: client_reference_id alone is not a signal", async () => {
1019
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(
1020
+ neverBound("usr_victim"),
1021
+ );
1022
+
1023
+ const { statusCode, logLines } = await withLogCapture(
1024
+ checkoutCompleted(
1025
+ "cus_attacker",
1026
+ { productId: "pro" },
1027
+ { client_reference_id: "usr_victim" },
1028
+ ),
1029
+ );
1030
+
1031
+ expect(statusCode).toBe(200);
1032
+ expect(prisma.user.findUnique).not.toHaveBeenCalled();
1033
+ expect(prisma.user.update).not.toHaveBeenCalled();
1034
+ expect(withOutcome(logLines, "billing_identity_unverified")).toHaveLength(
1035
+ 1,
1036
+ );
1037
+ });
1038
+
1039
+ // signalOf accepts only a non-empty string — anything else is no signal.
1040
+ it.each([
1041
+ ["an empty string", ""],
1042
+ ["a non-string", 42],
1043
+ ])(
1044
+ "T4b: an ailk_user_id that is %s is not a signal",
1045
+ async (_label, value) => {
1046
+ const { statusCode, logLines } = await withLogCapture(
1047
+ checkoutCompleted("cus_attacker", { ailk_user_id: value }),
1048
+ );
1049
+
1050
+ expect(statusCode).toBe(200);
1051
+ expect(prisma.user.findUnique).not.toHaveBeenCalled();
1052
+ expect(prisma.user.update).not.toHaveBeenCalled();
1053
+ expect(
1054
+ withOutcome(logLines, "billing_identity_unverified"),
1055
+ ).toHaveLength(1);
1056
+ },
1057
+ );
1058
+
1059
+ const buyerBind = {
1060
+ where: { id: "usr_buyer", billingCustomerId: null },
1061
+ data: { billingProvider: "stripe", billingCustomerId: "cus_buyer" },
1062
+ };
1063
+
1064
+ // T5a (AC2 — a signed-in Checkout binds the buyer's first customer)
1065
+ it("T5a: checkout.session.completed carrying the signal binds the buyer", async () => {
1066
+ (prisma.user.findUnique as jest.Mock).mockResolvedValueOnce(
1067
+ neverBound("usr_buyer"),
1068
+ );
1069
+
1070
+ const { statusCode } = await withLogCapture(
1071
+ checkoutCompleted("cus_buyer", { ailk_user_id: "usr_buyer" }),
1072
+ );
1073
+
1074
+ expect(statusCode).toBe(200);
1075
+ expect(findUniqueWheres()).toEqual([{ id: "usr_buyer" }]);
1076
+ expect(prisma.user.update).toHaveBeenCalledTimes(1);
1077
+ expect(prisma.user.update).toHaveBeenCalledWith(buyerBind);
1078
+ });
1079
+
1080
+ // T5b (AC2 — the signal is read from the customer on customer.created)
1081
+ it("T5b: customer.created carrying the signal binds the buyer", async () => {
1082
+ (prisma.user.findUnique as jest.Mock).mockResolvedValueOnce(
1083
+ neverBound("usr_buyer"),
1084
+ );
1085
+
1086
+ const { statusCode } = await withLogCapture(
1087
+ customerCreated("cus_buyer", null, { ailk_user_id: "usr_buyer" }),
1088
+ );
1089
+
1090
+ expect(statusCode).toBe(200);
1091
+ expect(findUniqueWheres()).toEqual([{ id: "usr_buyer" }]);
1092
+ expect(prisma.user.update).toHaveBeenCalledTimes(1);
1093
+ expect(prisma.user.update).toHaveBeenCalledWith(buyerBind);
1094
+ });
1095
+
1096
+ // T7 (F5 — a signal naming no User writes nothing and never echoes the value)
1097
+ it("T7: a signal naming no existing User binds nothing and is not logged", async () => {
1098
+ (prisma.user.findUnique as jest.Mock).mockResolvedValueOnce(null);
1099
+
1100
+ const { statusCode, logLines } = await withLogCapture(
1101
+ checkoutCompleted("cus_buyer", { ailk_user_id: "usr_ghost" }),
1102
+ );
1103
+
1104
+ expect(statusCode).toBe(200);
1105
+ expect(prisma.user.update).not.toHaveBeenCalled();
1106
+ const unknown = withOutcome(logLines, "billing_identity_unknown_user");
1107
+ expect(unknown).toHaveLength(1);
1108
+ expect(unknown[0]).toMatchObject({ level: WARN });
1109
+ expect(JSON.stringify(logLines)).not.toContain("usr_ghost");
1110
+ });
1111
+
1112
+ // T8 (F4 / SEC-04 — an ordinary purchase's customer.created is info, not warn)
1113
+ it("T8: a no-signal customer.created logs unverified at info", async () => {
1114
+ const { statusCode, logLines } = await withLogCapture(
1115
+ customerCreated("cus_buyer", null),
1116
+ );
1117
+
1118
+ expect(statusCode).toBe(200);
1119
+ expect(prisma.user.findUnique).not.toHaveBeenCalled();
1120
+ expect(prisma.user.update).not.toHaveBeenCalled();
1121
+ const unverified = withOutcome(logLines, "billing_identity_unverified");
1122
+ expect(unverified).toHaveLength(1);
1123
+ expect(unverified[0]).toMatchObject({
1124
+ level: INFO,
1125
+ eventType: "customer.created",
1126
+ });
1127
+ expect(JSON.stringify(logLines)).not.toContain("cus_buyer");
1128
+ });
1129
+
1130
+ // T9 (F8 — organizationId keeps precedence over the user signal)
1131
+ it("T9: organizationId takes the Organization path even with a user signal", async () => {
1132
+ (prisma.organization.findFirst as jest.Mock).mockResolvedValueOnce(null);
1133
+
1134
+ const { statusCode } = await withLogCapture(
1135
+ checkoutCompleted("cus_org", {
1136
+ organizationId: "org_1",
1137
+ ailk_user_id: "usr_buyer",
1138
+ }),
1139
+ );
1140
+
1141
+ expect(statusCode).toBe(200);
1142
+ expect(prisma.organization.update).toHaveBeenCalledWith(
1143
+ expect.objectContaining({ where: { id: "org_1" } }),
1144
+ );
1145
+ expect(prisma.user.findUnique).not.toHaveBeenCalled();
1146
+ expect(prisma.user.update).not.toHaveBeenCalled();
1147
+ });
1148
+ });
648
1149
  });