create-ailk 0.21.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 (175) hide show
  1. package/component-catalog.json +37 -3
  2. package/component-catalog.md +6 -5
  3. package/dist/add-module.js +9 -5
  4. package/dist/derive-architecture.d.ts +8 -1
  5. package/dist/derive-architecture.js +38 -3
  6. package/dist/design-package.js +7 -2
  7. package/dist/lib/env-catalog.js +5 -1
  8. package/dist/module-architecture.js +4 -2
  9. package/dist/programmatic.js +19 -5
  10. package/dist/site-chrome.d.ts +8 -0
  11. package/dist/site-chrome.js +17 -1
  12. package/dist/spoke-checkout.d.ts +39 -18
  13. package/dist/spoke-checkout.js +177 -236
  14. package/dist/surfaces.js +19 -14
  15. package/dist/sync-routes.js +3 -2
  16. package/dist/template-record.d.ts +4 -3
  17. package/dist/template-record.js +6 -3
  18. package/dist/topology.js +25 -1
  19. package/dist/update.js +5 -0
  20. package/package.json +3 -3
  21. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  22. package/templates/apps/api/CLAUDE.md +15 -6
  23. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
  24. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
  25. package/templates/apps/api/src/lib/consent-store.ts +28 -0
  26. package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
  27. package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
  28. package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
  29. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
  30. package/templates/apps/api/src/middleware/auth.ts +13 -6
  31. package/templates/apps/api/src/openapi/spec.ts +30 -3
  32. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
  33. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
  34. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
  35. package/templates/apps/api/src/routes/consent/account.ts +266 -0
  36. package/templates/apps/api/src/routes/consent/index.ts +15 -7
  37. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
  38. package/templates/apps/api/src/routes/content/get-page.ts +4 -0
  39. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
  40. package/templates/apps/api/src/routes/leads/index.ts +161 -7
  41. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
  42. package/templates/apps/api/src/routes/webhooks/stripe.ts +154 -19
  43. package/templates/apps/api/src/server.ts +28 -0
  44. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1038 -118
  45. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
  46. package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
  47. package/templates/apps/api/src/services/consent.ts +95 -0
  48. package/templates/apps/api/src/services/lead-routing.ts +337 -8
  49. package/templates/apps/api/src/services/magnet-completion.ts +242 -0
  50. package/templates/apps/api/src/services/person.ts +40 -8
  51. package/templates/apps/api/src/services/plan-offerings.ts +59 -0
  52. package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
  53. package/templates/apps/web/.env.example +14 -0
  54. package/templates/apps/web/CLAUDE.md +120 -9
  55. package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
  56. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
  57. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
  58. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
  59. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
  60. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
  61. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
  62. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
  63. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
  64. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
  65. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
  66. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
  67. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
  68. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
  69. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
  70. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
  71. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
  72. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
  73. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
  74. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
  75. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
  76. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
  77. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
  78. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
  79. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
  80. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
  81. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
  82. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
  83. package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
  84. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
  85. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
  86. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
  87. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
  88. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +293 -0
  89. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +115 -0
  90. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +146 -1
  91. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +310 -0
  92. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +143 -0
  93. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +350 -0
  94. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +29 -3
  95. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  96. package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
  97. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
  98. package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
  99. package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
  100. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  101. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +17 -1
  102. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
  103. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +41 -2
  104. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +438 -31
  105. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +7 -0
  106. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +168 -57
  107. package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
  108. package/templates/apps/web/app/[locale]/contact/page.tsx +49 -17
  109. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  110. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +1 -1
  111. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
  112. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +106 -1
  113. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
  114. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
  115. package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
  116. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
  117. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  118. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  119. package/templates/apps/web/app/[locale]/page.tsx +13 -1
  120. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  121. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  122. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
  123. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  124. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  125. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  126. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  127. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
  128. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
  129. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
  130. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
  131. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  132. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
  133. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
  134. package/templates/apps/web/app/api/checkout/[offering]/route.ts +48 -0
  135. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +120 -0
  136. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +86 -0
  137. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
  138. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
  139. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +92 -0
  140. package/templates/apps/web/app/api/webhooks/stripe/route.ts +31 -0
  141. package/templates/apps/web/auth.ts +28 -2
  142. package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
  143. package/templates/apps/web/lib/__tests__/checkout.test.ts +243 -0
  144. package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
  145. package/templates/apps/web/lib/__tests__/deck-page.test.ts +5 -0
  146. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +34 -17
  147. package/templates/apps/web/lib/account-invite.ts +37 -0
  148. package/templates/apps/web/lib/checkout-config.ts +27 -0
  149. package/templates/apps/web/lib/checkout.ts +93 -0
  150. package/templates/apps/web/lib/consent-policies.ts +22 -0
  151. package/templates/apps/web/lib/deck-page.ts +6 -0
  152. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
  153. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
  154. package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
  155. package/templates/apps/web/lib/sign-up-agreements.ts +447 -0
  156. package/templates/apps/web/lib/site-chrome.ts +2 -0
  157. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  158. package/templates/apps/web/messages/en.json +24 -7
  159. package/templates/apps/web/package.json +1 -0
  160. package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
  161. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
  162. package/templates/apps/web/src/lib/lead-write.ts +90 -0
  163. package/templates/apps/web/src/lib/rate-limit.ts +79 -0
  164. package/templates/database/CHANGELOG.md +38 -0
  165. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
  166. package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
  167. package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
  168. package/templates/database/__tests__/seed.test.ts +34 -1
  169. package/templates/database/inbox/schema.prisma +17 -2
  170. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
  171. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
  172. package/templates/database/package.json +1 -1
  173. package/templates/database/seed.ts +34 -3
  174. package/versions.json +34 -34
  175. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
