create-ailk 0.8.0 → 0.9.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 (211) hide show
  1. package/README.md +68 -0
  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 +92 -4
  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 +19 -9
  34. package/dist/lib/module-license-gate.js +19 -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 +64 -21
  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 +206 -17
  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 +32 -16
  52. package/dist/starter-grid.js +129 -33
  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 +49 -24
  57. package/package.json +5 -4
  58. package/site-templates/_base/content/en/UseCase/persona-1.mdx +7 -6
  59. package/site-templates/_base/content/en/UseCase/persona-2.mdx +7 -6
  60. package/site-templates/_base/content/en/UseCase/persona-3.mdx +7 -6
  61. package/site-templates/{minimalism-site/TEMPLATE-MANIFEST.json → aliases/minimalism-site.TEMPLATE-MANIFEST.json} +72 -40
  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/designs/standard/DESIGN.md +98 -6
  65. package/site-templates/marketing-site/DESIGN.md +98 -6
  66. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +60 -27
  67. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +50 -30
  68. package/site-templates/marketing-site-free/DESIGN.md +98 -6
  69. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +60 -24
  70. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +44 -27
  71. package/site-templates/marketing-site-free/ailk-template.json +1 -1
  72. package/site-templates/saas-product/DESIGN.md +98 -6
  73. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +127 -18
  74. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +74 -20
  75. package/site-templates/shapes/local-business-service/ailk-template.json +1 -1
  76. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +127 -18
  77. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +74 -20
  78. package/site-templates/shapes/organization-service/ailk-template.json +1 -1
  79. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +127 -18
  80. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +75 -21
  81. package/site-templates/shapes/person-service/ailk-template.json +1 -1
  82. package/site-templates/shapes/person-service/content/en/AboutPage/about.mdx +1 -1
  83. package/site-templates/shapes/person-service/content/en/HomePage/home.mdx +2 -2
  84. package/site-templates/shapes/person-service/content/en/PortfolioList/portfolio.mdx +4 -2
  85. package/site-templates/shapes/person-service/content/en/ServicesList/services.mdx +1 -1
  86. package/templates/.claude/rules/architecture.md +7 -0
  87. package/templates/.claude/skills/README.md +3 -2
  88. package/templates/.claude/skills/add-auth/references/env-vars.md +22 -15
  89. package/templates/.claude/skills/add-auth/references/oauth-app-setup.md +3 -2
  90. package/templates/.claude/skills/add-page/SKILL.md +241 -238
  91. package/templates/.claude/skills/add-page/references/page-types.md +85 -76
  92. package/templates/.claude/skills/add-page/scripts/site-pages.mjs +87 -0
  93. package/templates/.claude/skills/add-page/templates/comparison-page.mdx.tmpl +99 -0
  94. package/templates/.claude/skills/add-page/templates/content.mdx.tmpl +8 -18
  95. package/templates/.claude/skills/add-page/templates/flow-landing-page.mdx.tmpl +43 -0
  96. package/templates/.claude/skills/add-page/templates/report-page.mdx.tmpl +52 -0
  97. package/templates/.claude/skills/add-page/templates/reports-list.mdx.tmpl +38 -0
  98. package/templates/.claude/skills/check-site/SKILL.md +23 -0
  99. package/templates/.claude/skills/fill-page/SKILL.md +26 -7
  100. package/templates/.claude/skills/fill-page/profiles/plumbing.yaml +1 -1
  101. package/templates/.claude/skills/setup-deploy/references/env-vars-template.md +28 -11
  102. package/templates/.claude/skills/update-page/SKILL.md +117 -0
  103. package/templates/.claude/skills/update-page/scripts/update-page.ts +640 -0
  104. package/templates/.claude/skills/update-page/templates/redirects.mjs.tmpl +31 -0
  105. package/templates/.claude/skills/upgrade-plan/SKILL.md +12 -8
  106. package/templates/.github/workflows/ci.yml +4 -0
  107. package/templates/.prettierignore +1 -0
  108. package/templates/CLAUDE.md +146 -103
  109. package/templates/DESIGN.md +98 -6
  110. package/templates/README.md +50 -38
  111. package/templates/apps/api/.env.example +2 -1
  112. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +2 -0
  113. package/templates/apps/api/src/__tests__/server.test.ts +116 -0
  114. package/templates/apps/api/src/lib/stripe.ts +9 -0
  115. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +140 -0
  116. package/templates/apps/api/src/middleware/auth.ts +54 -0
  117. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +5 -2
  118. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +628 -0
  119. package/templates/apps/api/src/routes/checkout/sessions.ts +222 -24
  120. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -1
  121. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +5 -1
  122. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +521 -20
  123. package/templates/apps/api/src/routes/webhooks/stripe.ts +124 -32
  124. package/templates/apps/api/src/server.ts +28 -0
  125. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +90 -15
  126. package/templates/apps/api/src/services/checkout-buyer.ts +72 -0
  127. package/templates/apps/api/src/services/revalidation.ts +60 -15
  128. package/templates/apps/mcp/__tests__/tools/create_project.test.ts +30 -24
  129. package/templates/apps/mcp/src/tools/purchase.ts +4 -2
  130. package/templates/apps/web/.env.example +13 -0
  131. package/templates/apps/web/CLAUDE.md +22 -28
  132. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +1 -1
  133. package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +24 -0
  134. package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +17 -6
  135. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +110 -0
  136. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -0
  137. package/templates/apps/web/app/[locale]/[slug]/page.tsx +1 -1
  138. package/templates/apps/web/app/[locale]/alternatives/__tests__/page.test.tsx +161 -0
  139. package/templates/apps/web/app/[locale]/alternatives/page.tsx +104 -0
  140. package/templates/apps/web/app/[locale]/{use-cases → for}/[slug]/page.tsx +4 -3
  141. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +73 -0
  142. package/templates/apps/web/app/[locale]/{use-cases → for}/page.tsx +8 -7
  143. package/templates/apps/web/app/[locale]/glossary/page.tsx +1 -1
  144. package/templates/apps/web/app/[locale]/layout.tsx +35 -19
  145. package/templates/apps/web/app/[locale]/newsletter/page.tsx +1 -1
  146. package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +1 -1
  147. package/templates/apps/web/app/[locale]/portfolio/page.tsx +1 -1
  148. package/templates/apps/web/app/[locale]/vs/[slug]/__tests__/page.test.tsx +112 -0
  149. package/templates/apps/web/app/[locale]/vs/[slug]/page.tsx +70 -0
  150. package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +236 -44
  151. package/templates/apps/web/app/api/revalidate/route.ts +78 -36
  152. package/templates/apps/web/lib/__tests__/site-brand.test.ts +79 -0
  153. package/templates/apps/web/lib/__tests__/site-theme.test.ts +104 -11
  154. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +21 -4
  155. package/templates/apps/web/lib/locale-guard.ts +18 -0
  156. package/templates/apps/web/lib/meet-placeholder.ts +5 -0
  157. package/templates/apps/web/lib/nav-config.ts +14 -13
  158. package/templates/apps/web/lib/site-brand.tsx +13 -9
  159. package/templates/apps/web/lib/site-logo.tsx +36 -0
  160. package/templates/apps/web/lib/site-theme.ts +46 -26
  161. package/templates/apps/web/messages/en.json +23 -9
  162. package/templates/apps/web/middleware.ts +28 -4
  163. package/templates/content/en/BookCall/book-a-call.mdx +80 -0
  164. package/templates/content/en/BookCall/onsite-estimate.mdx +56 -0
  165. package/templates/content/en/BookCall/onsite-service.mdx +56 -0
  166. package/templates/content/en/BookCall/start-project.mdx +56 -0
  167. package/templates/content/en/ContactPage/contact.mdx +40 -1
  168. package/templates/content/en/HomePage/home.mdx +2 -6
  169. package/templates/content/en/LandingPage/meet.mdx +59 -0
  170. package/templates/content/en/UseCasesList/{use-cases.mdx → for.mdx} +19 -18
  171. package/templates/database/CHANGELOG.md +7 -0
  172. package/templates/database/package.json +1 -1
  173. package/templates/package.json +2 -1
  174. package/templates/tools/check-claude-md.ts +128 -0
  175. package/versions.json +33 -33
  176. package/site-templates/_base/content/en/BookCall/book-call.mdx +0 -52
  177. package/site-templates/minimalism-site/DESIGN.md +0 -162
  178. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +0 -458
  179. package/site-templates/minimalism-site/ailk-template.json +0 -23
  180. package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +0 -110
  181. package/site-templates/minimalism-site/content/en/BookCall/book-call.mdx +0 -52
  182. package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +0 -34
  183. package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +0 -57
  184. package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +0 -77
  185. package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +0 -73
  186. package/site-templates/minimalism-site/content/en/PurchasePolicyPage/purchase-policy.mdx +0 -123
  187. package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +0 -52
  188. package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +0 -52
  189. package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +0 -52
  190. package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +0 -52
  191. package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +0 -38
  192. package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +0 -38
  193. package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +0 -38
  194. package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +0 -73
  195. package/site-templates/minimalism-site/flows/referral.flow.json +0 -71
  196. package/site-templates/minimalism-site/provenance.json +0 -4
  197. package/templates/.claude/skills/add-page/templates/route.tsx.tmpl +0 -28
  198. package/templates/apps/web/app/[locale]/book-a-call/page.tsx +0 -50
  199. package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +0 -72
  200. /package/site-templates/{minimalism-site → designs/minimalism/sample}/content/en/HomePage/home.mdx +0 -0
  201. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-1.svg +0 -0
  202. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-2.svg +0 -0
  203. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-3.svg +0 -0
  204. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-4.svg +0 -0
  205. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-5.svg +0 -0
  206. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-6.svg +0 -0
  207. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/hero-backdrop.svg +0 -0
  208. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-1.svg +0 -0
  209. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-2.svg +0 -0
  210. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-3.svg +0 -0
  211. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/wordmark-placeholder.svg +0 -0
