create-ailk 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/README.md +21 -1
  2. package/component-catalog.json +220 -8
  3. package/component-catalog.md +21 -9
  4. package/dist/add-module.js +9 -5
  5. package/dist/apply-design.d.ts +9 -0
  6. package/dist/apply-design.js +78 -31
  7. package/dist/derive-architecture.d.ts +8 -1
  8. package/dist/derive-architecture.js +38 -3
  9. package/dist/design-home.d.ts +54 -11
  10. package/dist/design-home.js +160 -22
  11. package/dist/design-package.js +7 -2
  12. package/dist/lib/design-checks.d.ts +6 -1
  13. package/dist/lib/design-checks.js +23 -2
  14. package/dist/lib/env-catalog.js +5 -1
  15. package/dist/module-architecture.js +4 -2
  16. package/dist/programmatic.js +63 -25
  17. package/dist/site-chrome.d.ts +8 -0
  18. package/dist/site-chrome.js +17 -1
  19. package/dist/site-roster.d.ts +4 -2
  20. package/dist/site-roster.js +7 -5
  21. package/dist/spoke-checkout.d.ts +39 -18
  22. package/dist/spoke-checkout.js +177 -236
  23. package/dist/starter-grid.js +16 -0
  24. package/dist/surfaces.js +30 -14
  25. package/dist/sync-routes.d.ts +28 -0
  26. package/dist/sync-routes.js +124 -1
  27. package/dist/template-record.d.ts +4 -3
  28. package/dist/template-record.js +6 -3
  29. package/dist/topology.js +25 -1
  30. package/dist/update.d.ts +6 -1
  31. package/dist/update.js +125 -18
  32. package/package.json +3 -3
  33. package/site-templates/_base/ailk-template.json +2 -2
  34. package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
  35. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  36. package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
  37. package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
  38. package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
  39. package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
  40. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
  41. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
  42. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
  43. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
  44. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
  45. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
  46. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
  47. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
  48. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
  49. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
  50. package/templates/README.md +26 -25
  51. package/templates/apps/api/CLAUDE.md +20 -7
  52. package/templates/apps/api/jest.config.cjs +1 -0
  53. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
  54. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
  55. package/templates/apps/api/src/lib/consent-store.ts +28 -0
  56. package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
  57. package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
  58. package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
  59. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
  60. package/templates/apps/api/src/middleware/auth.ts +13 -6
  61. package/templates/apps/api/src/openapi/spec.ts +30 -3
  62. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
  63. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
  64. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
  65. package/templates/apps/api/src/routes/consent/account.ts +266 -0
  66. package/templates/apps/api/src/routes/consent/index.ts +15 -7
  67. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
  68. package/templates/apps/api/src/routes/content/get-page.ts +4 -0
  69. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
  70. package/templates/apps/api/src/routes/leads/index.ts +161 -7
  71. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
  72. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
  73. package/templates/apps/api/src/routes/webhooks/stripe.ts +154 -19
  74. package/templates/apps/api/src/server.ts +28 -0
  75. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1038 -118
  76. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
  77. package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
  78. package/templates/apps/api/src/services/consent.ts +95 -0
  79. package/templates/apps/api/src/services/lead-routing.ts +337 -8
  80. package/templates/apps/api/src/services/magnet-completion.ts +242 -0
  81. package/templates/apps/api/src/services/person.ts +40 -8
  82. package/templates/apps/api/src/services/plan-offerings.ts +59 -0
  83. package/templates/apps/api/src/services/project-listings.ts +6 -1
  84. package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
  85. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +69 -61
  86. package/templates/apps/mcp/jest.config.cjs +1 -0
  87. package/templates/apps/web/.env.example +14 -0
  88. package/templates/apps/web/CLAUDE.md +139 -3
  89. package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
  90. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
  91. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
  92. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
  93. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
  94. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
  95. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
  96. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
  97. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
  98. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
  99. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
  100. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
  101. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
  102. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
  103. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
  104. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
  105. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
  106. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
  107. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
  114. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
  115. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
  116. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
  117. package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
  119. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
  120. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
  121. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
  122. package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
  123. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
  124. package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
  125. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +293 -0
  126. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +115 -0
  127. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +146 -1
  128. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +310 -0
  129. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +143 -0
  130. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +350 -0
  131. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +29 -3
  132. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  133. package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
  134. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
  135. package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
  136. package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
  137. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  138. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +24 -1
  139. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
  140. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
  141. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
  142. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +386 -0
  143. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +504 -24
  144. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +12 -3
  145. package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
  146. package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
  147. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +184 -41
  148. package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
  149. package/templates/apps/web/app/[locale]/contact/page.tsx +120 -16
  150. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  151. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +1 -1
  152. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
  153. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +106 -1
  154. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
  155. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
  156. package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
  157. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
  158. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  159. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  160. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  161. package/templates/apps/web/app/[locale]/page.tsx +13 -1
  162. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  163. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  164. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
  165. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  166. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  167. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  168. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  169. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
  170. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
  171. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
  172. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
  173. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  174. package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
  175. package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
  176. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
  177. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
  178. package/templates/apps/web/app/api/checkout/[offering]/route.ts +48 -0
  179. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +120 -0
  180. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +86 -0
  181. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
  182. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
  183. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +92 -0
  184. package/templates/apps/web/app/api/webhooks/stripe/route.ts +31 -0
  185. package/templates/apps/web/auth.ts +28 -2
  186. package/templates/apps/web/jest.config.cjs +1 -0
  187. package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
  188. package/templates/apps/web/lib/__tests__/checkout.test.ts +243 -0
  189. package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
  190. package/templates/apps/web/lib/__tests__/deck-page.test.ts +249 -0
  191. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +38 -19
  192. package/templates/apps/web/lib/account-invite.ts +37 -0
  193. package/templates/apps/web/lib/checkout-config.ts +27 -0
  194. package/templates/apps/web/lib/checkout.ts +93 -0
  195. package/templates/apps/web/lib/consent-policies.ts +22 -0
  196. package/templates/apps/web/lib/deck-page.ts +192 -0
  197. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
  198. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
  199. package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
  200. package/templates/apps/web/lib/sign-up-agreements.ts +447 -0
  201. package/templates/apps/web/lib/site-chrome.ts +2 -0
  202. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  203. package/templates/apps/web/messages/en.json +40 -12
  204. package/templates/apps/web/package.json +1 -0
  205. package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
  206. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
  207. package/templates/apps/web/src/lib/lead-write.ts +90 -0
  208. package/templates/apps/web/src/lib/rate-limit.ts +79 -0
  209. package/templates/database/CHANGELOG.md +47 -0
  210. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
  211. package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
  212. package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
  213. package/templates/database/__tests__/seed.test.ts +72 -2
  214. package/templates/database/inbox/schema.prisma +17 -2
  215. package/templates/database/jest.config.cjs +1 -0
  216. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
  217. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
  218. package/templates/database/package.json +1 -1
  219. package/templates/database/seed.ts +60 -0
  220. package/templates/tools/jest/pin-timezone.cjs +34 -0
  221. package/versions.json +34 -34
  222. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