@@ -96,6 +96,16 @@
96
96
  * `stripeSessionId`), with `session.customer` as the customer id only on a
97
97
  * live-mode event. Logs an outcome kind only; never fails the 200.
98
98
  *
99
+ * Checkout terms grant (checkout.session.completed, #8765 D14):
100
+ * Chained after the Person step, `recordCheckoutTermsGrant` records the
101
+ * buyer's acceptance of the session's terms box as one `checkout` row of
102
+ * the consent ledger — only for a session whose `consent.terms_of_service`
103
+ * is `accepted` and whose server-written `metadata.ailk_terms` names
104
+ * `account_terms` at its current version (a Payment Link, invoice or older
105
+ * session carries none), for the Person `checkoutBuyerPerson` resolves.
106
+ * Logs `{ event: "checkout-consent", outcome, eventIdPrefix }` only; never
107
+ * fails the 200.
108
+ *
99
109
  * Dunning (invoice.payment_failed / customer.subscription.updated, #6034):
100
110
  * Records a per-subscriber lifecycle state in a `UserData` row behind a
101
111
  * reserved-key guard (routes/auth/user.ts). `invoice.payment_failed` arms
@@ -108,6 +118,11 @@
108
118
  import { track } from "@working-theory/analytics/server";
109
119
  import type { Prisma } from "@working-theory/database";
110
120
  import { isId } from "@working-theory/ids";
121
+ import {
122
+ ACCOUNT_TERMS_PURPOSE_ID,
123
+ consentPurposeById,
124
+ SITE_CONSENT_CONFIG_DEFAULTS,
125
+ } from "@working-theory/validation";
111
126
  import type { FastifyReply, FastifyRequest } from "fastify";
112
127
 
113
128
  import { config } from "../../config/index.js";
@@ -133,6 +148,8 @@ import {
133
148
  } from "../../lib/stripe.js";
134
149
  import { hasSeen, markSeen } from "../../lib/webhook-idempotency.js";
135
150
  import { recordWorkspaceAuditAction } from "../../services/audit-log.js";
151
+ import { checkoutBuyerPerson } from "../../services/checkout-buyer.js";
152
+ import { recordCheckoutConsent } from "../../services/consent.js";
136
153
  import { isUniqueViolation } from "../../services/default-tenant.js";
137
154
  import {
138
155
  DUNNING_MAX_SUBSCRIPTIONS,
@@ -142,8 +159,8 @@ import {
142
159
  type DunningRecord,
143
160
  } from "../../services/dunning-policy.js";
144
161
  import {
145
- recordCheckoutPerson,
146
- type CheckoutPersonOutcome,
162
+ resolveCheckoutPerson,
163
+ type CheckoutPersonResult,
147
164
  } from "../../services/person.js";
148
165
 
149
166
  const TELEMETRY_TIMEOUT_MS = 2000;
@@ -429,13 +446,24 @@ export const stripeWebhookHandler = async (
429
446
  session,
430
447
  eventIdPrefix,
431
448
  outcome: "completed",
432
- }).then(() =>
433
- recordFlowCheckoutPerson(request, {
434
- session,
435
- livemode: event.livemode === true,
436
- eventIdPrefix,
437
- }),
438
- ),
449
+ })
450
+ .then(() =>
451
+ recordFlowCheckoutPerson(request, {
452
+ session,
453
+ livemode: event.livemode === true,
454
+ eventIdPrefix,
455
+ }),
456
+ )
457
+ // #8765 D14 — after the Person step, whose Person is a flow
458
+ // checkout's grant subject.
459
+ .then((flowPerson) =>
460
+ recordCheckoutTermsGrant(request, {
461
+ session,
462
+ livemode: event.livemode === true,
463
+ eventIdPrefix,
464
+ flowPerson,
465
+ }),
466
+ ),
439
467
  stampRequestPaid(request, { session, eventIdPrefix }),