@@ -34,16 +34,20 @@
34
34
  * Billing upsert (customer.created / checkout.session.completed):
35
35
  * Billing-entity routing (org-scoped billing, #3601, spec D2): a checkout
36
36
  * carrying metadata.organizationId links the Organization (multi-tenant
37
- * module); absent → the existing User path (single-tenant), byte-for-byte.
37
+ * module); absent → the User path (single-tenant).
38
38
  *
39
39
  * Organization path (metadata.organizationId present):
40
40
  * 1. Typed composite match already pointing at this org → idempotent no-op
41
41
  * 2. Else link the target Organization (by id) billing fields
42
42
  *
43
- * User path (no organizationId — unchanged):
43
+ * User path (no organizationId):
44
44
  * 1. Typed composite match: (billingProvider="stripe", billingCustomerId=customer)
45
- * 2. Email fallback: User.findUnique({ where: { email } }) → update billing fields
46
- * 3. No match → 200 + warn log; no User row created (pre-signup state)
45
+ * 2. Verified user signal: metadata.ailk_user_id →
46
+ * User.findUnique({ where: { id } }) → bind only when the row's
47
+ * billingCustomerId is empty; otherwise log billing_identity_conflict
48
+ * and write nothing (#6113). No signal → log billing_identity_unverified,
49
+ * write nothing (#6852) — the payer-typed Checkout email never binds.
50
+ * 3. Signal names no User → warn billing_identity_unknown_user; no row created
47
51
  *
48
52
  * Recommender purchase stamp (checkout.session.completed / .expired, #4838):
49
53
  * A session carrying metadata.flowCheckoutId (the FlowCheckout buyer row
@@ -275,7 +279,7 @@ export const stripeWebhookHandler = async (
275
279
  // Billing-entity routing (spec D2): a multi-tenant checkout stamps the
276
280
  // target Organization id in metadata.organizationId (an individual → their
277
281
  // sentinel default org). Present → link the Organization; absent → the
278
- // existing User path, byte-for-byte (single-tenant, degrade-clean).
282
+ // User path (single-tenant, degrade-clean).
279
283
  const organizationId = customer.metadata?.["organizationId"] ?? null;
280
284
  if (organizationId) {
281
285
  await upsertOrgBillingIdentity(request, {
@@ -286,7 +290,8 @@ export const stripeWebhookHandler = async (
286
290
  } else {
287
291
  await upsertBillingIdentity(request, {
288
292
  customerId: customer.id,
289
- email: customer.email ?? null,
293
+ userId: signalOf(customer.metadata),
294
+ eventType: event.type,
290
295
  eventIdPrefix,
291
296
  });
292
297
  }
@@ -306,7 +311,7 @@ export const stripeWebhookHandler = async (
306
311
  };
307
312
 
308
313
  // Billing-entity routing (spec D2): metadata.organizationId → Organization
309
- // path; absent → the existing User path, unchanged (single-tenant).
314
+ // path; absent → the User path (single-tenant).
310
315
  const organizationId = session.metadata?.["organizationId"] ?? null;
311
316
  let billingTask: Promise<void> = Promise.resolve();
312
317
  if (session.customer) {
@@ -318,7 +323,8 @@ export const stripeWebhookHandler = async (
318
323
  })
319
324
  : upsertBillingIdentity(request, {
320
325
  customerId: session.customer,
321
- email: session.customer_details?.email ?? null,
326
+ userId: signalOf(session.metadata),
327
+ eventType: event.type,
322
328
  eventIdPrefix,
323
329
  });
324
330
  }
@@ -704,14 +710,44 @@ async function stampRequestPaidByInvoice(
704
710
  }
705
711
  }
706
712
 
713
+ /**
714
+ * The verified user signal (#6852): `metadata.ailk_user_id` on the Checkout
715
+ * session or the Stripe customer, when it is a non-empty string. Never
716
+ * `client_reference_id` (a Payment Link accepts it from the payer's URL) and
717
+ * never the payer-typed email.
718
+ */
719
+ function signalOf(
720
+ metadata: Record<string, string> | null | undefined,
721
+ ): string | null {
722
+ const value: unknown = metadata?.["ailk_user_id"];
723
+ return typeof value === "string" && value !== "" ? value : null;
724
+ }
725
+
707
726
  /**
708
727
  * Link User.billingProvider + User.billingCustomerId for a Stripe customer.
709
728
  *
710
729
  * Lookup priority:
711
730
  * 1. Typed composite match: (billingProvider="stripe", billingCustomerId=customerId)
712
731
  * → already linked; no-op (idempotent)
713
- * 2. Email fallback: User.findUnique({ where: { email } }) → update billing fields
714
- * 3. No match → warn; do NOT create a User row (pre-signup state preserved for Phase B #852)
732
+ * 2. Verified user signal (#6852): `metadata.ailk_user_id` →
733
+ * User.findUnique({ where: { id } }) → bind only when the row's
734
+ * billingCustomerId is empty; otherwise log billing_identity_conflict and
735
+ * write nothing (#6113). The write is guarded on `billingCustomerId: null`;
736
+ * a P2025 means a concurrent event bound the row first — the same customer
737
+ * is a no-op, any other is a conflict.
738
+ * No signal → log billing_identity_unverified and write nothing. The payer
739
+ * types the Checkout email and Stripe never verifies it, so an email
740
+ * never makes a binding, first or otherwise.
741
+ * Trust boundary: Checkout-session and customer `metadata` can be written
742
+ * only by a principal the seller authorised on their Stripe account (our
743
+ * server's key, a write-capable restricted key, a connected app, the
744
+ * dashboard, a Payment Link's own metadata) — never by the payer. A
745
+ * producer sets the key only from an authenticated server session, never
746
+ * from request input.
747
+ * 3. Signal names no User → warn billing_identity_unknown_user; do NOT
748
+ * create a User row (pre-signup state preserved for Phase B #852)
749
+ *
750
+ * No line logs the email, the Stripe customer id, or an unresolved signal.
715
751
  *
716
752
  * DB failures are caught and logged — a broken DB link should not cause Stripe to retry
717
753
  * the event (which would fire analytics + billing again on eventual success).
@@ -720,9 +756,15 @@ async function upsertBillingIdentity(
720
756
  request: FastifyRequest,
721
757
  {
722
758
  customerId,
723
- email,
759
+ userId,
760
+ eventType,
724
761
  eventIdPrefix,
725
- }: { customerId: string; email: string | null; eventIdPrefix: string },
762
+ }: {
763
+ customerId: string;
764
+ userId: string | null;
765
+ eventType: string;
766
+ eventIdPrefix: string;
767
+ },
726
768
  ): Promise<void> {
727
769
  try {
728
770
  const existingByBilling = await prisma.user.findFirst({
@@ -740,30 +782,80 @@ async function upsertBillingIdentity(
740
782
  return;
741
783
  }
742
784
 
743
- if (email) {
744
- const existingByEmail = await prisma.user.findUnique({
745
- where: { email },
746
- });
785
+ if (!userId) {
786
+ // A customer Checkout creates never carries session metadata, so a
787
+ // no-signal customer.created is every ordinary purchase — info, not warn.
788
+ const fields = {
789
+ eventIdPrefix,
790
+ eventType,
791
+ outcome: "billing_identity_unverified",
792
+ };
793
+ const msg =
794
+ "stripe webhook: no verified user signal — billing identity not bound";
795
+ if (eventType === "customer.created") request.log.info(fields, msg);
796
+ else request.log.warn(fields, msg);
797
+ return;
798
+ }
747
799
 
748
- if (existingByEmail) {
749
- await prisma.user.update({
750
- where: { id: existingByEmail.id },
751
- data: {
752
- billingProvider: "stripe",
753
- billingCustomerId: customerId,
754
- },
755
- });
756
- request.log.info(
757
- { eventIdPrefix },
758
- "billing identity linked via email fallback",
759
- );
760
- return;
761
- }
800
+ const row = await prisma.user.findUnique({
801
+ where: { id: userId },
802
+ select: { id: true, billingProvider: true, billingCustomerId: true },
803
+ });
804
+
805
+ if (!row) {
806
+ request.log.warn(
807
+ { eventIdPrefix, eventType, outcome: "billing_identity_unknown_user" },
808
+ "stripe webhook: user signal names no User — pre-signup state preserved",
809
+ );
810
+ return;
762
811
  }
763
812
 
764
- request.log.warn(
813
+ const isSameBinding = (r: {
814
+ billingProvider: string | null;
815
+ billingCustomerId: string | null;
816
+ }) => r.billingProvider === "stripe" && r.billingCustomerId === customerId;
817
+ const logAlreadyLinked = () =>
818
+ request.log.info(
819
+ { eventIdPrefix, outcome: "billing_identity_already_linked" },
820
+ "billing identity already linked — idempotent no-op",
821
+ );
822
+ const logConflict = () =>
823
+ request.log.warn(
824
+ {
825
+ eventIdPrefix,
826
+ outcome: "billing_identity_conflict",
827
+ userId: row.id,
828
+ },
829
+ "stripe webhook: user already bound to a different billing customer — not re-pointed",
830
+ );
831
+
832
+ if (row.billingCustomerId !== null) {
833
+ if (isSameBinding(row)) logAlreadyLinked();
834
+ else logConflict();
835
+ return;
836
+ }
837
+
838
+ try {
839
+ await prisma.user.update({
840
+ where: { id: row.id, billingCustomerId: null },
841
+ data: {
842
+ billingProvider: "stripe",
843
+ billingCustomerId: customerId,
844
+ },
845
+ });
846
+ } catch (err) {
847
+ if ((err as { code?: unknown }).code !== "P2025") throw err;
848
+ const now = await prisma.user.findUnique({
849
+ where: { id: row.id },
850
+ select: { billingProvider: true, billingCustomerId: true },
851
+ });
852
+ if (now && isSameBinding(now)) logAlreadyLinked();
853
+ else logConflict();
854
+ return;
855
+ }
856
+ request.log.info(
765
857
  { eventIdPrefix },
766
- "stripe webhook: no User match for billing identity — pre-signup state preserved",
858
+ "billing identity linked via verified user signal",
767
859
  );
768
860
  } catch (err) {
769
861
  request.log.error(
@@ -12,6 +12,7 @@
12
12
  import cors from "@fastify/cors";
13
13
  import rateLimit from "@fastify/rate-limit";
14
14
  import { idRegex } from "@working-theory/ids";
15
+ import { logger } from "@working-theory/observability";
15
16
  import Fastify from "fastify";
16
17
  import type {
17
18
  FastifyPluginAsync,
@@ -507,6 +508,31 @@ async function assertCommerceSandboxKey(key: string): Promise<void> {
507
508
  assertSandboxKeyFn(key);
508
509
  }
509
510
 
511
+ /**
512
+ * #6889 (SEC-02 of the #6876 security review) — make the checkout route's
513
+ * posture observable. POST /v1/checkout/sessions admits a caller without
514
+ * X-Internal-Auth when OSS_API_INTERNAL_SECRET is unset. With FRONTEND_URL
515
+ * unset, the route's same-origin redirect lock (#6877) answers 500
516
+ * config-error to that caller, and the CORS and cookie-CSRF origin checks pin
517
+ * to the `http://localhost:3000` config default. Both stay optional (OSS
518
+ * standalone compat), so this only ever WARNS — once per unset variable, and
519
+ * only in production. FRONTEND_URL is read from the raw environment because
520
+ * `config.FRONTEND_URL` carries that localhost default.
521
+ */
522
+ function warnOnUnsetProductionProtections(): void {
523
+ if (config.NODE_ENV !== "production") return;
524
+ if (!process.env["FRONTEND_URL"]) {
525
+ logger.warn(
526
+ "[startup] FRONTEND_URL not set in production — CORS/cookie origin checks are pinned to http://localhost:3000, and checkout answers 500 config-error to any caller admitted without X-Internal-Auth",
527
+ );
528
+ }
529
+ if (!process.env["OSS_API_INTERNAL_SECRET"]) {
530
+ logger.warn(
531
+ "[startup] OSS_API_INTERNAL_SECRET not set in production — the checkout route admits callers without X-Internal-Auth",
532
+ );
533
+ }
534
+ }
535
+
510
536
  /**
511
537
  * Build Fastify server instance (for serverless or standalone)
512
538
  * @returns Configured Fastify instance ready for requests
@@ -515,6 +541,8 @@ export async function build() {
515
541
  // Startup-time validation: fail fast if LEAD_CAPTURE_FILE points at a missing directory.
516
542
  await validateLeadCaptureDir();
517
543
 
544
+ warnOnUnsetProductionProtections();
545
+
518
546
  // #6155 D6 — the site-alias workspace assertion (ownership brief § Layer
519
547
  // 2, AC-5): resolves + asserts once at startup that AILK_SITE_ID (or its
520
548
  // "default" fallback) names a real workspace, throwing when it does not —
@@ -4,9 +4,10 @@
4
4
  * loopback HTTP server (spec D11), not a `global.fetch` double: the contract
5
5
  * under test is the wire format, and only a real server proves that the bytes
6
6
  * on the wire are the bytes the signature covers. The server recomputes the
7
- * HMAC inside its own handler — the receiver's verification, performed for
8
- * real — and compares with `timingSafeEqual`, exactly as a Phase-3 receiver
9
- * will (spec FR7).
7
+ * HMAC inside its own handler over the D1 base string
8
+ * `v0:{timestamp}:{event}:{rawBody}` (#5902) — the receiver's verification,
9
+ * performed for real — and compares with `timingSafeEqual`, exactly as the
10
+ * receiver does (spec FR7).
10
11
  *
11
12
  * The server binds 127.0.0.1, which `ssrfBlocked` rejects; D7's
12
13
  * `allowPrivate: true` is what makes this substrate runnable at all.
@@ -23,10 +24,13 @@ import type { AddressInfo } from "node:net";
23
24
  const TEST_SECRET = "not-a-real-secret-fixture-value";
24
25
  const SIGNATURE_HEADER = "x-ailk-revalidate-signature";
25
26
  const EVENT_HEADER = "x-ailk-revalidate-event";
27
+ const TIMESTAMP_HEADER = "x-ailk-revalidate-timestamp";
26
28
 
29
+ // `type` must be a registered page type and `slug` a SLUG_PATTERN value — the
30
+ // sender validates both before signing (#5902).
27
31
  const TARGET = {
28
32
  workspaceId: "ws_test_0001",
29
- type: "page",
33
+ type: "Service",
30
34
  slug: "pricing",
31
35
  locale: "en",
32
36
  };
@@ -63,8 +67,11 @@ jest.mock("@working-theory/observability", () => ({
63
67
  interface Recorded {
64
68
  rawBody: string;
65
69
  signatureValid: boolean;
70
+ /** Whether the signature is HMAC over the raw body ALONE (the pre-#5902 wire). */
71
+ bodyOnlySignatureValid: boolean;
66
72
  signatureHeader: string;
67
73
  eventHeader: string;
74
+ timestampHeader: string;
68
75
  contentType: string;
69
76
  receivedAt: number;
70
77
  }
@@ -81,26 +88,40 @@ beforeAll(async () => {
81
88
  req.on("data", (c: Buffer) => void chunks.push(c));
82
89
  req.on("end", () => {
83
90
  const raw = Buffer.concat(chunks);
84
-
85
- // The receiver's own verification (FR7): recompute HMAC-SHA256 over the
86
- // RAW request body bytes and compare in constant time.
87
- const expected = Buffer.from(
88
- createHmac("sha256", TEST_SECRET).update(raw).digest("hex"),
89
- "utf8",
90
- );
91
+ const timestampHeader = String(req.headers[TIMESTAMP_HEADER] ?? "");
92
+ const eventHeader = String(req.headers[EVENT_HEADER] ?? "");
91
93
  const received = Buffer.from(
92
94
  String(req.headers[SIGNATURE_HEADER] ?? "").replace(/^sha256=/, ""),
93
95
  "utf8",
94
96
  );
95
- const signatureValid =
96
- expected.length === received.length &&
97
- timingSafeEqual(expected, received);
97
+ const matches = (material: Buffer): boolean => {
98
+ const expected = Buffer.from(
99
+ createHmac("sha256", TEST_SECRET).update(material).digest("hex"),
100
+ "utf8",
101
+ );
102
+ return (
103
+ expected.length === received.length &&
104
+ timingSafeEqual(expected, received)
105
+ );
106
+ };
107
+
108
+ // The receiver's own verification (FR7, D1 as amended by #5902):
109
+ // recompute HMAC-SHA256 over `v0:{timestamp}:{event}:` followed by the
110
+ // RAW request body bytes, and compare in constant time.
111
+ const signatureValid = matches(
112
+ Buffer.concat([
113
+ Buffer.from(`v0:${timestampHeader}:${eventHeader}:`, "utf8"),
114
+ raw,
115
+ ]),
116
+ );
98
117
 
99
118
  recorded.push({
100
119
  rawBody: raw.toString("utf8"),
101
120
  signatureValid,
121
+ bodyOnlySignatureValid: matches(raw),
102
122
  signatureHeader: String(req.headers[SIGNATURE_HEADER] ?? ""),
103
- eventHeader: String(req.headers[EVENT_HEADER] ?? ""),
123
+ eventHeader,
124
+ timestampHeader,
104
125
  contentType: String(req.headers["content-type"] ?? ""),
105
126
  receivedAt: Date.now(),
106
127
  });
@@ -219,6 +240,60 @@ describe("notifyRevalidate", () => {
219
240
  });
220
241
  }, 20_000);
221
242
 
243
+ it("signs v0:{timestamp}:{event}:{body} and sends the timestamp header (#5902)", async () => {
244
+ responseStatuses = [200];
245
+ const before = Math.floor(Date.now() / 1000);
246
+
247
+ await loadService().notifyRevalidate(TARGET, { event: "unpublish" });
248
+
249
+ const after = Math.floor(Date.now() / 1000);
250
+ expect(recorded).toHaveLength(1);
251
+ const [req] = recorded;
252
+
253
+ // Unix seconds, digits only, taken at send time — what the receiver checks
254
+ // its 5-minute tolerance against.
255
+ expect(req.timestampHeader).toMatch(/^[0-9]+$/);
256
+ const ts = Number(req.timestampHeader);
257
+ expect(ts).toBeGreaterThanOrEqual(before);
258
+ expect(ts).toBeLessThanOrEqual(after);
259
+
260
+ // The base-string shape, recomputed independently here: the timestamp and
261
+ // the event are inside the MAC, so neither can be altered in flight.
262
+ const expected = createHmac("sha256", TEST_SECRET)
263
+ .update(`v0:${req.timestampHeader}:unpublish:${req.rawBody}`, "utf8")
264
+ .digest("hex");
265
+ expect(req.signatureHeader).toBe(`sha256=${expected}`);
266
+ expect(req.signatureValid).toBe(true);
267
+ // And the body-alone signature of the pre-#5902 wire no longer verifies.
268
+ expect(req.bodyOnlySignatureValid).toBe(false);
269
+ });
270
+
271
+ it.each([
272
+ ["type", "page"], // not a registered page type
273
+ ["type", "Service:some-slug:en"], // the tag-collision shape (#6005 M3)
274
+ ["type", "S".repeat(300)],
275
+ ["slug", "Bad Slug"],
276
+ ["slug", "a:b"],
277
+ ["slug", ""],
278
+ ["locale", "english"],
279
+ ["locale", "en:x"],
280
+ ["locale", ""],
281
+ ])(
282
+ "throws before any outbound call on an invalid %s (%j) (#5902)",
283
+ async (field, value) => {
284
+ responseStatuses = [200];
285
+
286
+ await expect(
287
+ loadService().notifyRevalidate(
288
+ { ...TARGET, [field]: value },
289
+ { event: "publish" },
290
+ ),
291
+ ).rejects.toThrow();
292
+
293
+ expect(recorded).toHaveLength(0);
294
+ },
295
+ );
296
+
222
297
  it("is a single-warning no-op with CONTENT_REVALIDATE_URL unset", async () => {
223
298
  delete process.env.CONTENT_REVALIDATE_URL;
224
299
  delete process.env.CONTENT_REVALIDATE_SECRET;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * @file checkout-buyer.ts
3
+ * @description Resolves a signed-in user into the Checkout buyer
4
+ * `POST /v1/checkout/sessions` pins and stamps (#6866 D3).
5
+ *
6
+ * A buyer is a user whose email is VERIFIED — the customer created for them
7
+ * carries the account email, and it must never carry an unproven one — and
8
+ * whose billing binding, if any, is a Stripe customer. The binding decides
9
+ * whether the library reuses a customer (`customerId`) or creates one
10
+ * (`null`). A row bound under any other provider is not a buyer: the webhook
11
+ * never re-points a non-null `billingCustomerId` (#6113), so a new stamped
12
+ * customer could only be an orphan.
13
+ *
14
+ * Base tree (N4): imports only `verified-email.ts` and `lib/prisma.ts`, never
15
+ * the commerce overlay's `services/subscription.ts`. The billing read selects
16
+ * no email, so `tools/lint-email-authz.ts` has nothing to flag.
17
+ */
18
+
19
+ import { prisma } from "../lib/prisma.js";
20
+
21
+ import { findVerifiedEmail } from "./verified-email.js";
22
+
23
+ export type CheckoutBuyer = {
24
+ userId: string;
25
+ email: string;
26
+ /** The user's bound Stripe customer, or `null` when one is to be created. */
27
+ customerId: string | null;
28
+ };
29
+
30
+ export type CheckoutBuyerResult =
31
+ | { ok: true; buyer: CheckoutBuyer }
32
+ | {
33
+ ok: false;
34
+ reason: "user_not_found" | "email_unverified" | "foreign_provider";
35
+ };
36
+
37
+ /**
38
+ * @param userId the id resolved from the authenticated session cookie — never
39
+ * a request-supplied value.
40
+ * @throws whatever Prisma throws; the caller degrades that to anonymous (F7).
41
+ */
42
+ export async function resolveCheckoutBuyer(
43
+ userId: string,
44
+ ): Promise<CheckoutBuyerResult> {
45
+ const verified = await findVerifiedEmail(userId);
46
+ if (!verified.ok) return { ok: false, reason: verified.reason };
47
+
48
+ const billing = await prisma.user.findUnique({
49
+ where: { id: userId },
50
+ select: { billingProvider: true, billingCustomerId: true },
51
+ });
52
+ if (!billing) return { ok: false, reason: "user_not_found" };
53
+
54
+ if (billing.billingCustomerId === null) {
55
+ return {
56
+ ok: true,
57
+ buyer: { userId, email: verified.value.email, customerId: null },
58
+ };
59
+ }
60
+ // The same provider predicate `resolveBillingCustomer` applies.
61
+ if (billing.billingProvider !== "stripe") {
62
+ return { ok: false, reason: "foreign_provider" };
63
+ }
64
+ return {
65
+ ok: true,
66
+ buyer: {
67
+ userId,
68
+ email: verified.value.email,
69
+ customerId: billing.billingCustomerId,
70
+ },
71
+ };
72
+ }
@@ -3,30 +3,48 @@
3
3
  * @description The signed webhook `apps/api` fires at the site's
4
4
  * `/api/revalidate` on publish and unpublish (#5851, roadmap D6). One POST of
5
5
  * `{ workspaceId, type, slug, locale }` to `CONTENT_REVALIDATE_URL`, signed
6
- * HMAC-SHA256 over the exact body bytes with `CONTENT_REVALIDATE_SECRET`.
6
+ * HMAC-SHA256 with `CONTENT_REVALIDATE_SECRET` over the base string
7
+ * `v0:{timestamp}:{event}:{body}` (#5902) — the Slack shape
8
+ * (`routes/slack/actions.ts:56`) with the event folded in, so neither a replay
9
+ * outside the receiver's 5-minute window nor an in-flight event flip verifies.
10
+ *
11
+ * `type`, `slug` and `locale` are validated against bounded Zod shapes FIRST,
12
+ * before the config check and before any outbound call; an invalid value
13
+ * throws. That is the one way this function rejects, and it is a programmer
14
+ * error rather than a delivery failure: the publish routes already hold these
15
+ * values to the same shapes.
7
16
  *
8
17
  * Either var absent means "revalidation off": one `warn` and return. Both set
9
18
  * means three bounded attempts (500 ms then 2000 ms backoff, 10 s per-attempt
10
19
  * timeout — 32.5 s worst case), every attempt logged, and a final failure
11
- * LOGGED rather than thrown. `notifyRevalidate` never rejects, which is what
12
- * lets the publish path `await` it and still always complete.
20
+ * LOGGED rather than thrown. Delivery never rejects, which is what lets the
21
+ * publish path `await` it and still always complete.
13
22
  *
14
23
  * PII boundary: log lines carry only
15
24
  * `{ event, workspaceId, type, slug, locale, attempt/attempts, status/lastStatus, errName }`.
16
25
  * Never the secret, never the signature, never a response body, never page
17
26
  * content.
18
27
  *
19
- * Nothing calls this yet — the publish/unpublish handlers are a sibling child
20
- * of Epic #5323, and the receiver is Epic #5324. This module fixes the wire
21
- * contract a receiver implements: method POST; the four-key JSON body;
22
- * headers `content-type: application/json`, `x-ailk-revalidate-signature:
23
- * sha256=<lowercase hex>` and `x-ailk-revalidate-event: publish | unpublish`.
24
- * A receiver verifies by recomputing the HMAC over the RAW request body and
25
- * comparing in constant time.
28
+ * The publish and unpublish handlers in `routes/workspaces/content.ts` call
29
+ * this; the receiver is `apps/web/app/api/revalidate/route.ts`. This module
30
+ * fixes the wire contract that receiver implements: method POST; the four-key
31
+ * JSON body; headers `content-type: application/json`,
32
+ * `x-ailk-revalidate-timestamp: <unix seconds>`,
33
+ * `x-ailk-revalidate-event: publish | unpublish` and
34
+ * `x-ailk-revalidate-signature: sha256=<lowercase hex>`. A receiver rejects a
35
+ * timestamp more than 5 minutes from its own clock, then recomputes the HMAC
36
+ * over `v0:{timestamp}:{event}:` + the RAW request body and compares in
37
+ * constant time.
26
38
  */
27
39
  import { createHmac } from "node:crypto";
28
40
 
41
+ import {
42
+ SCHEMA_ORG_TYPES,
43
+ SLUG_PATTERN,
44
+ } from "@working-theory/content-adapters";
29
45
  import { createLogger } from "@working-theory/observability";
46
+ import { localeSchema } from "@working-theory/validation";
47
+ import { z } from "zod";
30
48
 
31
49
  import { config } from "../config/index.js";
32
50
  import { outboundFetch } from "../lib/outbound-http.js";
@@ -35,6 +53,22 @@ const log = createLogger({ service: "revalidation" });
35
53
 
36
54
  const SIGNATURE_HEADER = "x-ailk-revalidate-signature";
37
55
  const EVENT_HEADER = "x-ailk-revalidate-event";
56
+ const TIMESTAMP_HEADER = "x-ailk-revalidate-timestamp";
57
+
58
+ /**
59
+ * The bounds on the three fields that become the site's cache tags
60
+ * (`content:{workspace}:{type}:{slug}:{locale}`). `type` and `slug` are exactly
61
+ * the content routes' `addressParamsSchema` shapes and `locale` is the BCP-47
62
+ * subset `resolveLocale` produces, so the publish path can never trip this
63
+ * after its write has committed. None admits a `:`, which is what stops a
64
+ * crafted `type` colliding with another page's tag (#6005 M3); the receiver
65
+ * applies the same shapes.
66
+ */
67
+ const targetFieldsSchema = z.object({
68
+ type: z.enum(SCHEMA_ORG_TYPES),
69
+ slug: z.string().regex(SLUG_PATTERN),
70
+ locale: localeSchema,
71
+ });
38
72
 
39
73
  /**
40
74
  * Backoff before attempt 2 and attempt 3. Three attempts total, no fourth:
@@ -69,14 +103,21 @@ function isRetryableStatus(status: number): boolean {
69
103
  }
70
104
 
71
105
  /**
72
- * Tells the site to revalidate one piece of content. Resolves in every case —
73
- * success, exhausted retries, or unconfigured — so an `await` in the publish
74
- * path can never fail the publish it follows.
106
+ * Tells the site to revalidate one piece of content. Resolves on success,
107
+ * exhausted retries, or unconfigured, so an `await` in the publish path cannot
108
+ * fail the publish it follows. Rejects only when `type`, `slug` or `locale` is
109
+ * out of bounds, before anything is signed or sent.
75
110
  */
76
111
  export async function notifyRevalidate(
77
112
  target: RevalidateTarget,
78
113
  options: RevalidateOptions,
79
114
  ): Promise<void> {
115
+ targetFieldsSchema.parse({
116
+ type: target.type,
117
+ slug: target.slug,
118
+ locale: target.locale,
119
+ });
120
+
80
121
  const url = config.CONTENT_REVALIDATE_URL;
81
122
  const secret = config.CONTENT_REVALIDATE_SECRET;
82
123
 
@@ -100,8 +141,11 @@ export async function notifyRevalidate(
100
141
  slug: target.slug,
101
142
  locale: target.locale,
102
143
  });
144
+ // Taken once per call and shared by every attempt: the retry schedule's
145
+ // 32.5 s worst case sits well inside the receiver's 5-minute tolerance.
146
+ const timestamp = String(Math.floor(Date.now() / 1000));
103
147
  const signature = createHmac("sha256", secret)
104
- .update(body, "utf8")
148
+ .update(`v0:${timestamp}:${options.event}:${body}`, "utf8")
105
149
  .digest("hex");
106
150
 
107
151
  let attempt = 0;
@@ -117,8 +161,9 @@ export async function notifyRevalidate(
117
161
  method: "POST",
118
162
  headers: {
119
163
  "content-type": "application/json",
120
- [SIGNATURE_HEADER]: `sha256=${signature}`,
164
+ [TIMESTAMP_HEADER]: timestamp,
121
165
  [EVENT_HEADER]: options.event,
166
+ [SIGNATURE_HEADER]: `sha256=${signature}`,
122
167
  },
123
168
  body,
124
169
  // The SSRF gate is waived deliberately and narrowly: this URL comes