@@ -13,6 +13,10 @@
13
13
  * match, never overwriting a link.
14
14
  * - `recordCheckoutPerson` — the webhook's step: the Person of a completed
15
15
  * hub checkout, with its live-mode customer id.
16
+ * `resolveCheckoutPerson` is the same step answering that Person too, for
17
+ * the checkout's terms grant (#8765 D14); the webhook calls it, and
18
+ * `recordCheckoutPerson` stays as its outcome-only form for the #852
19
+ * suites that pin that outcome.
16
20
  * - `resolveMemberPersonId` — the member reads' Person (#7877 D8): the one
17
21
  * linked to the caller, else the one whose email is the caller's
18
22
  * verified email, linked on first read by `stampPersonUser`'s rule.
@@ -768,6 +772,13 @@ export type CheckoutPersonOutcome =
768
772
  | "person_skipped"
769
773
  | "person_failed";
770
774
 
775
+ /** `recordCheckoutPerson`'s outcome plus the Person it found or created (#8765 D14). */
776
+ export type CheckoutPersonResult = {
777
+ outcome: CheckoutPersonOutcome;
778
+ /** The checkout's Person, or `null` when the step skipped or failed. */
779
+ person: { workspaceId: string; personId: string } | null;
780
+ };
781
+
771
782
  /**
772
783
  * The hub webhook's Person step for a paid `checkout.session.completed` of
773
784
  * its OWN site carrying `metadata.flowCheckoutId`.
@@ -786,8 +797,23 @@ export async function recordCheckoutPerson(input: {
786
797
  customer: unknown;
787
798
  livemode: boolean;
788
799
  }): Promise<CheckoutPersonOutcome> {
800
+ return (await resolveCheckoutPerson(input)).outcome;
801
+ }
802
+
803
+ /**
804
+ * `recordCheckoutPerson`, also answering the Person it found or created —
805
+ * the subject of the checkout's terms grant (#8765 D14), which reads the
806
+ * flow checkout's Person from here and never creates one itself.
807
+ */
808
+ export async function resolveCheckoutPerson(input: {
809
+ flowCheckoutId: string;
810
+ stripeSessionId: string | null | undefined;
811
+ customer: unknown;
812
+ livemode: boolean;
813
+ }): Promise<CheckoutPersonResult> {
814
+ const skipped = { outcome: "person_skipped", person: null } as const;
789
815
  if (typeof input.stripeSessionId !== "string" || input.stripeSessionId === "") {
790
- return "person_skipped";
816
+ return skipped;
791
817
  }
792
818
  try {
793
819
  const checkout = await prisma.flowCheckout.findFirst({
@@ -798,13 +824,13 @@ export async function recordCheckoutPerson(input: {
798
824
  },
799
825
  select: { leadId: true, workspaceId: true },
800
826
  });
801
- if (!checkout) return "person_skipped";
827
+ if (!checkout) return skipped;
802
828
 
803
829
  const lead = await prisma.lead.findFirst({
804
830
  where: { id: checkout.leadId, workspaceId: checkout.workspaceId },
805
831
  select: { email: true },
806
832
  });
807
- if (!lead) return "person_skipped";
833
+ if (!lead) return skipped;
808
834
 
809
835
  const customerId =
810
836
  input.livemode === true && typeof input.customer === "string"
@@ -815,18 +841,24 @@ export async function recordCheckoutPerson(input: {
815
841
  ...(customerId !== undefined && { billingCustomerId: customerId }),
816
842
  });
817
843
  if (!result.ok) {
818
- return result.error.kind === "invalid_email" ? "person_skipped" : "person_failed";
844
+ return result.error.kind === "invalid_email"
845
+ ? skipped
846
+ : { outcome: "person_failed", person: null };
819
847
  }
848
+ const person = {
849
+ workspaceId: checkout.workspaceId,
850
+ personId: result.value.personId,
851
+ };
820
852
  switch (result.value.billing) {
821
853
  case "written":
822
- return "person_written";
854
+ return { outcome: "person_written", person };
823
855
  case "kept":
824
- return "person_kept";
856
+ return { outcome: "person_kept", person };
825
857
  default:
826
- return "person_unchanged";
858
+ return { outcome: "person_unchanged", person };
827
859
  }
828
860
  } catch {
829
- return "person_failed";
861
+ return { outcome: "person_failed", person: null };
830
862
  }
831
863
  }
832
864
 
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The workspace's offerings as plan keys (#8514 D9) — moved here unchanged
3
+ * from `routes/workspaces/plan.ts` by #8757 D4, so the magnet PATCH's
4
+ * save-time offering check and the lead route's submit-time re-check
5
+ * (`services/magnet-completion.ts`) read the same list `GET …/plan-offerings`
6
+ * serves.
7
+ *
8
+ * The workspace's OWN Service then Product pages: `key` is the page slug's
9
+ * last segment when it is an offering key (else the page is skipped), the
10
+ * first page per key wins, at most 200; `name` is the page title. Off the
11
+ * database content adapter only the default workspace has content
12
+ * (`workspaceContentAdapter`), so every other workspace reads `[]`.
13
+ *
14
+ * Never throws: a failed content read answers `{ failed }` with the error's
15
+ * class name only, so a caller can tell "none published" from "could not
16
+ * tell" — a failure is never a pass.
17
+ *
18
+ * Base tree (SEC-8757-D): no `oss: false` marker and no `MODULE_SLICES`
19
+ * entry, because the never-gate lead route reaches it.
20
+ */
21
+ import {
22
+ PLAN_OFFERINGS_MAX,
23
+ planOfferingSchema,
24
+ type PlanOfferingItem,
25
+ } from "@working-theory/validation";
26
+
27
+ import { workspaceContentAdapter } from "./public-site-resolver.js";
28
+
29
+ /** The workspace's Service then Product pages as plan keys (D9). */
30
+ export async function listPlanOfferings(
31
+ workspaceId: string,
32
+ ): Promise<PlanOfferingItem[] | { failed: string }> {
33
+ try {
34
+ const adapter = await workspaceContentAdapter(workspaceId);
35
+ if (!adapter) return [];
36
+ const [services, products] = await Promise.all([
37
+ adapter.listPages("Service"),
38
+ adapter.listPages("Product"),
39
+ ]);
40
+ const offerings: PlanOfferingItem[] = [];
41
+ const seen = new Set<string>();
42
+ const typed = [
43
+ ...services.map((page) => ({ page, type: "service" as const })),
44
+ ...products.map((page) => ({ page, type: "product" as const })),
45
+ ];
46
+ for (const { page, type } of typed) {
47
+ if (offerings.length >= PLAN_OFFERINGS_MAX) break;
48
+ const key = page.slug.split("/").filter(Boolean).pop() ?? "";
49
+ const name = page.title.trim().slice(0, 200);
50
+ if (!planOfferingSchema.safeParse(key).success || name === "" || seen.has(key)) continue;
51
+ seen.add(key);
52
+ offerings.push({ key, name, type });
53
+ }
54
+ return offerings;
55
+ } catch (error) {
56
+ // The error's class name only — never its message, which may name a slug.
57
+ return { failed: error instanceof Error ? error.name : typeof error };
58
+ }
59
+ }
@@ -65,6 +65,7 @@ import {
65
65
  LISTING_ENTRY_TYPE_DEFAULT,
66
66
  PROJECT_LISTING_CONCURRENT_LIMIT,
67
67
  PROJECT_LISTING_NON_TERMINAL_STATUSES,
68
+ RESERVED_FLOW_SLUGS,
68
69
  } from "@working-theory/validation";
69
70
  import type {
70
71
  FlowAddressValue,
@@ -184,6 +185,10 @@ const SLUG_COLLISION_SCAN_LIMIT = 200;
184
185
  * free slug and race to the insert; the loser hits the unique constraint and
185
186
  * is reported as `slug_taken`, exactly as it was when the client supplied the
186
187
  * slug itself.
188
+ *
189
+ * #8661 D4 — a listing's slug becomes its diagnostic flow's slug on approval,
190
+ * so the standard magnets' reserved slugs are treated as taken: a project
191
+ * named "Contact" is stored at `contact-2`.
187
192
  */
188
193
  async function firstFreeSlug(siteId: string, base: string): Promise<string> {
189
194
  const rows = await prisma.projectListing.findMany({
@@ -191,7 +196,7 @@ async function firstFreeSlug(siteId: string, base: string): Promise<string> {
191
196
  select: { slug: true },
192
197
  take: SLUG_COLLISION_SCAN_LIMIT,
193
198
  });
194
- const taken = new Set(rows.map((r) => r.slug));
199
+ const taken = new Set<string | null>([...RESERVED_FLOW_SLUGS, ...rows.map((r) => r.slug)]);
195
200
  if (!taken.has(base)) return base;
196
201
 
197
202
  // Bounded by `taken.size + 1` iterations — one of the first `n` candidates
@@ -246,7 +246,13 @@ export async function resolveCallerSite(
246
246
  * `request.log` only; the routes answer both reasons the same way.
247
247
  */
248
248
  export type CallerWorkspace =
249
- | { kind: "resolved"; workspaceId: string; viaSpoke: boolean }
249
+ | {
250
+ kind: "resolved";
251
+ workspaceId: string;
252
+ viaSpoke: boolean;
253
+ /** #8771 D3 — set exactly when `viaSpoke` is true: the registered spoke's id. */
254
+ spokeId?: string;
255
+ }
250
256
  | { kind: "unresolved"; reason: "spoke-refused" | "alias-unresolved" };
251
257
 
252
258
  /**
@@ -270,6 +276,7 @@ export async function resolveCallerWorkspace(
270
276
  kind: "resolved",
271
277
  workspaceId: caller.workspaceId,
272
278
  viaSpoke: true,
279
+ spokeId: caller.spokeId,
273
280
  };
274
281
  }
275
282
  // Deferred, like every database import in this file.
@@ -276,71 +276,79 @@ describe("MCP catalog ↔ server drift — UNSERVED_MCP_TOOLS subtracts from the
276
276
  });
277
277
  });
278
278
 
279
- describe("MCP catalog ↔ server agreement after a bounded re-read (#7156 AC-1)", () => {
280
- const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
281
-
282
- beforeEach(() => {
283
- process.env.AILK_ENABLED_MODULES = PAID_MODULES;
284
- jest.useFakeTimers();
285
- });
279
+ describe.each([
280
+ ["hub", []],
281
+ ["spoke", ["create_project"]],
282
+ ] as const)(
283
+ "MCP catalog ↔ server agreement after a bounded re-read (#7156 AC-1) — %s seam",
284
+ (_topology, unserved) => {
285
+ const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
286
286
 
287
- afterEach(() => {
288
- resetMcpCommerceClaim();
289
- jest.useRealTimers();
290
- if (priorEnabledModules === undefined) {
291
- delete process.env.AILK_ENABLED_MODULES;
292
- } else {
293
- process.env.AILK_ENABLED_MODULES = priorEnabledModules;
294
- }
295
- });
287
+ beforeEach(() => {
288
+ process.env.AILK_ENABLED_MODULES = PAID_MODULES;
289
+ mockSeam.unserved = unserved;
290
+ jest.useFakeTimers();
291
+ });
296
292
 
297
- it("a new server and listRegisteredTools() match the catalog once the re-read resolves", async () => {
298
- __setMcpFeatureGateForTest("refuse");
299
- await initMcpCommerceClaim({
300
- checkLicense: resolving({
301
- tier: "pro",
302
- features: ["scheduling", "flows-deliverables", "commerce"],
303
- }),
293
+ afterEach(() => {
294
+ resetMcpCommerceClaim();
295
+ mockSeam.unserved = undefined;
296
+ jest.useRealTimers();
297
+ if (priorEnabledModules === undefined) {
298
+ delete process.env.AILK_ENABLED_MODULES;
299
+ } else {
300
+ process.env.AILK_ENABLED_MODULES = priorEnabledModules;
301
+ }
304
302
  });
305
303
 
306
- const bootOptions: McpCatalogOptions = {
307
- addons: { commerce: true },
308
- features: ["scheduling", "flows-deliverables"],
309
- featureGate: "refuse",
310
- };
311
- const bootNames = Object.keys(registeredToolsOf(createMcpServer())).sort();
312
- expect(bootNames).toEqual(
313
- servedCatalog(bootOptions)
304
+ it("a new server and listRegisteredTools() match the catalog once the re-read resolves", async () => {
305
+ __setMcpFeatureGateForTest("refuse");
306
+ await initMcpCommerceClaim({
307
+ checkLicense: resolving({
308
+ tier: "pro",
309
+ features: ["scheduling", "flows-deliverables", "commerce"],
310
+ }),
311
+ });
312
+
313
+ const bootOptions: McpCatalogOptions = {
314
+ addons: { commerce: true },
315
+ features: ["scheduling", "flows-deliverables"],
316
+ featureGate: "refuse",
317
+ };
318
+ const bootNames = Object.keys(
319
+ registeredToolsOf(createMcpServer()),
320
+ ).sort();
321
+ // Both counts below are of the served catalog — the full catalog (20 at
322
+ // boot, 14 after the re-read) minus this seam's UNSERVED_MCP_TOOLS (#8665).
323
+ expect(bootNames).toEqual(
324
+ servedCatalog(bootOptions)
325
+ .map((t) => t.name)
326
+ .sort(),
327
+ );
328
+ expect(bootNames).toHaveLength(20 - UNSERVED_MCP_TOOLS.length);
329
+
330
+ startMcpClaimRefresh({ checkLicense: resolving({ features: [] }) });
331
+ await jest.advanceTimersByTimeAsync(MCP_CLAIM_REFRESH_MS);
332
+
333
+ const refreshedOptions: McpCatalogOptions = {
334
+ addons: { commerce: false },
335
+ features: [],
336
+ featureGate: "refuse",
337
+ };
338
+ const servedNames = servedCatalog(refreshedOptions)
314
339
  .map((t) => t.name)
315
- .sort(),
316
- );
317
- expect(bootNames).toHaveLength(20 - UNSERVED_MCP_TOOLS.length);
318
-
319
- startMcpClaimRefresh({ checkLicense: resolving({ features: [] }) });
320
- await jest.advanceTimersByTimeAsync(MCP_CLAIM_REFRESH_MS);
321
-
322
- const refreshedOptions: McpCatalogOptions = {
323
- addons: { commerce: false },
324
- features: [],
325
- featureGate: "refuse",
326
- };
327
- const catalogNames = getMcpToolCatalog(refreshedOptions)
328
- .map((t) => t.name)
329
- .sort();
330
- expect(catalogNames).toHaveLength(14);
331
-
332
- const servedNames = servedCatalog(refreshedOptions)
333
- .map((t) => t.name)
334
- .sort();
340
+ .sort();
341
+ expect(servedNames).toHaveLength(14 - UNSERVED_MCP_TOOLS.length);
335
342
 
336
- const serverNames = Object.keys(
337
- registeredToolsOf(createMcpServer()),
338
- ).sort();
339
- expect(serverNames).toEqual(servedNames);
343
+ const serverNames = Object.keys(
344
+ registeredToolsOf(createMcpServer()),
345
+ ).sort();
346
+ expect(serverNames).toEqual(servedNames);
340
347
 
341
- const listedNames = listRegisteredTools()
342
- .map((t) => t.name)
343
- .sort();
344
- expect(listedNames).toEqual(servedNames);
345
- });
346
- });
348
+ const listedNames = listRegisteredTools()
349
+ .map((t) => t.name)
350
+ .sort();
351
+ expect(listedNames).toEqual(servedNames);
352
+ });
353
+ },
354
+ );
@@ -1,5 +1,6 @@
1
1
  module.exports = {
2
2
  preset: 'ts-jest',
3
+ globalSetup: require.resolve('../../tools/jest/pin-timezone.cjs'),
3
4
  testEnvironment: 'node',
4
5
  // Workaround: @hono/node-server@1.19.14 (transitive via
5
6
  // @modelcontextprotocol/sdk's StreamableHTTPServerTransport) calls
@@ -92,6 +92,20 @@ CONTENT_ADAPTER=mdx
92
92
  # origin the browser never sees.
93
93
  # NEXT_PUBLIC_AILK_API_URL=http://localhost:3002
94
94
 
95
+ # =============================================================================
96
+ # The /contact lead's site marker (server-side only) — #8747
97
+ # =============================================================================
98
+ # This site's slug. The /contact Server Action writes it into every lead as
99
+ # payload.site (with payload.page = "contact"), so a hub serving several sites
100
+ # — or one site routing leads to two workspaces — can tell where a lead came
101
+ # from. Use the site's short name, lowercase letters, digits and hyphens (for
102
+ # example "wendihenry"); apps/mcp's capture_lead reads the same variable. Unset
103
+ # or blank leaves the lead with no site marker — nothing is invented, and a
104
+ # visitor-sent "site" field is dropped. The marker is attribution only: the
105
+ # lead route is public, so never gate access on it. The action sends no
106
+ # siteId; the API names the workspace itself (by AILK_SPOKE_KEY on a hub).
107
+ # AILK_LEAD_SITE=
108
+
95
109
  # =============================================================================
96
110
  # Listicle directory (#7521) — switched on by the site's listing key (#7872)
97
111
  # =============================================================================
@@ -110,6 +110,26 @@ function — the page under test throws instead of taking a branch. Declare
110
110
  every predicate the pages call, binding the two reset ones to the same
111
111
  `jest.fn()` when the case does not need them to disagree.
112
112
 
113
+ **Sign-up agreements are refused and written on the server (#8765).**
114
+ `lib/sign-up-agreements.ts` is a Better Auth plugin plus two database hooks
115
+ that `auth.ts` installs. While `isSignUpOpen()`, `POST /api/auth/sign-up/email`
116
+ without every sign-up agreement in its `agreements` body key answers
117
+ `400 AGREEMENTS_REQUIRED` before the email lookup (so the answer never depends
118
+ on whether the address is taken), and `503 AGREEMENTS_UNAVAILABLE` when the
119
+ API's `GET /v1/consent/sign-up` evidence cannot be read (60 s fresh, 10 min
120
+ stale-on-error, one cache entry per enabled locale). An accepted sign-up — or
121
+ an OAuth sign-up whose state carries the card's agreements — writes one
122
+ `sign_up` grant per agreement through the auth client's transaction
123
+ (`prismaAdapter(…, { transaction: true })`), so the `User`, its `Account` and
124
+ the grants commit together or not at all; this hook is the ledger's only
125
+ writer outside `apps/api`'s `services/consent.ts`, and it writes only rows of
126
+ a user it is creating. A user created without agreements (a first social
127
+ sign-in from `/sign-in`) has none, and the account interstitial asks them
128
+ (`(authed)/account/layout.tsx`, which fails closed on any read but a 2xx or an
129
+ older hub's 404). The carried `__Host-ailk-signup-agreements` cookie is
130
+ retired. Policy links on every agreement surface come from
131
+ `lib/consent-policies.ts`'s `consentPoliciesFor(locale)`.
132
+
113
133
  ## Contact page
114
134
 
115
135
  The contact page (`apps/web/app/[locale]/contact/page.tsx`) renders the
@@ -123,15 +143,123 @@ the site's contact email, never a form that would report a message as sent
123
143
  when it reached nobody. A failed status read counts as accepting, so the
124
144
  form renders and its submit reports honestly.
125
145
 
146
+ The form's fields come from the workspace's **Contact form magnet** (#8661):
147
+ every workspace is seeded one, live at slug `contact`, and the page is rendered
148
+ on every request (`force-dynamic`) and reads it through the public flow read,
149
+ `flows.get("contact")`, shared across requests for 15 seconds per server
150
+ instance (#8731) — so a burst of views is one hub read, and an Admin edit shows
151
+ within 15 seconds. The capture-status read is shared the same way. The API resolves the workspace (the site alias
152
+ keyless, a spoke by its key), so the page names none. `contact-form-magnet.ts`
153
+ maps the config's first step onto the block's fields, terms statement and
154
+ submit label. The order is: a usable magnet (its first step has `name` and
155
+ `email` fields), else the page's `contact-form` section fields when it
156
+ declares any, else the built-in form, which is the seeded config itself. Any
157
+ failed read (absent, draft, a Free build with no flow route, a throw) is the
158
+ fallback, never an error. A magnet-driven post carries a hidden
159
+ `source: "flow:contact"`, so it is an Inbox submission of the Contact form
160
+ with every answer shown; a fallback post is `contact-form` (#8747). The terms statement
161
+ links Terms, Privacy Policy and Working Agreement from a page-built
162
+ `policies` map, and `captureLeadAction` refuses a post without
163
+ `consent=true` whatever its source (#8730 D2 — the hidden `source` picks the
164
+ stamp, never the gate) and keeps only the contact sources
165
+ (`CONTACT_LEAD_SOURCES`: `contact-form`, `conversational-intake`,
166
+ `flow:contact`). An accepted post sends `consent: true` to the API, which
167
+ refuses a contact lead without it (`400 consent_required`, shown as the same
168
+ field error) and records `payload.consent` itself.
169
+
170
+ The action writes through `src/lib/lead-write.ts` (#8747), the site's lead
171
+ writer: `payload.site` is `AILK_LEAD_SITE` (none when unset) and
172
+ `payload.page` is `contact`, both spread last so no form field overrides them,
173
+ and no `siteId` is sent. Before the write it runs a per-visitor limit
174
+ (`src/lib/rate-limit.ts`, 30 posts per 10 minutes, per instance; skipped when
175
+ no client address is readable), because the hub sees only this site's egress
176
+ address. An API that cannot be reached — a
177
+ thrown submit, a 502 or a 504 — renders the contact email, like the 503
178
+ `lead_capture_not_configured`.
179
+
180
+ **The account link (#8757 D8, D9).** When the Contact form magnet is set to
181
+ `invite` in Admin, the API's 201 carries `next: { completion: "invite" }` and
182
+ the action answers `{ kind: "success", next: { href, label } }`: the success
183
+ alert gains "Track this in your account" (`contact.trackInAccount`, testid
184
+ `contact-success-next`), built by `lib/account-invite.ts` from
185
+ `SIGN_IN_PROVIDER` — `site` links `/sign-in?next=<locale>/account&email=…`,
186
+ `hub` links `/api/hub-session/sign-in?next=/account&email=…`, `none` (or an
187
+ email `accountEmailHintSchema` refuses) gives no link. Anything else,
188
+ `checkout` included (Phase 1), is exactly `{ kind: "success" }`. The link
189
+ signs no one in: `/sign-in` reads one valid `email` as the card's starting
190
+ value (`AuthCard`'s `defaultEmail`), the spoke's
191
+ `/api/hub-session/sign-in` forwards it as `login_hint`, and the hub's
192
+ authorize route carries a valid hint only onto its no-session `/sign-in`
193
+ redirect, never into a code, cookie or continuation ref (a repeated
194
+ `login_hint` is `400 invalid_request`). `email` and `login_hint` are in the
195
+ telemetry scrub lists (`lib/telemetry-scrub.ts`, `packages/analytics`).
196
+
197
+ **The same link on the magnet walk and /careers (#8771).** The `/careers/<slug>` job page's action reads the API's `next` the same way (`apply.actions.ts`): `invite` returns the link and `contact.trackInAccount`, anything else — or a throw after the write — exactly `{ kind: "success" }`, so an error never invites a duplicate application. The walk (`/flows/<slug>`, `FlowStepperClient`) tracks the first `mapsTo: "email"` answer it submitted; on a completing response with `next: { completion: "invite" }` it asks the server action `accountInviteLinkAction` (the builder reads the generated topology, so it never runs in the client) and shows the link inside `FlowStepper`'s finished card (its `completedAction` slot), or — for a flow with a `thankYou` block — writes one `sessionStorage` item, `ailk:account-invite`, that `/thanks/<slug>`'s `AccountInviteLink` reads once and removes (the page passes it to `renderPage` as `trailing`, so it sits inside `<main>`). Each link sits in a `data-sentry-block` element (Session Replay records `href`s); no walk URL ever carries the email.
198
+
199
+ A `contact-form` section in the `column` variant (the minimalism design's
200
+ Contact page) renders one centred heading, one line and the form, and the
201
+ page passes the block `look="column"` (the stacked look, CSS in
202
+ `packages/theme/src/tokens.css`) and `consentGate="on-submit"`: Send stays
203
+ enabled and an unticked submit shows the terms error. Every other look keeps
204
+ Send disabled until the box is ticked.
205
+
126
206
  The chat-style intake is not mounted by default.
127
207
  `ConversationalIntakeWrapper.tsx` remains for a site that prefers it: it hosts
128
208
  `@working-theory/ui`'s `ConversationalIntake` block (contract in
129
- `packages/ui/src/blocks/conversational-intake/README.md`), submits through the
130
- same `captureLeadAction`, and on Skip or a failed submit swaps to
209
+ `packages/ui/src/blocks/conversational-intake/README.md`), asks for the terms
210
+ in its own last turn (#8730 D2), submits through the same
211
+ `captureLeadAction` with `consent=true` only when the accept pill was tapped,
212
+ and on a declined terms turn, Skip or a failed submit swaps to
131
213
  `LeadCaptureForm`, prefilled with what was already captured. The canonical
132
214
  embedding for it is behind the hero CTA's lightbox gate
133
215
  (`packages/ui/src/sections/FormLightbox.tsx`, #4577), not a whole-page mount.
134
216
 
217
+ ## Spoke checkout
218
+
219
+ A spoke that carries `commerce` sells through the standard spoke checkout
220
+ (#8748), six files every such spoke shares and `create-ailk update` keeps
221
+ current. A hub, or a spoke without `commerce`, never gets them: the hub's
222
+ checkout is `apps/api`'s.
223
+
224
+ - **The files.** `app/api/checkout/[offering]/route.ts` (the buy route),
225
+ `app/api/webhooks/stripe/route.ts`, `app/[locale]/checkout/thank-you/page.tsx`,
226
+ `app/[locale]/checkout/unavailable/page.tsx`, `lib/checkout.ts` (the
227
+ standard) and `lib/checkout-config.ts` (the site's data). Every guarantee —
228
+ live-key admission, the buy guards, the webhook's verification and filters,
229
+ the duplicate guard — lives in `@working-theory/spoke-checkout` and arrives by
230
+ a package bump.
231
+ - **One data file.** `lib/checkout-config.ts` is the one file a site edits:
232
+ the `const SITE` stamp and the `offerings` map, no import and no code.
233
+ `create-ailk update` never rewrites it. Name an offering's slug after the last
234
+ segment of the Service or Product page that sells it.
235
+ - **A slug that is not an offering answers 404**, before any guard, limiter,
236
+ admission or buyer lookup. Every other refusal is a 303 to
237
+ `/checkout/unavailable`.
238
+ - **Spoke only.** Both routes answer 404 and both pages call `notFound()`
239
+ unless `lib/topology.generated.ts`'s `TOPOLOGY` is `"spoke"`, so this
240
+ monorepo's hub-shaped `apps/web` serves none of them.
241
+ - **One sender, two importers.** `lib/checkout.ts`'s private fulfilment is the
242
+ one `apps/web` file that sends an `Idempotency-Key`: one `leads.submit` per
243
+ paid session, `payload.site` set to the checkout stamp (never
244
+ `AILK_LEAD_SITE`), the session id as the key, and a body that must stay
245
+ byte-identical between attempts, because the hub replays only an identical
246
+ body. The payload's last key, `termsAccepted`, is the session's `ailk_terms`
247
+ stamp when the buyer accepted Stripe's terms box, else `null`; the hub writes
248
+ the buyer's checkout consent grant from it (#8778). The hub leaves that key
249
+ out of its replay comparison, so a redelivery across the update still
250
+ replays. Only the two route files may import `lib/checkout.ts` or the package
251
+ (`tests/architecture/idempotency-key-producers.test.ts`, `architecture.yaml`);
252
+ a spoke that signs in through its hub adds a third, its rendered billing
253
+ action, which its own `architecture.yaml` admits. The thank-you page may
254
+ import the package for `readPaidCheckoutEmail` alone, never
255
+ `lib/checkout.ts` (#8757 D13): it reads `session_id`, and for a paid
256
+ session of this site under 24 hours old shows "Open your account" with the
257
+ buyer's email (`accountInviteHref`), else the same link with no email. Each
258
+ visitor gets 10 reads per 10 minutes (`src/lib/rate-limit.ts`); the page
259
+ sends no referrer, renders dynamically and never touches a cookie.
260
+ `spokeCheckoutConfig` reads only `site` and `offerings` from the site's file,
261
+ so admission always reads `process.env`.
262
+
135
263
  ## The same-origin relay (`/v1/*`)
136
264
 
137
265
  A visitor's browser never calls the hub. Lead capture, newsletter signup, callback, scheduling and the commerce invoice call post to this site's own `/v1/...` address, and `app/v1/[...path]/route.ts` hands each request to `lib/relay.ts` (`handleRelay`), which forwards it server-side through `@working-theory/api-client`'s `relayToApi` with the site's own `AILK_SPOKE_KEY` when it has one (#7644). The browser never holds the key, so a hub can refuse unknown callers (#7633) without taking a site's forms down.
@@ -158,4 +286,12 @@ A visitor's browser never calls the hub. Lead capture, newsletter signup, callba
158
286
  - Don't reach into `apps/api` or `apps/mcp` from `apps/web`. They're peer apps — talk via HTTP/MCP.
159
287
  - Don't commit images to `screenshots/` — only the `.gitkeep` is tracked.
160
288
 
161
- Last updated: 2026-10-05 — doc-sync: added § The same-origin relay (#7644) — verified against `apps/web/lib/relay.ts` and `apps/web/app/v1/[...path]/route.ts`.
289
+ Last updated: 2026-10-16 — § Spoke checkout: the purchase lead's `termsAccepted` key, the stamp of the terms the buyer accepted, from which the hub writes the checkout consent grant (#8778) — verified against `lib/checkout.ts` and `packages/spoke-checkout/src/webhook.ts`.
290
+
291
+ Prior: 2026-10-16 — § Spoke checkout: the thank-you page reads the paid session's email for "Open your account" (#8757 D13, #8800) — verified against `app/[locale]/checkout/thank-you/page.tsx` and `packages/spoke-checkout/src/paid-email.ts`.
292
+
293
+ Prior: 2026-10-15 — § The `(auth)` route group: sign-up agreements are refused and written on the server, the carried cookie is retired, and the account interstitial asks a user with no current grant (#8765) — verified against `lib/sign-up-agreements.ts`, `auth.ts`, `lib/consent-policies.ts` and `app/[locale]/(authed)/account/layout.tsx`.
294
+
295
+ Prior: 2026-10-15 — § Spoke checkout: the standard spoke checkout files, the one data file, the 404 and the fulfilment's one-key rule (#8748) — verified against `app/api/checkout/[offering]/route.ts`, `app/api/webhooks/stripe/route.ts`, `app/[locale]/checkout/*/page.tsx`, `lib/checkout.ts` and `lib/checkout-config.ts`.
296
+
297
+ Prior: 2026-10-15 — § Contact page: the account link an `invite` magnet adds, and the sign-in email hint (#8757).
@@ -24,10 +24,14 @@ const catalog = msgs as unknown as Record<string, Record<string, string>>;
24
24
  // getTranslations({ namespace, locale }) — renderPage (post-migration)
25
25
  export const getTranslations = async (
26
26
  arg: string | { namespace?: string; locale?: string },
27
- ): Promise<(key: string) => string> => {
27
+ ): Promise<((key: string) => string) & { has: (key: string) => boolean }> => {
28
28
  const ns = typeof arg === "string" ? arg : (arg.namespace ?? "");
29
29
  const messages = catalog[ns];
30
- return (key: string): string => messages?.[key] ?? `${ns}.${key}`;
30
+ const t = (key: string): string => messages?.[key] ?? `${ns}.${key}`;
31
+ // next-intl's `t.has` — an optional catalog key (#8790's prospect line) is
32
+ // present only when the site added it.
33
+ t.has = (key: string): boolean => messages?.[key] !== undefined;
34
+ return t;
31
35
  };
32
36
 
33
37
  export const getRequestConfig = (fn: unknown): unknown => fn;
@@ -141,9 +141,9 @@ describe("the (auth) layout", () => {
141
141
  expect(document.querySelector(".ailk-footer")).not.toBeInTheDocument();
142
142
  });
143
143
 
144
- it("renders the brand mark and the slim footer's three links", async () => {
144
+ it("renders no brand row (the mark is in each card, #8790 D3) and the slim footer's three links", async () => {
145
145
  await renderLayout();
146
- expect(screen.getByTestId("auth-shell-brand")).toBeInTheDocument();
146
+ expect(screen.queryByTestId("auth-shell-brand")).not.toBeInTheDocument();
147
147
  const footer = screen.getByTestId("auth-shell-footer");
148
148
  expect(footer.querySelectorAll("a")).toHaveLength(3);
149
149
  expect(
@@ -193,9 +193,10 @@ describe("the moved sign-in card", () => {
193
193
  expect(screen.queryByTestId("auth-forgot")).not.toBeInTheDocument();
194
194
  });
195
195
 
196
- it("renders no logo inside the card — the mark is the shell's", async () => {
196
+ it("renders the site's mark inside the card, not in the shell (#8790 D3)", async () => {
197
197
  await renderPage(SignInPage);
198
- expect(screen.queryByTestId("auth-card-logo")).not.toBeInTheDocument();
198
+ expect(screen.getByTestId("auth-card-logo")).toBeInTheDocument();
199
+ expect(screen.queryByTestId("auth-shell-brand")).not.toBeInTheDocument();
199
200
  });
200
201
  });
201
202
 
@@ -32,12 +32,14 @@ import {
32
32
  type AuthSubmitData,
33
33
  } from "@working-theory/ui/blocks";
34
34
  import { createAuthClient } from "better-auth/react";
35
- import type { ReactElement } from "react";
35
+ import type { ReactElement, ReactNode } from "react";
36
36
  import { useState } from "react";
37
37
 
38
38
  const authClient = createAuthClient();
39
39
 
40
40
  export interface ForgotPasswordCardProps {
41
+ /** The site's mark, centred in the card at 48px (#8790 D3). */
42
+ logo?: ReactNode;
41
43
  /** Absolute same-origin URL of `/reset-password`, resolved server-side. */
42
44
  resetUrl: string;
43
45
  /** Href for the card footer's "Back to sign in". */
@@ -53,6 +55,7 @@ export function ForgotPasswordCard({
53
55
  signInHref,
54
56
  labels,
55
57
  errorLabels,
58
+ logo,
56
59
  }: ForgotPasswordCardProps): ReactElement {
57
60
  const [sent, setSent] = useState(false);
58
61
  const [sentTo, setSentTo] = useState("");
@@ -85,6 +88,7 @@ export function ForgotPasswordCard({
85
88
  return (
86
89
  <div className="flex flex-col gap-4" data-testid="forgot-password-page">
87
90
  <AuthCard
91
+ {...(logo !== undefined ? { logo } : {})}
88
92
  intent={sent ? "sent" : "forgot"}
89
93
  signInHref={signInHref}
90
94
  labels={labels}
@@ -40,6 +40,7 @@ import type { ReactElement } from "react";
40
40
  import { resetSendConfigured } from "../../../../lib/auth-env";
41
41
 
42
42
  import { ForgotPasswordCard } from "./forgot-password-card";
43
+ import { siteLogo } from "../../../../lib/site-logo";
43
44
 
44
45
  interface Props {
45
46
  params: Promise<{ locale: string }>;
@@ -100,6 +101,7 @@ export default async function ForgotPasswordPage({
100
101
 
101
102
  return (
102
103
  <ForgotPasswordCard
104
+ logo={siteLogo()}
103
105
  resetUrl={resetUrl}
104
106
  signInHref={signInHref}
105
107
  labels={labels}