440
468
  stampProposalPaid(request, { session, eventId: event.id, eventIdPrefix }),
441
469
  ]);
@@ -531,6 +559,8 @@ type CheckoutSessionObject = {
531
559
  currency?: string | null;
532
560
  customer?: string | null;
533
561
  customer_details?: { email?: string | null } | null;
562
+ /** The terms box's answer (#8765 D14): `accepted` when the buyer ticked it. */
563
+ consent?: { terms_of_service?: string | null } | null;
534
564
  metadata?: Record<string, string> | null;
535
565
  };
536
566
 
@@ -750,7 +780,7 @@ async function stampFlowCheckout(
750
780
  /**
751
781
  * The hub checkout's Person (#852 D6): after `stampFlowCheckout`, find or
752
782
  * create the Person of the completed checkout's lead
753
- * (`services/person.ts`'s `recordCheckoutPerson`). Runs only for a paid (or
783
+ * (`services/person.ts`'s `resolveCheckoutPerson`). Runs only for a paid (or
754
784
  * nothing-due) session of THIS site — the foreign-site filter has already
755
785
  * returned — that carries `metadata.flowCheckoutId`. The customer id's ONE
756
786
  * source is `session.customer`, written onto the Person only for a
@@ -758,7 +788,8 @@ async function stampFlowCheckout(
758
788
  * delivery whose Person step failed is repaired by the next one.
759
789
  *
760
790
  * Logs `{ eventIdPrefix, flowCheckoutIdPrefix, outcome }` only — never the
761
- * email or the customer id. Never throws: the response stays 200.
791
+ * email or the customer id. Answers the Person, or `null` when the step did
792
+ * not run or found none (#8765 D14). Never throws: the response stays 200.
762
793
  */
763
794
  async function recordFlowCheckoutPerson(
764
795
  request: FastifyRequest,
@@ -771,33 +802,137 @@ async function recordFlowCheckoutPerson(
771
802
  livemode: boolean;
772
803
  eventIdPrefix: string;
773
804
  },
774
- ): Promise<void> {
805
+ ): Promise<CheckoutPersonResult["person"]> {
775
806
  const flowCheckoutId = session.metadata?.["flowCheckoutId"];
776
- if (!flowCheckoutId) return;
807
+ if (!flowCheckoutId) return null;
777
808
  if (
778
809
  session.payment_status !== "paid" &&
779
810
  session.payment_status !== "no_payment_required"
780
811
  ) {
781
- return;
812
+ return null;
782
813
  }
783
814
  const flowCheckoutIdPrefix = flowCheckoutId.substring(0, 8);
784
- let outcome: CheckoutPersonOutcome;
815
+ let result: CheckoutPersonResult;
785
816
  try {
786
- outcome = await recordCheckoutPerson({
817
+ result = await resolveCheckoutPerson({
787
818
  flowCheckoutId,
788
819
  stripeSessionId: session.id,
789
820
  customer: session.customer,
790
821
  livemode,
791
822
  });
792
823
  } catch {
793
- outcome = "person_failed";
824
+ result = { outcome: "person_failed", person: null };
794
825
  }
795
- const line = { eventIdPrefix, flowCheckoutIdPrefix, outcome };
796
- if (outcome === "person_failed") {
826
+ const line = { eventIdPrefix, flowCheckoutIdPrefix, outcome: result.outcome };
827
+ if (result.outcome === "person_failed") {
797
828
  request.log.error(line, "flow checkout person failed — continuing");
798
829
  } else {
799
830
  request.log.info(line, "flow checkout person");
800
831
  }
832
+ return result.person;
833
+ }
834
+
835
+ /** The metadata key the Checkout creators stamp with the terms the box showed (#8765 D12). */
836
+ const TERMS_STAMP_KEY = "ailk_terms";
837
+
838
+ /**
839
+ * `<purposeId>@<policyVersion>` of the account terms as the registry reads
840
+ * them now — the only stamp a grant is recorded for (SEC-8765-E) — and that
841
+ * version, which `recordCheckoutConsent` checks against the site's registry.
842
+ * Derived, never written down, so a version bump retires every older stamp.
843
+ */
844
+ function currentTermsStamp(): { stamp: string; policyVersion: number } | null {
845
+ const purpose = consentPurposeById(
846
+ SITE_CONSENT_CONFIG_DEFAULTS,
847
+ ACCOUNT_TERMS_PURPOSE_ID,
848
+ );
849
+ return purpose
850
+ ? {
851
+ stamp: `${purpose.id}@${purpose.policyVersion}`,
852
+ policyVersion: purpose.policyVersion,
853
+ }
854
+ : null;
855
+ }
856
+
857
+ /**
858
+ * The buyer's terms grant (#8765 D14). Writes nothing — and logs why — for a
859
+ * session whose box was not accepted (`consent_absent`: Stripe enforced the
860
+ * box, so the order completes regardless), one that does not carry the
861
+ * current `ailk_terms` stamp (`terms_unstamped`: a Payment Link, an invoice,
862
+ * an older session or other terms), or one whose buyer has no Person
863
+ * (`person_unresolved`). Otherwise `recordCheckoutConsent` writes at most one
864
+ * row per Person and session (`recorded` / `unchanged`), or none when the
865
+ * site's registry has moved past the stamped version (`version_changed`).
866
+ *
867
+ * Logs `{ event, outcome, eventIdPrefix }` only (plus `errName` on `failed`)
868
+ * — never an email or a Person id. Never throws: the response stays 200.
869
+ */
870
+ async function recordCheckoutTermsGrant(
871
+ request: FastifyRequest,
872
+ {
873
+ session,
874
+ livemode,
875
+ eventIdPrefix,
876
+ flowPerson,
877
+ }: {
878
+ session: CheckoutSessionObject;
879
+ livemode: boolean;
880
+ eventIdPrefix: string;
881
+ /** A flow checkout's Person, from `recordFlowCheckoutPerson`. */
882
+ flowPerson: CheckoutPersonResult["person"];
883
+ },
884
+ ): Promise<void> {
885
+ const line = (outcome: string) => ({
886
+ event: "checkout-consent",
887
+ outcome,
888
+ eventIdPrefix,
889
+ });
890
+ try {
891
+ if (session.consent?.terms_of_service !== "accepted") {
892
+ request.log.info(
893
+ line("consent_absent"),
894
+ "checkout terms grant: box not accepted",
895
+ );
896
+ return;
897
+ }
898
+ const stamp: unknown = session.metadata?.[TERMS_STAMP_KEY];
899
+ const expected = currentTermsStamp();
900
+ if (expected === null || stamp !== expected.stamp) {
901
+ request.log.info(
902
+ line("terms_unstamped"),
903
+ "checkout terms grant: session carries no current terms stamp",
904
+ );
905
+ return;
906
+ }
907
+ const sessionId = session.id;
908
+ const buyer =
909
+ typeof sessionId === "string" && sessionId !== ""
910
+ ? await checkoutBuyerPerson(session, livemode, flowPerson)
911
+ : null;
912
+ if (buyer === null || typeof sessionId !== "string") {
913
+ request.log.info(
914
+ line("person_unresolved"),
915
+ "checkout terms grant: no Person for this buyer",
916
+ );
917
+ return;
918
+ }
919
+ const outcome = await recordCheckoutConsent({
920
+ workspaceId: buyer.workspaceId,
921
+ personId: buyer.personId,
922
+ sessionId,
923
+ policyVersion: expected.policyVersion,
924
+ });
925
+ request.log.info(line(outcome), "checkout terms grant");
926
+ } catch (err) {
927
+ // The error's class only (a Prisma error's message can carry values).
928
+ request.log.error(
929
+ {
930
+ ...line("failed"),
931
+ errName: err instanceof Error ? err.name : "UnknownError",
932
+ },
933
+ "checkout terms grant failed — continuing",
934
+ );
935
+ }
801
936
  }
802
937
 
803
938
  /**
@@ -50,13 +50,16 @@ import type { RegisteredRoute } from "./openapi/surface-drift.js";
50
50
  import { exampleAeoRoute, scoreAeoRoute } from "./routes/aeo/index.js";
51
51
  import {
52
52
  hubSignInClientRoutes,
53
+ hubSignInPublicRoutes,
53
54
  hubSignInSessionRoutes,
54
55
  } from "./routes/auth/hub-sign-in.js";
55
56
  import { tokenRoutes } from "./routes/auth/tokens.js";
56
57
  import { transactionalEmailRoute } from "./routes/auth/transactional-email.js";
57
58
  import { userRoutes } from "./routes/auth/user.js";
58
59
  import {
60
+ consentAccountRoutes,
59
61
  consentCookieRoute,
62
+ consentSignUpRoute,
60
63
  consentUnsubscribeRoute,
61
64
  } from "./routes/consent/index.js";
62
65
  import { getPageRoute } from "./routes/content/get-page.js";
@@ -980,6 +983,14 @@ export async function build() {
980
983
  // preHandler's hub-credential branch (D6).
981
984
  await authScope.register(hubSignInSessionRoutes);
982
985
 
986
+ // #8765 D5 — the caller's account-level sign-up agreements. Auth only,
987
+ // NOT tenant-guarded: the agreement is about the `User` row, read and
988
+ // written in the deployment's own site workspace whatever site asks.
989
+ // Unconditional — auth and consent are never-gate surfaces — and both
990
+ // are in `HUB_SESSION_ROUTES`, so a spoke reaches them with its member's
991
+ // credential (the account layout's interstitial, D7).
992
+ await authScope.register(consentAccountRoutes);
993
+
983
994
  // Tenant-context routes (multi-tenant module) — auth only. They
984
995
  // establish/inspect the active workspace, so the tenant guard does not
985
996
  // apply to them; each enforces membership in the service layer.
@@ -1378,6 +1389,10 @@ export async function build() {
1378
1389
  await server.register(searchContentRoute);
1379
1390
  await server.register(listPagesRoute);
1380
1391
  await server.register(getPageRoute);
1392
+ // The hub's sign-in page names the spoke that sent a visitor (#8790 D2):
1393
+ // a public read of one client's name + origin from the registry. No auth —
1394
+ // the visitor is signed out by definition when the page asks.
1395
+ await server.register(hubSignInPublicRoutes);
1381
1396
  // Checkout launcher + reader — commerce PRIMITIVES (D-64, #5796; the GET
1382
1397
  // added by #6084). Registered UNCONDITIONALLY and gated at CALL time on
1383
1398
  // the JWT `commerce` claim, the same posture content-write has
@@ -1570,6 +1585,19 @@ export async function build() {
1570
1585
  });
1571
1586
  await unsubscribeScope.register(consentUnsubscribeRoute);
1572
1587
  });
1588
+ // #8765 D2 — the sign-up agreements' evidence, beside the consent floor:
1589
+ // anonymous, keyless, outside every module gate (sign-up and consent are
1590
+ // never-gate surfaces). Its own scope, `signupevidence`, so an anonymous
1591
+ // burst never shares a bucket with the cookie banner's log (SEC-8765-D).
1592
+ // Its main caller is the web's own server, whose cache keeps it to one call
1593
+ // a minute per enabled locale per instance.
1594
+ await server.register(async (signUpEvidenceScope) => {
1595
+ signUpEvidenceScope.register(rateLimitMiddleware, {
1596
+ perIpPerMinute: DEFAULT_PER_IP_PER_MINUTE_STRICT,
1597
+ scope: "signupevidence",
1598
+ });
1599
+ await signUpEvidenceScope.register(consentSignUpRoute);
1600
+ });
1573
1601
 
1574
1602
  // Leads routes — rate-limit middleware scoped to this sub-router. Own
1575
1603
  // default (10/min, pre-#4730) rather than inheriting the wider