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
@@ -0,0 +1,243 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+
5
+ /**
6
+ * The standard spoke checkout (#8748 D6; spec
7
+ * docs/specs/8748-a-standard-spoke-checkout-route-spec.md AC-2, AC-3, AC-4).
8
+ *
9
+ * `./checkout-config` — the site's own data — is mocked with a fixture in
10
+ * every case, so this suite passes against any shape of the site's file,
11
+ * including a pre-#8748 one that also carries `siteBaseUrl` and `fulfil`.
12
+ * The hub's `leads.submit` is the one external boundary mocked: the
13
+ * fulfilment must post one lead marked with the site, under the session id as
14
+ * its Idempotency-Key, with a body that stays byte-identical between attempts
15
+ * so the hub can replay it.
16
+ */
17
+ import { readFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+
20
+ const mockSubmit = jest.fn();
21
+ jest.mock("@working-theory/api-client", () => ({
22
+ __esModule: true,
23
+ leads: { submit: (...args: unknown[]) => mockSubmit(...args) },
24
+ }));
25
+
26
+ const OFFERINGS = {
27
+ starter: { kind: "fixed", lookupKey: "acme_starter", cancelPath: "/" },
28
+ };
29
+ const mockSite: { value: Record<string, unknown> } = {
30
+ value: { site: "acme", offerings: OFFERINGS },
31
+ };
32
+ // eslint-disable-next-line no-restricted-syntax -- #507: the site's checkout-config.ts is the site's own data, not this suite's subject; a fixture stands in for it so the suite holds against any shape a site's file has (#8748 AC-2).
33
+ jest.mock("../checkout-config", () => ({
34
+ __esModule: true,
35
+ get checkoutConfig() {
36
+ return mockSite.value;
37
+ },
38
+ }));
39
+
40
+ const mockTopology: { value: "hub" | "spoke" } = { value: "spoke" };
41
+ // eslint-disable-next-line no-restricted-syntax -- #507: the topology is a generated constant (#7426), not a runtime input; the seam IS the boundary between a hub and a spoke build, and no env or HTTP surface selects it.
42
+ jest.mock("../topology.generated", () => ({
43
+ __esModule: true,
44
+ get TOPOLOGY() {
45
+ return mockTopology.value;
46
+ },
47
+ }));
48
+
49
+ type CheckoutModule = typeof import("../checkout");
50
+
51
+ /** `lib/checkout.ts`, loaded fresh against the current site fixture. */
52
+ function load(site: Record<string, unknown> = { site: "acme", offerings: OFFERINGS }): CheckoutModule {
53
+ mockSite.value = site;
54
+ let loaded!: CheckoutModule;
55
+ jest.isolateModules(() => {
56
+ loaded = require("../checkout") as CheckoutModule;
57
+ });
58
+ return loaded;
59
+ }
60
+
61
+ /** A test-mode completion as the package hands it to `fulfil`. */
62
+ const COMPLETION = {
63
+ idempotencyKey: "cs_test_1",
64
+ sessionId: "cs_test_1",
65
+ offering: "starter",
66
+ kind: "fixed" as const,
67
+ mode: "test" as const,
68
+ livemode: false,
69
+ paymentStatus: "paid" as const,
70
+ amountTotal: 4900,
71
+ currency: "usd",
72
+ email: "b@example.com",
73
+ customerId: "cus_test_1",
74
+ termsOfService: null,
75
+ termsStamp: null,
76
+ metadata: { site: "acme", offering: "starter", kind: "fixed" },
77
+ };
78
+
79
+ /**
80
+ * `renderConfig`'s body for COMPLETION, key order included (NFR5), plus the
81
+ * session's terms consent as its last key (#8778).
82
+ */
83
+ const EXPECTED_BODY =
84
+ '{"siteId":"acme","source":"web","email":"b@example.com","payload":{"site":"acme","page":"checkout","offering":"starter","checkoutKind":"fixed","mode":"test","amountTotal":4900,"currency":"usd","stripeCustomerId":"cus_test_1","checkoutSession":"cs_test_1","termsAccepted":null}}';
85
+
86
+ const SOURCE = readFileSync(join(__dirname, "..", "checkout.ts"), "utf8");
87
+ const ENV = process.env;
88
+
89
+ beforeEach(() => {
90
+ mockSubmit.mockReset();
91
+ mockSubmit.mockResolvedValue({ ok: true, status: 201 });
92
+ process.env = { ...ENV, AILK_SPOKE_KEY: "spoke-key-fixture" };
93
+ mockTopology.value = "spoke";
94
+ });
95
+
96
+ afterAll(() => {
97
+ process.env = ENV;
98
+ });
99
+
100
+ describe("spokeCheckoutConfig (#8748 AC-2, AC-4)", () => {
101
+ it("exports exactly spokeCheckoutConfig, checkoutServed and checkoutNotServed", () => {
102
+ expect(Object.keys(load()).sort()).toEqual([
103
+ "checkoutNotServed",
104
+ "checkoutServed",
105
+ "spokeCheckoutConfig",
106
+ ]);
107
+ });
108
+
109
+ it("has exactly site, offerings, siteBaseUrl and fulfil, the offerings being the site's own object", () => {
110
+ const { spokeCheckoutConfig } = load();
111
+ expect(Object.keys(spokeCheckoutConfig).sort()).toEqual([
112
+ "fulfil",
113
+ "offerings",
114
+ "site",
115
+ "siteBaseUrl",
116
+ ]);
117
+ expect(spokeCheckoutConfig.site).toBe("acme");
118
+ expect(spokeCheckoutConfig.offerings).toBe(OFFERINGS);
119
+ });
120
+
121
+ it("takes only site and offerings from a site file that also carries env, siteBaseUrl and fulfil (SEC-8748-B)", async () => {
122
+ const siteFulfil = jest.fn();
123
+ const { spokeCheckoutConfig } = load({
124
+ site: "acme",
125
+ offerings: OFFERINGS,
126
+ env: { STRIPE_MODE: "live" },
127
+ siteBaseUrl: "https://elsewhere.example",
128
+ fulfil: siteFulfil,
129
+ });
130
+ expect(Object.keys(spokeCheckoutConfig).sort()).toEqual([
131
+ "fulfil",
132
+ "offerings",
133
+ "site",
134
+ "siteBaseUrl",
135
+ ]);
136
+ expect("env" in spokeCheckoutConfig).toBe(false);
137
+ expect(spokeCheckoutConfig.siteBaseUrl).not.toBe("https://elsewhere.example");
138
+ expect(spokeCheckoutConfig.fulfil).not.toBe(siteFulfil);
139
+ await spokeCheckoutConfig.fulfil(COMPLETION);
140
+ expect(siteFulfil).not.toHaveBeenCalled();
141
+ expect(mockSubmit).toHaveBeenCalledTimes(1);
142
+ });
143
+
144
+ it("reads no Stripe setting and imports no Stripe client (AC-4)", () => {
145
+ expect(SOURCE).not.toContain("STRIPE_");
146
+ expect(SOURCE).not.toContain("stripe-client");
147
+ });
148
+ });
149
+
150
+ describe("checkoutServed / checkoutNotServed (#8748 D11)", () => {
151
+ it("serves on a spoke only", () => {
152
+ const { checkoutServed } = load();
153
+ mockTopology.value = "spoke";
154
+ expect(checkoutServed()).toBe(true);
155
+ mockTopology.value = "hub";
156
+ expect(checkoutServed()).toBe(false);
157
+ });
158
+
159
+ it("answers 404 with no-store", () => {
160
+ const res = load().checkoutNotServed();
161
+ expect(res.status).toBe(404);
162
+ expect(res.headers.get("cache-control")).toBe("no-store");
163
+ });
164
+ });
165
+
166
+ describe("the fulfilment (#8748 AC-3)", () => {
167
+ it("posts one lead marked with the site, under the session id as its Idempotency-Key", async () => {
168
+ await load().spokeCheckoutConfig.fulfil(COMPLETION);
169
+ expect(mockSubmit).toHaveBeenCalledTimes(1);
170
+ const [body, init] = mockSubmit.mock.calls[0] as [unknown, unknown];
171
+ expect(body).toEqual({
172
+ siteId: "acme",
173
+ source: "web",
174
+ email: "b@example.com",
175
+ payload: {
176
+ site: "acme",
177
+ page: "checkout",
178
+ offering: "starter",
179
+ checkoutKind: "fixed",
180
+ mode: "test",
181
+ amountTotal: 4900,
182
+ currency: "usd",
183
+ stripeCustomerId: "cus_test_1",
184
+ checkoutSession: "cs_test_1",
185
+ termsAccepted: null,
186
+ },
187
+ });
188
+ expect(init).toEqual({ headers: { "Idempotency-Key": "cs_test_1" } });
189
+ expect(JSON.stringify(body)).toBe(EXPECTED_BODY);
190
+ });
191
+
192
+ it("carries the stamp of the terms the buyer accepted as payload.termsAccepted (#8778)", async () => {
193
+ await load().spokeCheckoutConfig.fulfil({
194
+ ...COMPLETION,
195
+ termsOfService: "accepted",
196
+ termsStamp: "account_terms@2",
197
+ });
198
+ const [body] = mockSubmit.mock.calls[0] as [{ payload: Record<string, unknown> }];
199
+ expect(body.payload["termsAccepted"]).toBe("account_terms@2");
200
+ });
201
+
202
+ it.each([
203
+ ["a box not accepted", { termsOfService: null, termsStamp: "account_terms@2" }],
204
+ ["an accepted box with no stamp", { termsOfService: "accepted" as const, termsStamp: null }],
205
+ ])("sends termsAccepted: null for %s (#8778)", async (_label, terms) => {
206
+ await load().spokeCheckoutConfig.fulfil({ ...COMPLETION, ...terms });
207
+ const [body] = mockSubmit.mock.calls[0] as [{ payload: Record<string, unknown> }];
208
+ expect(body.payload).toHaveProperty("termsAccepted", null);
209
+ });
210
+
211
+ it("sends the same bytes under the same key on a replay", async () => {
212
+ const { spokeCheckoutConfig } = load();
213
+ await spokeCheckoutConfig.fulfil(COMPLETION);
214
+ await spokeCheckoutConfig.fulfil(COMPLETION);
215
+ expect(mockSubmit).toHaveBeenCalledTimes(2);
216
+ const [first, second] = mockSubmit.mock.calls as [
217
+ [unknown, unknown],
218
+ [unknown, unknown],
219
+ ];
220
+ expect(JSON.stringify(second[0])).toBe(JSON.stringify(first[0]));
221
+ expect(second[1]).toEqual(first[1]);
222
+ });
223
+
224
+ it("makes no call for a completion without an email", async () => {
225
+ await load().spokeCheckoutConfig.fulfil({ ...COMPLETION, email: null });
226
+ expect(mockSubmit).not.toHaveBeenCalled();
227
+ });
228
+
229
+ it("rejects with no call when AILK_SPOKE_KEY is unset", async () => {
230
+ delete process.env["AILK_SPOKE_KEY"];
231
+ await expect(load().spokeCheckoutConfig.fulfil(COMPLETION)).rejects.toThrow(
232
+ "AILK_SPOKE_KEY is not set",
233
+ );
234
+ expect(mockSubmit).not.toHaveBeenCalled();
235
+ });
236
+
237
+ it("rejects when the hub answers { ok: false }", async () => {
238
+ mockSubmit.mockResolvedValue({ ok: false, status: 422 });
239
+ await expect(load().spokeCheckoutConfig.fulfil(COMPLETION)).rejects.toThrow(
240
+ "hub lead write answered 422",
241
+ );
242
+ });
243
+ });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The localized policy map (#8765 D9, AC-5): one map, the registry's keys,
3
+ * each path carrying the locale prefix off the default locale.
4
+ */
5
+ import { consentPoliciesFor } from "../consent-policies";
6
+
7
+ describe("consentPoliciesFor", () => {
8
+ it("is /contact's three destinations for the default locale", () => {
9
+ expect(consentPoliciesFor("en")).toEqual({
10
+ terms: "/terms",
11
+ privacy: "/privacy",
12
+ working_agreement: "/working-agreement",
13
+ });
14
+ });
15
+
16
+ it("prefixes every path for another locale", () => {
17
+ expect(consentPoliciesFor("fr")).toEqual({
18
+ terms: "/fr/terms",
19
+ privacy: "/fr/privacy",
20
+ working_agreement: "/fr/working-agreement",
21
+ });
22
+ });
23
+ });
@@ -197,10 +197,15 @@ describe("the registered inputsFor (#8687 D3, AC-2)", () => {
197
197
  const chrome = siteChrome as typeof siteChrome & {
198
198
  deckGrounds?: unknown;
199
199
  deckPresentation?: true;
200
+ deckAnchors?: "bare";
200
201
  };
201
202
  expect(result).toEqual({
202
203
  ...(chrome.deckGrounds !== undefined ? { deckGrounds: chrome.deckGrounds } : {}),
203
204
  deckPresentation: chrome.deckPresentation === true,
205
+ // #8773 — the design's slide naming rides along only when it presents and chose bare.
206
+ ...(chrome.deckPresentation === true && chrome.deckAnchors === "bare"
207
+ ? { deckAnchorStyle: "bare" }
208
+ : {}),
204
209
  });
205
210
  });
206
211
 
@@ -297,11 +297,13 @@ describe("structured data — every content page builds valid JSON-LD", () => {
297
297
  * exempt route whose page.tsx does render through `renderPage` fails too,
298
298
  * because the reason given for it is false.
299
299
  */
300
- // Literal, not imported: apps/web does not depend on create-ailk (the import
301
- // boundary). Mirrors packages/create-ailk/src/spoke-checkout.ts's
302
- // SPOKE_CHECKOUT_PAGE_MARKER; packages/create-ailk/__tests__/
303
- // spoke-checkout.test.ts pins the two in sync (#8513 AC-5).
304
- const CHECKOUT_PAGE_MARKER = "GENERATED by create-ailk (#7838)";
300
+ // The marker both standard spoke checkout pages carry in their header comment
301
+ // (apps/web/app/[locale]/checkout/{thank-you,unavailable}/page.tsx, #8748 D5).
302
+ const CHECKOUT_PAGE_MARKER = "Standard spoke checkout page (#8748)";
303
+ // The marker create-ailk rendered before #8748. A spoke scaffolded then keeps
304
+ // its rendered pages through `create-ailk update` (they are not in its
305
+ // record), so its updated copy of this test must still exempt them.
306
+ const LEGACY_CHECKOUT_PAGE_MARKER = "GENERATED by create-ailk (#7838)";
305
307
  const CHECKOUT_PAGE_NOINDEX_LINE = "robots: { index: false, follow: false }";
306
308
 
307
309
  const NO_STRUCTURED_DATA_ROUTES: ReadonlyArray<{
@@ -313,9 +315,10 @@ const NO_STRUCTURED_DATA_ROUTES: ReadonlyArray<{
313
315
  * entry naming no strings applies to its route unconditionally, as every
314
316
  * entry did before. Keys the exemption on the file, not the topology seam:
315
317
  * a spoke can carry a hand-written page at this route, and a site can
316
- * edit the generated one — either is checked like any other route.
318
+ * edit the generated one — either is checked like any other route. A
319
+ * nested list is any one of its strings.
317
320
  */
318
- requires?: readonly string[];
321
+ requires?: ReadonlyArray<string | readonly string[]>;
319
322
  }> = [
320
323
  // Fumadocs routes: JSON-LD is emitted by @working-theory/fumadocs-schema
321
324
  // from the fumadocs `.source` collection, which is not built in this
@@ -388,22 +391,28 @@ const NO_STRUCTURED_DATA_ROUTES: ReadonlyArray<{
388
391
  reason: "auth surfaces, not content pages",
389
392
  subtree: true,
390
393
  },
391
- // #8513 — a spoke carrying commerce gets two generated checkout return
392
- // pages (create-ailk #7838): noindex, rendered through SectionRenderer
393
- // directly, no JSON-LD. Exempt only when the page on disk still carries
394
- // both the generated marker and the noindex line (see `requires` above) —
395
- // a hand-written or edited page at either route is checked like any other.
394
+ // #8513 — the two standard spoke checkout return pages (#8748 D5):
395
+ // noindex, rendered through SectionRenderer directly, no JSON-LD. Exempt
396
+ // only when the page on disk still carries both the standard's marker and
397
+ // the noindex line (see `requires` above) — a hand-written or edited page
398
+ // at either route is checked like any other.
396
399
  {
397
400
  route: "checkout/thank-you",
398
401
  reason:
399
- "spoke checkout return page (create-ailk #7838) — noindex, renders sections only, no JSON-LD",
400
- requires: [CHECKOUT_PAGE_MARKER, CHECKOUT_PAGE_NOINDEX_LINE],
402
+ "spoke checkout return page (#8748) — noindex, renders sections only, no JSON-LD",
403
+ requires: [
404
+ [CHECKOUT_PAGE_MARKER, LEGACY_CHECKOUT_PAGE_MARKER],
405
+ CHECKOUT_PAGE_NOINDEX_LINE,
406
+ ],
401
407
  },
402
408
  {
403
409
  route: "checkout/unavailable",
404
410
  reason:
405
- "spoke checkout return page (create-ailk #7838) — noindex, renders sections only, no JSON-LD",
406
- requires: [CHECKOUT_PAGE_MARKER, CHECKOUT_PAGE_NOINDEX_LINE],
411
+ "spoke checkout return page (#8748) — noindex, renders sections only, no JSON-LD",
412
+ requires: [
413
+ [CHECKOUT_PAGE_MARKER, LEGACY_CHECKOUT_PAGE_MARKER],
414
+ CHECKOUT_PAGE_NOINDEX_LINE,
415
+ ],
407
416
  },
408
417
  ];
409
418
 
@@ -439,7 +448,9 @@ function exemption(route: string, src: string): string | undefined {
439
448
  if (entry === undefined) return undefined;
440
449
  if (
441
450
  entry.requires !== undefined &&
442
- !entry.requires.every((s) => src.includes(s))
451
+ !entry.requires.every((s) =>
452
+ typeof s === "string" ? src.includes(s) : s.some((any) => src.includes(any)),
453
+ )
443
454
  ) {
444
455
  return undefined;
445
456
  }
@@ -645,6 +656,12 @@ describe("structured data — exemption(route, src) keys the generated checkout
645
656
  expect(exemption("checkout/unavailable", NO_NOINDEX_SRC)).toBeUndefined();
646
657
  });
647
658
 
659
+ it("still exempts a page create-ailk rendered before #8748, which update leaves in place", () => {
660
+ const legacy = `// The checkout thank-you page — ${LEGACY_CHECKOUT_PAGE_MARKER}.\nexport async function generateMetadata() {\n return { ${CHECKOUT_PAGE_NOINDEX_LINE} };\n}\n`;
661
+ expect(exemption("checkout/thank-you", legacy)).toContain("noindex");
662
+ expect(exemption("checkout/unavailable", legacy)).toContain("noindex");
663
+ });
664
+
648
665
  it("does not exempt a neighboring checkout route or an unrelated route", () => {
649
666
  expect(exemption("checkout/other", GENERATED_SRC)).toBeUndefined();
650
667
  expect(exemption("about", GENERATED_SRC)).toBeUndefined();
@@ -0,0 +1,37 @@
1
+ /**
2
+ * account-invite.ts — the /contact thank-you's account link (#8757 D8): the
3
+ * path that starts sign-in on this site with the submitter's email prefilled
4
+ * and lands on their account. It signs no one in; the email is only the
5
+ * sign-in form's starting value (NFR2).
6
+ *
7
+ * Built from the generated sign-in provider (`SIGN_IN_PROVIDER`):
8
+ * - `site` — this site's own `/sign-in`, locale-prefixed like every page
9
+ * (`localeHref`), with `next` = the locale's `/account` and `email`;
10
+ * - `hub` — a hub-sign-in spoke starts at `/api/hub-session/sign-in`
11
+ * (`accountInvitePath("hub", …)`), which hands the hint to the hub;
12
+ * - `none` — no sign-in, so no link.
13
+ * An email the hint schema refuses (`accountEmailHintSchema`) gives no link.
14
+ * The query is built with `URLSearchParams` (SEC-8757-L).
15
+ */
16
+ import { localeHref } from "@working-theory/ui";
17
+ import { accountEmailHintSchema, accountInvitePath } from "@working-theory/validation";
18
+
19
+ import { SIGN_IN_PROVIDER } from "./topology.generated";
20
+
21
+ export function accountInviteHref(locale: string, email: string): string | null {
22
+ const hint = accountEmailHintSchema.safeParse(email);
23
+ if (!hint.success) return null;
24
+ switch (SIGN_IN_PROVIDER) {
25
+ case "site": {
26
+ const query = new URLSearchParams({
27
+ next: localeHref(locale, "/account"),
28
+ email: hint.data,
29
+ });
30
+ return `${localeHref(locale, "/sign-in")}?${query.toString()}`;
31
+ }
32
+ case "hub":
33
+ return accountInvitePath("hub", hint.data);
34
+ default:
35
+ return null;
36
+ }
37
+ }
@@ -0,0 +1,27 @@
1
+ // checkout-config.ts — this spoke's checkout, as data (#7838, #8748).
2
+ //
3
+ // The one checkout file this site edits. Written once by create-ailk and yours
4
+ // from then on: `create-ailk update` never rewrites it. The standard checkout
5
+ // (lib/checkout.ts, the buy route and the Stripe webhook route) reads only
6
+ // `site` and `offerings` from it and is kept current by `create-ailk update`.
7
+ // Keep it to data: no import, and no code.
8
+ //
9
+ // site Stamped on every Checkout Session as metadata.site. This site's
10
+ // webhook acts only on its own stamp, and the hub ignores any stamp
11
+ // that is not its own, so the stamp must be unique on the Stripe
12
+ // account this site shares. Never "default". Change it only before
13
+ // any price exists: every lookup key starts with it.
14
+ // offerings slug -> Stripe lookup_key. The buy link is /api/checkout/<slug>,
15
+ // and any other slug answers 404. Name an offering's slug after the
16
+ // last segment of the Service or Product page that sells it: that
17
+ // is the key the hub's plan rule and a magnet's checkoutOffering
18
+ // use. scaffold-commerce replaces the placeholder below.
19
+ const SITE = "example-site";
20
+
21
+ export const checkoutConfig = {
22
+ site: SITE,
23
+ offerings: {
24
+ // A placeholder until scaffold-commerce creates this site's prices.
25
+ example: { kind: "fixed", lookupKey: `${SITE}_example`, cancelPath: "/" },
26
+ },
27
+ };
@@ -0,0 +1,93 @@
1
+ // checkout.ts — the standard spoke checkout (#8748).
2
+ //
3
+ // The standard every selling spoke shares, kept current by `create-ailk
4
+ // update`: do not edit it. This site's checkout is data in
5
+ // lib/checkout-config.ts, the one file to edit. Only the buy route and the
6
+ // Stripe webhook route import this file, and it never imports the checkout
7
+ // package: every guarantee lives there and arrives by a package bump.
8
+ //
9
+ // spokeCheckoutConfig What the two routes build their handlers from. It reads
10
+ // only `site` and `offerings` from the site's file, so no
11
+ // other key a site declares reaches the package.
12
+ // fulfil Runs for each paid session, at least once. It posts one
13
+ // lead to the hub with the session id as its
14
+ // Idempotency-Key, so a repeat writes nothing new. The hub
15
+ // dedupes only an identical body: change the payload's
16
+ // shape only when no webhook is failing. `termsAccepted`
17
+ // is the stamp of the terms the buyer ticked in Stripe's
18
+ // box, else null: the hub records the buyer's checkout
19
+ // consent grant from it (#8778).
20
+ import { leads } from "@working-theory/api-client";
21
+
22
+ import { resolveBaseUrl } from "../src/lib/routes";
23
+
24
+ import { checkoutConfig } from "./checkout-config";
25
+ import { TOPOLOGY } from "./topology.generated";
26
+
27
+ /** What the fulfilment reads from a completed checkout. */
28
+ interface CompletedCheckout {
29
+ idempotencyKey: string;
30
+ sessionId: string;
31
+ offering: string;
32
+ kind: "fixed" | "quoted";
33
+ mode: "test" | "live";
34
+ amountTotal: number | null;
35
+ currency: string | null;
36
+ email: string | null;
37
+ customerId: string | null;
38
+ termsOfService: "accepted" | null;
39
+ termsStamp: string | null;
40
+ }
41
+
42
+ async function fulfil(checkout: CompletedCheckout): Promise<void> {
43
+ if (checkout.email === null) return;
44
+ // No spoke key: the hub cannot place the lead in this site's workspace.
45
+ // Fail, so Stripe retries and the dashboard shows a failed webhook.
46
+ if (!process.env.AILK_SPOKE_KEY)
47
+ throw new Error("AILK_SPOKE_KEY is not set");
48
+ const result = await leads.submit(
49
+ {
50
+ siteId: checkoutConfig.site,
51
+ source: "web",
52
+ email: checkout.email,
53
+ payload: {
54
+ site: checkoutConfig.site,
55
+ page: "checkout",
56
+ offering: checkout.offering,
57
+ checkoutKind: checkout.kind,
58
+ mode: checkout.mode,
59
+ amountTotal: checkout.amountTotal,
60
+ currency: checkout.currency,
61
+ stripeCustomerId: checkout.customerId,
62
+ checkoutSession: checkout.sessionId,
63
+ termsAccepted:
64
+ checkout.termsOfService === "accepted" ? checkout.termsStamp : null,
65
+ },
66
+ },
67
+ { headers: { "Idempotency-Key": checkout.idempotencyKey } },
68
+ );
69
+ // A replay under the same key answers 201 with the first lead; any
70
+ // failure (422 idempotency_key_reused included) makes Stripe retry.
71
+ if (!result.ok) throw new Error(`hub lead write answered ${result.status}`);
72
+ }
73
+
74
+ /** The config both routes pass to the checkout package, built key by key. */
75
+ export const spokeCheckoutConfig = {
76
+ site: checkoutConfig.site,
77
+ offerings: checkoutConfig.offerings,
78
+ siteBaseUrl: resolveBaseUrl(),
79
+ fulfil,
80
+ };
81
+
82
+ /** Whether this build serves the checkout: a spoke only (#8748 D11). */
83
+ export function checkoutServed(): boolean {
84
+ return TOPOLOGY === "spoke";
85
+ }
86
+
87
+ /** What either route answers on a build that does not serve the checkout. */
88
+ export function checkoutNotServed(): Response {
89
+ return new Response(null, {
90
+ status: 404,
91
+ headers: { "Cache-Control": "no-store" },
92
+ });
93
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * consent-policies.ts — the one policy map an agreement surface links
3
+ * through (#8765 D9, spec docs/specs/8765-sign-up-refuses-without-the-spec.md).
4
+ *
5
+ * A consent statement names its links by key (`[Terms](terms)`); the keys
6
+ * resolve against the registry's `policies` map. This localizes that map, so
7
+ * the sign-up page, the account interstitial and the Privacy tab link Terms,
8
+ * Privacy Policy and Working Agreement to the same three pages `/contact`
9
+ * builds inline (#8761 decision, C6).
10
+ */
11
+ import { localeHref } from "@working-theory/ui";
12
+ import { SITE_CONSENT_CONFIG_DEFAULTS } from "@working-theory/validation";
13
+
14
+ /** The registry's policy map with each path localized for `locale`. */
15
+ export function consentPoliciesFor(locale: string): Record<string, string> {
16
+ return Object.fromEntries(
17
+ Object.entries(SITE_CONSENT_CONFIG_DEFAULTS.policies).map(([key, path]) => [
18
+ key,
19
+ localeHref(locale, path),
20
+ ]),
21
+ );
22
+ }
@@ -55,6 +55,8 @@ export const DECK_ROUTES = [
55
55
  type Chrome = SiteChrome & {
56
56
  deckGrounds?: DeckPageInputs["deckGrounds"];
57
57
  deckPresentation?: true;
58
+ /** #8773 — present only when the design chooses bare slide anchors. */
59
+ deckAnchors?: "bare";
58
60
  };
59
61
  const chrome: Chrome = siteChrome;
60
62
 
@@ -180,6 +182,10 @@ async function inputsFor(ctx: DeckPageContext): Promise<DeckPageInputs | null> {
180
182
  return {
181
183
  ...(chrome.deckGrounds !== undefined ? { deckGrounds: chrome.deckGrounds } : {}),
182
184
  deckPresentation: chrome.deckPresentation === true,
185
+ // #8773 — a page that presents names its slides as the design chose.
186
+ ...(chrome.deckPresentation === true && chrome.deckAnchors === "bare"
187
+ ? { deckAnchorStyle: "bare" as const }
188
+ : {}),
183
189
  };
184
190
  }
185
191