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
@@ -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
+ }
@@ -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.
@@ -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
@@ -124,9 +144,11 @@ when it reached nobody. A failed status read counts as accepting, so the
124
144
  form renders and its submit reports honestly.
125
145
 
126
146
  The form's fields come from the workspace's **Contact form magnet** (#8661):
127
- every workspace is seeded one, live at slug `contact`, and the page reads it
128
- on every request (`force-dynamic`) through the public flow read,
129
- `flows.get("contact")` — the API resolves the workspace (the site alias
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
130
152
  keyless, a spoke by its key), so the page names none. `contact-form-magnet.ts`
131
153
  maps the config's first step onto the block's fields, terms statement and
132
154
  submit label. The order is: a usable magnet (its first step has `name` and
@@ -135,11 +157,44 @@ declares any, else the built-in form, which is the seeded config itself. Any
135
157
  failed read (absent, draft, a Free build with no flow route, a throw) is the
136
158
  fallback, never an error. A magnet-driven post carries a hidden
137
159
  `source: "flow:contact"`, so it is an Inbox submission of the Contact form
138
- with every answer shown; a fallback post stays `web`. The terms statement
160
+ with every answer shown; a fallback post is `contact-form` (#8747). The terms statement
139
161
  links Terms, Privacy Policy and Working Agreement from a page-built
140
162
  `policies` map, and `captureLeadAction` refuses a post without
141
- `consent=true` (the intake excepted) and keeps only the sources `web`,
142
- `conversational-intake` and `flow:contact`.
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.
143
198
 
144
199
  A `contact-form` section in the `column` variant (the minimalism design's
145
200
  Contact page) renders one centred heading, one line and the form, and the
@@ -151,12 +206,60 @@ Send disabled until the box is ticked.
151
206
  The chat-style intake is not mounted by default.
152
207
  `ConversationalIntakeWrapper.tsx` remains for a site that prefers it: it hosts
153
208
  `@working-theory/ui`'s `ConversationalIntake` block (contract in
154
- `packages/ui/src/blocks/conversational-intake/README.md`), submits through the
155
- 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
156
213
  `LeadCaptureForm`, prefilled with what was already captured. The canonical
157
214
  embedding for it is behind the hero CTA's lightbox gate
158
215
  (`packages/ui/src/sections/FormLightbox.tsx`, #4577), not a whole-page mount.
159
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
+
160
263
  ## The same-origin relay (`/v1/*`)
161
264
 
162
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.
@@ -183,4 +286,12 @@ A visitor's browser never calls the hub. Lead capture, newsletter signup, callba
183
286
  - Don't reach into `apps/api` or `apps/mcp` from `apps/web`. They're peer apps — talk via HTTP/MCP.
184
287
  - Don't commit images to `screenshots/` — only the `.gitkeep` is tracked.
185
288
 
186
- Last updated: 2026-10-14 — § Contact page: the Contact form magnet read, the fallback order and the `column` look (#8661).
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}
@@ -24,7 +24,6 @@ import { headers } from "next/headers";
24
24
  import { getTranslations } from "next-intl/server";
25
25
  import type { ReactElement, ReactNode } from "react";
26
26
 
27
- import { siteLogo } from "../../../lib/site-logo";
28
27
 
29
28
  interface Props {
30
29
  children: ReactNode;
@@ -51,19 +50,9 @@ export default async function AuthLayout({
51
50
 
52
51
  return (
53
52
  <AuthShell
54
- brand={
55
- /*
56
- * The mark is clamped to 28px the way `BrandLogo` clamps it inside
57
- * Nav: `siteLogo()` emits an <img> with a height ATTRIBUTE and no
58
- * width, and an SVG with no intrinsic size then stretches to the
59
- * container — it rendered 48 x 350 here before the clamp. The shell
60
- * owns the row's geometry; the site owns its mark's size.
61
- */
62
- <div className="inline-flex h-7 items-center [&>img]:h-full [&>img]:w-auto [&>img]:object-contain">
63
- {siteLogo()}
64
- </div>
65
- }
66
- brandHref={localeHref(locale, "/")}
53
+ // No brand row (#8790 D3): every auth card now carries the site's mark
54
+ // centred at 48px (`AuthCard`'s `logo` slot), so the shell's top-start
55
+ // corner is free for a page's own affordance (the sign-in's back link).
67
56
  footerLinks={footerLinks}
68
57
  languageSwitcher={
69
58
  <LocaleSwitcherNav
@@ -39,6 +39,7 @@ import type { ReactElement } from "react";
39
39
  import { resetSendConfigured } from "../../../../lib/auth-env";
40
40
 
41
41
  import { ResetPasswordCard } from "./reset-password-card";
42
+ import { siteLogo } from "../../../../lib/site-logo";
42
43
 
43
44
  interface Props {
44
45
  params: Promise<{ locale: string }>;
@@ -122,6 +123,7 @@ export default async function ResetPasswordPage({
122
123
 
123
124
  return (
124
125
  <ResetPasswordCard
126
+ logo={siteLogo()}
125
127
  token={token}
126
128
  signInHref={signInHref}
127
129
  labels={labels}
@@ -23,12 +23,14 @@ import {
23
23
  type AuthSubmitData,
24
24
  } from "@working-theory/ui/blocks";
25
25
  import { createAuthClient } from "better-auth/react";
26
- import type { ReactElement } from "react";
26
+ import type { ReactElement, ReactNode } from "react";
27
27
  import { useState } from "react";
28
28
 
29
29
  const authClient = createAuthClient();
30
30
 
31
31
  export interface ResetPasswordCardProps {
32
+ /** The site's mark, centred in the card at 48px (#8790 D3). */
33
+ logo?: ReactNode;
32
34
  /** The `?token=` Better Auth redirected back with. May be empty. */
33
35
  token: string;
34
36
  /** Where a completed reset lands. */
@@ -60,6 +62,7 @@ export function ResetPasswordCard({
60
62
  labels,
61
63
  errorLabels,
62
64
  navigate = defaultNavigate,
65
+ logo,
63
66
  }: ResetPasswordCardProps): ReactElement {
64
67
  const [errors, setErrors] = useState<{
65
68
  password?: string;
@@ -104,6 +107,7 @@ export function ResetPasswordCard({
104
107
  return (
105
108
  <div className="flex flex-col gap-4" data-testid="reset-password-page">
106
109
  <AuthCard
110
+ {...(logo !== undefined ? { logo } : {})}
107
111
  intent="reset"
108
112
  signInHref={signInHref}
109
113
  labels={labels}