create-ailk 0.27.0 → 0.28.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 (210) hide show
  1. package/README.md +84 -7
  2. package/dist/add-module-core.js +2 -1
  3. package/dist/add-module.d.ts +2 -1
  4. package/dist/add-module.js +9 -4
  5. package/dist/add-route.js +46 -19
  6. package/dist/body-heading.d.ts +56 -0
  7. package/dist/body-heading.js +394 -0
  8. package/dist/cli.js +28 -1
  9. package/dist/content-payloads.js +2 -2
  10. package/dist/derive-architecture.js +8 -2
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.js +4 -1
  13. package/dist/lib/auth-secret-file.d.ts +6 -2
  14. package/dist/lib/auth-secret-file.js +46 -20
  15. package/dist/lib/env-catalog.js +20 -0
  16. package/dist/lib/site-modules.d.ts +8 -0
  17. package/dist/lib/site-modules.js +34 -0
  18. package/dist/module-architecture.js +23 -14
  19. package/dist/paid-sections.d.ts +17 -0
  20. package/dist/paid-sections.js +39 -0
  21. package/dist/programmatic.d.ts +16 -0
  22. package/dist/programmatic.js +73 -4
  23. package/dist/remove-module.d.ts +98 -0
  24. package/dist/remove-module.js +620 -0
  25. package/dist/seed-flows.d.ts +2 -2
  26. package/dist/seed-flows.js +16 -9
  27. package/dist/site-roster.js +4 -1
  28. package/dist/starter-grid.d.ts +26 -0
  29. package/dist/starter-grid.js +34 -0
  30. package/dist/surfaces.d.ts +26 -0
  31. package/dist/surfaces.js +105 -9
  32. package/dist/sync-routes.d.ts +7 -0
  33. package/dist/sync-routes.js +53 -6
  34. package/dist/template-pages.d.ts +36 -0
  35. package/dist/template-pages.js +129 -0
  36. package/dist/topology.d.ts +2 -1
  37. package/dist/topology.js +4 -2
  38. package/dist/update.d.ts +3 -1
  39. package/dist/update.js +38 -4
  40. package/package.json +3 -3
  41. package/release-scope.json +2 -1
  42. package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
  43. package/templates/.env.example +5 -2
  44. package/templates/apps/api/.env.example +9 -5
  45. package/templates/apps/api/CLAUDE.md +14 -9
  46. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
  47. package/templates/apps/api/src/vercel-handler.ts +8 -1
  48. package/templates/apps/mcp/CLAUDE.md +18 -10
  49. package/templates/apps/web/.env.example +25 -11
  50. package/templates/apps/web/CLAUDE.md +45 -4
  51. package/templates/apps/web/__tests__/setup.ts +7 -0
  52. package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
  53. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
  54. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
  55. package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
  56. package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
  57. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  58. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
  59. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
  60. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
  61. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
  62. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
  63. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
  64. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
  65. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
  66. package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
  67. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
  68. package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
  69. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
  70. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  71. package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
  72. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
  73. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
  74. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
  75. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
  76. package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
  77. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
  78. package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
  79. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
  80. package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
  81. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
  82. package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
  83. package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
  84. package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
  85. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
  86. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
  87. package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
  88. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
  89. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
  90. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
  91. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
  92. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
  93. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
  94. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
  95. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
  96. package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
  97. package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
  98. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
  99. package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
  100. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
  101. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  102. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
  103. package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
  104. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
  105. package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
  106. package/templates/apps/web/app/[locale]/layout.tsx +157 -139
  107. package/templates/apps/web/app/[locale]/page.tsx +51 -3
  108. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
  109. package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
  110. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
  111. package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
  112. package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
  113. package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
  114. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
  115. package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
  116. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  117. package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
  118. package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
  119. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
  120. package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
  121. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
  122. package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
  123. package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
  124. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
  125. package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
  126. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
  127. package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
  128. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
  129. package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
  130. package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
  131. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
  132. package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
  133. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
  134. package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +223 -0
  135. package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
  136. package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
  137. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  138. package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
  139. package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
  140. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
  141. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  142. package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
  143. package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
  144. package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +236 -0
  145. package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
  146. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  147. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
  148. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  149. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +204 -2
  150. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +151 -33
  151. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
  152. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
  153. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
  154. package/templates/apps/web/app/llms.txt/route.ts +41 -3
  155. package/templates/apps/web/app/robots.txt/route.ts +5 -1
  156. package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
  157. package/templates/apps/web/auth.ts +102 -50
  158. package/templates/apps/web/components/signed-in-nav.tsx +8 -1
  159. package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
  160. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
  161. package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
  162. package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
  163. package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
  164. package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +278 -0
  165. package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
  166. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
  167. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
  168. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
  169. package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
  170. package/templates/apps/web/lib/__tests__/relay.test.ts +202 -10
  171. package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
  172. package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
  173. package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
  174. package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
  175. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
  176. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
  177. package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
  178. package/templates/apps/web/lib/auth-secret.ts +57 -0
  179. package/templates/apps/web/lib/bot-check.ts +138 -0
  180. package/templates/apps/web/lib/consent-visitor.ts +81 -7
  181. package/templates/apps/web/lib/deck-page.ts +6 -3
  182. package/templates/apps/web/lib/docs-index.ts +100 -0
  183. package/templates/apps/web/lib/docs-path.ts +94 -0
  184. package/templates/apps/web/lib/hub-children.tsx +318 -0
  185. package/templates/apps/web/lib/modules.generated.ts +9 -0
  186. package/templates/apps/web/lib/nav-label-check.ts +61 -0
  187. package/templates/apps/web/lib/nav-label-keys.ts +78 -0
  188. package/templates/apps/web/lib/newsletter.actions.ts +31 -6
  189. package/templates/apps/web/lib/offerings-pricing.ts +135 -6
  190. package/templates/apps/web/lib/page-chrome.ts +367 -0
  191. package/templates/apps/web/lib/posting-title.ts +26 -4
  192. package/templates/apps/web/lib/pricing-page.ts +430 -0
  193. package/templates/apps/web/lib/relay.ts +31 -106
  194. package/templates/apps/web/lib/route-family.ts +69 -0
  195. package/templates/apps/web/lib/service-listing.ts +89 -0
  196. package/templates/apps/web/lib/services-path.ts +97 -0
  197. package/templates/apps/web/lib/site-header.ts +94 -0
  198. package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
  199. package/templates/apps/web/lib/template-listing.ts +169 -0
  200. package/templates/apps/web/messages/en.json +86 -30
  201. package/templates/apps/web/middleware.ts +171 -11
  202. package/templates/apps/web/next.config.mjs +211 -2
  203. package/templates/apps/web/package.json +1 -1
  204. package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
  205. package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
  206. package/templates/apps/web/sentry.server.config.ts +30 -2
  207. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
  208. package/templates/apps/web/src/lib/rate-limit.ts +68 -27
  209. package/templates/apps/web/src/lib/routes.ts +15 -1
  210. package/versions.json +48 -48
@@ -0,0 +1,430 @@
1
+ /**
2
+ * The shared Pricing render (#10044 D5) — what `/pricing` and every
3
+ * `/pricing/<offering>` page do with the workspace's Offerings shown set.
4
+ *
5
+ * Moved unchanged from `/pricing` (#8927, spec
6
+ * docs/specs/8927-pricing-renders-the-shown-set-spec.md): the shown-offerings
7
+ * read, shared per server instance for READ_TTL_MS, and its three pages —
8
+ * - unconnected (or production with no API origin) — the MDX page as
9
+ * written, with `?plan=` moving the authored recommendation (#8842, D7);
10
+ * - a current shown set — the workspace's Stripe cards in place of the
11
+ * page's authored `pricing` sections, `?plan=` moving the recommendation
12
+ * only for a `{plan}`-grammar value (D4, contract §3.7);
13
+ * - anything else (expired, empty, a failed read) — the site's contact
14
+ * email in place of those sections, never an error (D5).
15
+ * `pricingSections` runs that choice per page, narrowed to the page's own
16
+ * `plans` (#10044 D4); `plans: []` reads no Stripe data at all.
17
+ *
18
+ * Stripe text reaches markup only as React text through `offeringsPricing`,
19
+ * and JSON-LD only through renderPage's serializer (T4).
20
+ *
21
+ * Base tree: no paid import, no `oss: false`.
22
+ */
23
+ import { offerings } from "@working-theory/api-client";
24
+ import { ContactFallback } from "@working-theory/ui";
25
+ import {
26
+ LOOKUP_KEY_PLAN_PATTERN,
27
+ type PageSection,
28
+ } from "@working-theory/validation";
29
+ import { getTranslations } from "next-intl/server";
30
+ import { createElement, type ReactNode } from "react";
31
+
32
+ import { contactFormText } from "../app/[locale]/contact/contact-form-text";
33
+ import { readSiteContactEmail } from "../app/[locale]/contact/site-contact-email";
34
+
35
+ import { browserApiOrigin } from "./api-origin.generated";
36
+ import {
37
+ offeringsPricing,
38
+ type OfferingsPricing,
39
+ type OfferingsPricingLabels,
40
+ } from "./offerings-pricing";
41
+ import type { DetailPage } from "./route-family";
42
+
43
+ type PricingSection = Extract<PageSection, { type: "pricing" }>;
44
+ type PricingData = PricingSection["data"];
45
+ type ItemsSection = Extract<PageSection, { type: "items" }>;
46
+ type CardItem = ItemsSection["data"]["items"][number];
47
+
48
+ /** The slug of `/pricing`'s own page — the hub, never an offering page. */
49
+ export const PRICING_HUB_SLUG = "pricing";
50
+
51
+ /**
52
+ * Every `pricing` section with `recommended` set to `plan` (#8842). Each
53
+ * section matches the key against its own plans, so a second plan group
54
+ * keeps its own default unless it carries the same key.
55
+ */
56
+ export function withRecommendedPlan(
57
+ sections: PageSection[],
58
+ plan: string,
59
+ ): PageSection[] {
60
+ return sections.map((section) =>
61
+ section.type === "pricing"
62
+ ? {
63
+ ...section,
64
+ // `recommended` is a render-time field the section reads, not an
65
+ // authored one, so it is not in the validated section schema.
66
+ data: { ...section.data, recommended: plan } as typeof section.data,
67
+ }
68
+ : section,
69
+ );
70
+ }
71
+
72
+ type OfferingsRead =
73
+ | { kind: "unconnected" }
74
+ | { kind: "shown"; shownSet: unknown }
75
+ | { kind: "contact" };
76
+
77
+ const CONTACT: OfferingsRead = { kind: "contact" };
78
+
79
+ /**
80
+ * How long the Offerings read is shared across requests (D2): a view carries
81
+ * no visitor headers, so every view would otherwise count against this
82
+ * server's one per-IP limit at the API. A product switched off disappears
83
+ * within it. The contact page's window (#8731).
84
+ */
85
+ const READ_TTL_MS = 15_000;
86
+
87
+ const reads = new Map<string, { at: number; read: Promise<OfferingsRead> }>();
88
+
89
+ /**
90
+ * `read()`'s result, shared per instance for READ_TTL_MS. Concurrent calls
91
+ * share one in-flight read, and a failed read is shared the same way, so a
92
+ * burst against an unreachable API is one call.
93
+ */
94
+ function sharedRead(
95
+ key: string,
96
+ read: () => Promise<OfferingsRead>,
97
+ ): Promise<OfferingsRead> {
98
+ const now = Date.now();
99
+ const hit = reads.get(key);
100
+ if (hit && now - hit.at < READ_TTL_MS) return hit.read;
101
+ const fresh = read();
102
+ reads.set(key, { at: now, read: fresh });
103
+ return fresh;
104
+ }
105
+
106
+ /**
107
+ * Whether an API origin is configured: the three names `getApiBase()` in
108
+ * packages/api-client/src/http.ts reads, in step with it on a hub. Without one
109
+ * that function answers `localhost:3001`. The browser-facing origin comes from
110
+ * the topology seam (#7874 D12), which a spoke renders with no env read, so a
111
+ * spoke passes its own `env:check` (#9480). A spoke diverges on purpose: the
112
+ * browser-facing name set on one at runtime counts as no origin here.
113
+ */
114
+ function apiOriginConfigured(): boolean {
115
+ return Boolean(
116
+ process.env.AILK_API_URL ||
117
+ browserApiOrigin() ||
118
+ process.env.NEXT_PUBLIC_API_URL,
119
+ );
120
+ }
121
+
122
+ /**
123
+ * The shown-offerings read (D2). A production deployment with no API has no
124
+ * connection to read, so it is unconnected without a call. The body is
125
+ * untrusted: anything but `unconnected` or `shown` — `empty`, an unknown
126
+ * status, a non-object body, a non-2xx, a throw — is the contact email.
127
+ */
128
+ export function readShownOfferings(): Promise<OfferingsRead> {
129
+ if (process.env.NODE_ENV === "production" && !apiOriginConfigured()) {
130
+ return Promise.resolve({ kind: "unconnected" });
131
+ }
132
+ return sharedRead("shown-offerings", async () => {
133
+ try {
134
+ const res = await offerings.shown();
135
+ if (!res.ok) return CONTACT;
136
+ const body: unknown = res.body;
137
+ if (body === null || typeof body !== "object") return CONTACT;
138
+ const { status, shownSet } = body as {
139
+ status?: unknown;
140
+ shownSet?: unknown;
141
+ };
142
+ if (status === "unconnected") return { kind: "unconnected" };
143
+ if (status === "shown") return { kind: "shown", shownSet };
144
+ return CONTACT;
145
+ } catch {
146
+ return CONTACT;
147
+ }
148
+ });
149
+ }
150
+
151
+ /** Keeps the `{amount}` placeholder for `period` to fill (D6). */
152
+ const KEEP_AMOUNT_PLACEHOLDER = { amount: "{amount}" };
153
+ /** Keeps both placeholders for `withRequired` to fill (#10028 D9). */
154
+ const KEEP_BUNDLE_PLACEHOLDERS = { plan: "{plan}", required: "{required}" };
155
+ const KEEP_DEPOSIT_PLACEHOLDER = { deposit: "{deposit}" };
156
+
157
+ /** The mapper's labels and the plans section's recommendation label (D6). */
158
+ export async function offeringsCopy(locale: string): Promise<{
159
+ labels: OfferingsPricingLabels;
160
+ recommendedLabel: string;
161
+ addonsHeadline: string;
162
+ }> {
163
+ const t = await getTranslations({ locale, namespace: "pricingOfferings" });
164
+ const period = (key: "monthly" | "annual" | "oneTime") => {
165
+ const phrase = t(key, KEEP_AMOUNT_PLACEHOLDER);
166
+ return (amount: string) => phrase.replace("{amount}", () => amount);
167
+ };
168
+ return {
169
+ labels: {
170
+ cta: t("cta"),
171
+ recommended: t("recommended"),
172
+ monthly: period("monthly"),
173
+ annual: period("annual"),
174
+ oneTime: period("oneTime"),
175
+ withRequired: (plan, required) =>
176
+ t("withRequired", KEEP_BUNDLE_PLACEHOLDERS)
177
+ .replace("{plan}", () => plan)
178
+ .replace("{required}", () => required),
179
+ depositNote: (deposit) =>
180
+ t("depositNote", KEEP_DEPOSIT_PLACEHOLDER).replace(
181
+ "{deposit}",
182
+ () => deposit,
183
+ ),
184
+ },
185
+ recommendedLabel: t("recommendedForYou"),
186
+ addonsHeadline: t("addonsHeadline"),
187
+ };
188
+ }
189
+
190
+ /** The authored section copy a Stripe section keeps — never a plan or price fact. */
191
+ const AUTHORED_COPY_FIELDS = [
192
+ "eyebrow",
193
+ "headline",
194
+ "description",
195
+ "header",
196
+ "headlineSize",
197
+ "toggleStyle",
198
+ "featureListStyle",
199
+ "gapAbove",
200
+ "separation",
201
+ "gap",
202
+ "surface",
203
+ "note",
204
+ "collapseFeaturesOnMobile",
205
+ ] as const satisfies readonly (keyof PricingData)[];
206
+
207
+ function authoredCopy(
208
+ section: PricingSection | undefined,
209
+ extra: readonly (keyof PricingData)[] = [],
210
+ ): Partial<PricingData> {
211
+ const copy: Partial<Record<keyof PricingData, unknown>> = {};
212
+ if (!section) return copy as Partial<PricingData>;
213
+ for (const field of [...AUTHORED_COPY_FIELDS, ...extra]) {
214
+ if (section.data[field] !== undefined) copy[field] = section.data[field];
215
+ }
216
+ return copy as Partial<PricingData>;
217
+ }
218
+
219
+ function isPricing(section: PageSection): section is PricingSection {
220
+ return section.type === "pricing";
221
+ }
222
+
223
+ /** Where a section with no slot goes: after a leading hero, else first. */
224
+ function insertionIndex(sections: PageSection[]): number {
225
+ return sections[0]?.type === "hero" ? 1 : 0;
226
+ }
227
+
228
+ /**
229
+ * The page's sections with the Stripe fragments in place of its authored
230
+ * `pricing` sections (D4). The first non-`addons` pricing section is the
231
+ * plans slot and the first `addons` one the add-ons slot; every other pricing
232
+ * section, and a slot whose fragment is `null`, is removed. A fragment with no
233
+ * slot is inserted after a leading hero (the add-ons directly after the
234
+ * plans). No other section moves.
235
+ */
236
+ export function withOfferings(
237
+ sections: PageSection[],
238
+ pricing: OfferingsPricing,
239
+ plan: string | undefined,
240
+ recommendedLabel: string,
241
+ addonsHeadline: string,
242
+ ): PageSection[] {
243
+ const plansSlot = sections.find(
244
+ (s): s is PricingSection => isPricing(s) && s.data.variant !== "addons",
245
+ );
246
+ const addonsSlot = sections.find(
247
+ (s): s is PricingSection => isPricing(s) && s.data.variant === "addons",
248
+ );
249
+ const recommend =
250
+ plan !== undefined && LOOKUP_KEY_PLAN_PATTERN.test(plan) ? plan : undefined;
251
+ const plansSection: PricingSection | null = pricing.plans && {
252
+ id: plansSlot?.id ?? "offerings-plans",
253
+ type: "pricing",
254
+ data: {
255
+ ...authoredCopy(plansSlot),
256
+ ...pricing.plans,
257
+ // A `?plan=` recommendation moves the mapper's highlight onto the named
258
+ // plan and wears `recommendedLabel` (#8842); the mapper's own
259
+ // "Recommended" pill label would ride along as a second label, so it is
260
+ // dropped from every card when a recommendation is in play.
261
+ ...(recommend !== undefined &&
262
+ pricing.plans.plans.some(
263
+ (card) => card.key?.toLowerCase() === recommend.toLowerCase(),
264
+ )
265
+ ? {
266
+ plans: pricing.plans.plans.map(
267
+ ({ highlightLabel: _, ...card }) => card,
268
+ ),
269
+ }
270
+ : {}),
271
+ // Render-time fields the section reads (#8842), not authored ones.
272
+ recommendedLabel,
273
+ ...(recommend !== undefined ? { recommended: recommend } : {}),
274
+ } as PricingData,
275
+ };
276
+ const addonsSection: PricingSection | null = pricing.addons && {
277
+ id: addonsSlot?.id ?? "offerings-addons",
278
+ type: "pricing",
279
+ data: {
280
+ // An inserted add-ons section (no authored slot) sits tight under the
281
+ // plans and carries its own headline, so a lone tile is never an
282
+ // unlabeled band (design review B2).
283
+ ...(addonsSlot ? {} : { headline: addonsHeadline, gapAbove: "tight" }),
284
+ ...authoredCopy(addonsSlot, ["addonLayout"]),
285
+ ...pricing.addons,
286
+ } as PricingData,
287
+ };
288
+
289
+ const placed: PageSection[] = [];
290
+ for (const section of sections) {
291
+ if (!isPricing(section)) placed.push(section);
292
+ else if (section === plansSlot && plansSection) placed.push(plansSection);
293
+ else if (section === addonsSlot && addonsSection)
294
+ placed.push(addonsSection);
295
+ }
296
+ if (plansSection && !plansSlot) {
297
+ placed.splice(insertionIndex(placed), 0, plansSection);
298
+ }
299
+ if (addonsSection && !addonsSlot) {
300
+ const plansAt = plansSection ? placed.indexOf(plansSection) : -1;
301
+ placed.splice(
302
+ plansAt >= 0 ? plansAt + 1 : insertionIndex(placed),
303
+ 0,
304
+ addonsSection,
305
+ );
306
+ }
307
+ return placed;
308
+ }
309
+
310
+ /** The section the contact email is slotted into (D5). */
311
+ export const CONTACT_SECTION_ID = "offerings-contact";
312
+
313
+ /**
314
+ * The page's sections with every `pricing` section removed and one
315
+ * `contact-form` section in the first one's place, else after a leading hero
316
+ * (D5). It contributes no JSON-LD, so the page publishes no `Offer`.
317
+ */
318
+ export function withContactFallback(sections: PageSection[]): PageSection[] {
319
+ const first = sections.findIndex(isPricing);
320
+ const kept: PageSection[] = sections.filter((s) => !isPricing(s));
321
+ const contact = {
322
+ id: CONTACT_SECTION_ID,
323
+ type: "contact-form",
324
+ data: { fields: [] },
325
+ } as PageSection;
326
+ kept.splice(first >= 0 ? first : insertionIndex(kept), 0, contact);
327
+ return kept;
328
+ }
329
+
330
+ /**
331
+ * The slot the contact section renders into (D5). A slot whose section is
332
+ * absent renders nothing, so every Pricing page carries it and only the
333
+ * contact path places its section (#10044 D6).
334
+ */
335
+ export async function pricingSectionSlots(
336
+ locale: string,
337
+ ): Promise<Record<string, ReactNode>> {
338
+ const text = contactFormText(
339
+ await getTranslations({ locale, namespace: "contact" }),
340
+ );
341
+ return {
342
+ [CONTACT_SECTION_ID]: createElement(ContactFallback, {
343
+ email: readSiteContactEmail(),
344
+ text: {
345
+ heading: text.unavailableHeading,
346
+ body: text.unavailableBody,
347
+ noEmail: text.unavailableNoEmail,
348
+ },
349
+ }),
350
+ };
351
+ }
352
+
353
+ /**
354
+ * A page's `plans` frontmatter (#10044 D1) — its plan slugs, or `undefined`
355
+ * when the page names none. The adapter validated it; a non-array is none.
356
+ */
357
+ export function pagePlans(frontmatter: unknown): string[] | undefined {
358
+ if (frontmatter === null || typeof frontmatter !== "object") return undefined;
359
+ const { plans } = frontmatter as { plans?: unknown };
360
+ if (!Array.isArray(plans)) return undefined;
361
+ return plans.filter((plan): plan is string => typeof plan === "string");
362
+ }
363
+
364
+ /**
365
+ * One Pricing page's sections in its Offerings state (#10044 D5): `/pricing`'s
366
+ * three-state choice with the page's own `plans` passed through.
367
+ * - `plans: []` → the written sections, no Stripe read;
368
+ * - unconnected → the written sections (`?plan=` moving the recommendation);
369
+ * - shown → the page's Stripe cards in place of its `pricing` sections,
370
+ * when any of its plans is shown;
371
+ * - anything else → the contact email in their place.
372
+ */
373
+ export async function pricingSections(
374
+ declared: PageSection[],
375
+ options: {
376
+ plans: readonly string[] | undefined;
377
+ plan: string | undefined;
378
+ locale: string;
379
+ },
380
+ ): Promise<PageSection[]> {
381
+ const { plans, plan, locale } = options;
382
+ const asWritten = () =>
383
+ plan ? withRecommendedPlan(declared, plan) : declared;
384
+ if (plans !== undefined && plans.length === 0) return asWritten();
385
+
386
+ const read = await readShownOfferings();
387
+ if (read.kind === "unconnected") return asWritten();
388
+ if (read.kind === "shown") {
389
+ const { labels, recommendedLabel, addonsHeadline } =
390
+ await offeringsCopy(locale);
391
+ const pricing = offeringsPricing(read.shownSet, { locale, labels, plans });
392
+ if (pricing && (pricing.plans || pricing.addons)) {
393
+ return withOfferings(
394
+ declared,
395
+ pricing,
396
+ plan,
397
+ recommendedLabel,
398
+ addonsHeadline,
399
+ );
400
+ }
401
+ }
402
+ return withContactFallback(declared);
403
+ }
404
+
405
+ /**
406
+ * The offering pricing pages `/pricing` lists (#10044 D5): those whose
407
+ * frontmatter `slug` is their file slug — a page served at a URL its route
408
+ * does not answer is left off — and is not the hub's own, in input order.
409
+ */
410
+ export function offeringPricingPages(pages: DetailPage[]): DetailPage[] {
411
+ return pages.filter(
412
+ (page) =>
413
+ page.frontmatter.slug === page.slug && page.slug !== PRICING_HUB_SLUG,
414
+ );
415
+ }
416
+
417
+ /** One `/pricing` list card for an offering pricing page (#10044 D5). */
418
+ export function pricingPageCard(
419
+ page: DetailPage,
420
+ labels: { viewPricing: string },
421
+ ): CardItem {
422
+ const { title, summary, description } = page.frontmatter;
423
+ const text = (value: unknown): string | undefined =>
424
+ typeof value === "string" && value.length > 0 ? value : undefined;
425
+ return {
426
+ title: text(title) ?? page.slug,
427
+ description: text(summary) ?? text(description) ?? "",
428
+ link: { label: labels.viewPricing, href: `/pricing/${page.slug}` },
429
+ };
430
+ }
@@ -35,22 +35,24 @@
35
35
  * and only as strong as the platform: it holds only where the site's edge
36
36
  * overwrites `x-forwarded-for` and `x-real-ip` (Vercel does; a bare
37
37
  * self-hosted Next server does not).
38
- * - A BOT CHECK ON THE BOOKING (#9889). `POST /v1/schedule/book` asks
39
- * Vercel BotID (`checkBotId`) for a verdict after every refusal above and
40
- * before the hub is called. Only a human verdict forwards; a bot is
41
- * `403 bot_detected`, and a check that throws, answers no boolean or
42
- * takes over 5 seconds is `503 bot_check_unavailable` — either way the
43
- * hub is never called. It runs only on Vercel (`VERCEL=1`), unless
44
- * `AILK_BOOKING_BOT_CHECK=off`.
38
+ * - A BOT CHECK ON THE WRITES (#9889, #9255). `POST /v1/leads/submit`,
39
+ * `/v1/leads/subscribe`, `/v1/leads/callback` and `/v1/schedule/book`
40
+ * ask Vercel BotID (`runBotCheck`, lib/bot-check.ts) for a verdict after
41
+ * every refusal above and before the hub is called. Only a human verdict
42
+ * forwards; a bot is `403 bot_detected`, and a check that throws, answers
43
+ * no boolean or takes over 5 seconds is `503 bot_check_unavailable` —
44
+ * either way the hub is never called. It runs only on Vercel
45
+ * (`VERCEL=1`); `AILK_BOT_CHECK=off` turns every check off and
46
+ * `AILK_BOOKING_BOT_CHECK=off` the booking's alone.
45
47
  *
46
48
  * NO SECRET IS LOGGED. A failure logs `{ path, outcome }` and nothing else.
47
49
  */
48
50
  import { isIP } from "node:net";
49
51
 
50
52
  import { relayToApi, visitorForwardHeaders } from "@working-theory/api-client";
51
- import { checkBotId } from "botid/server";
52
53
 
53
54
  import { browserApiOrigin } from "./api-origin.generated";
55
+ import { runBotCheck } from "./bot-check";
54
56
  import { readConsentVisitorFromCookieHeader } from "./consent-visitor";
55
57
 
56
58
  type RelayMethod = "GET" | "POST";
@@ -77,92 +79,33 @@ function route(method: RelayMethod, path: string): RelayRoute {
77
79
  */
78
80
  const SESSION_ROUTE = route("POST", "/v1/commerce/invoices");
79
81
 
80
- /** The booking pair, the one route the bot check guards (#9889 D1). */
82
+ /** The three lead pairs (#9255 D2) and the booking pair (#9889 D1). */
83
+ const SUBMIT_ROUTE = route("POST", "/v1/leads/submit");
84
+ const SUBSCRIBE_ROUTE = route("POST", "/v1/leads/subscribe");
85
+ const CALLBACK_ROUTE = route("POST", "/v1/leads/callback");
81
86
  const BOOK_ROUTE = route("POST", "/v1/schedule/book");
82
87
 
83
88
  export const RELAY_ROUTES: readonly RelayRoute[] = Object.freeze([
84
- route("POST", "/v1/leads/submit"),
85
- route("POST", "/v1/leads/subscribe"),
86
- route("POST", "/v1/leads/callback"),
89
+ SUBMIT_ROUTE,
90
+ SUBSCRIBE_ROUTE,
91
+ CALLBACK_ROUTE,
87
92
  route("GET", "/v1/schedule/availability"),
88
93
  BOOK_ROUTE,
89
94
  SESSION_ROUTE,
90
95
  ]);
91
96
 
92
97
  /**
93
- * The entries the bot check guards (#9889 D1, D4). The locale layout passes
94
- * the same list to `BotIdClient`, so the browser protects exactly the pairs
95
- * the server checks: a checked pair the client does not protect would carry
96
- * no challenge header and be refused as a bot.
98
+ * The entries the bot check guards (#9889 D1, #9255 D2): every write a
99
+ * browser posts here. The locale layout's `BotIdClient` protects every
100
+ * same-origin POST (`BOT_CLIENT_PROTECT`), so each carries BotID's challenge.
97
101
  */
98
102
  export const BOT_CHECKED_ROUTES: readonly RelayRoute[] = Object.freeze([
103
+ SUBMIT_ROUTE,
104
+ SUBSCRIBE_ROUTE,
105
+ CALLBACK_ROUTE,
99
106
  BOOK_ROUTE,
100
107
  ]);
101
108
 
102
- /** The longest the relay waits for a verdict; the library's own fetch has no bound. */
103
- const BOT_CHECK_TIMEOUT_MS = 5_000;
104
-
105
- /**
106
- * True when the booking bot check runs (#9889 D2): on Vercel, unless
107
- * `AILK_BOOKING_BOT_CHECK` is `off`. Unset, empty or any other value leaves
108
- * it on, so a typo fails toward protection. Read on every call, never cached.
109
- */
110
- export function bookingBotCheckOn(): boolean {
111
- return process.env.VERCEL === "1" && !botCheckSwitchedOff();
112
- }
113
-
114
- function botCheckSwitchedOff(): boolean {
115
- return process.env.AILK_BOOKING_BOT_CHECK?.trim().toLowerCase() === "off";
116
- }
117
-
118
- let warnedBotCheckInactive = false;
119
-
120
- /**
121
- * Called when the check is off. Off Vercel it cannot run (D2): a production
122
- * site whose switch is not `off` logs that once per process, so an owner who
123
- * believes it is on can find out. It never refuses.
124
- */
125
- function warnBotCheckInactiveOnce(): void {
126
- if (warnedBotCheckInactive || process.env.NODE_ENV !== "production") return;
127
- if (botCheckSwitchedOff()) return;
128
- warnedBotCheckInactive = true;
129
- console.warn("[relay] booking bot check inactive", {
130
- outcome: "not-on-vercel",
131
- });
132
- }
133
-
134
- function namedError(name: string): Error {
135
- const err = new Error(name);
136
- err.name = name;
137
- return err;
138
- }
139
-
140
- /**
141
- * BotID's verdict on this request (D1, D3): `true` for a bot, `false` for a
142
- * human. Throws `TimeoutError` past `BOT_CHECK_TIMEOUT_MS` and
143
- * `InvalidVerdictError` when `isBot` is not a boolean, as `checkBotId` returns
144
- * for an error answer from Vercel.
145
- */
146
- async function isBotVerdict(): Promise<boolean> {
147
- let timer: ReturnType<typeof setTimeout> | undefined;
148
- try {
149
- const verdict = await Promise.race([
150
- checkBotId(),
151
- new Promise<never>((_resolve, reject) => {
152
- timer = setTimeout(
153
- () => reject(namedError("TimeoutError")),
154
- BOT_CHECK_TIMEOUT_MS,
155
- );
156
- }),
157
- ]);
158
- const isBot = (verdict as { isBot?: unknown } | undefined)?.isBot;
159
- if (typeof isBot !== "boolean") throw namedError("InvalidVerdictError");
160
- return isBot;
161
- } finally {
162
- clearTimeout(timer);
163
- }
164
- }
165
-
166
109
  /**
167
110
  * The three lead routes (#8438 D11, D12): a visitor's banner choice is
168
111
  * linked to a Person only through these — a booking has no consent writer.
@@ -552,33 +495,15 @@ export async function handleRelay(request: Request): Promise<Response> {
552
495
  }
553
496
 
554
497
  if (BOT_CHECKED_ROUTES.includes(entry)) {
555
- if (bookingBotCheckOn()) {
556
- let isBot: boolean;
557
- try {
558
- isBot = await isBotVerdict();
559
- } catch (err) {
560
- // The error's name only — never its message (#9889 D1).
561
- console.error("[relay] bot check failed", {
562
- path: entry.path,
563
- outcome: "bot-check-failed",
564
- error: err instanceof Error ? err.name : "unknown",
565
- });
566
- return refusal(
567
- 503,
568
- "bot_check_unavailable",
569
- "The booking check is unavailable",
570
- );
571
- }
572
- if (isBot) {
573
- console.warn("[relay] bot refused", {
574
- path: entry.path,
575
- outcome: "bot-detected",
576
- });
577
- return refusal(403, "bot_detected", "Request refused");
578
- }
579
- } else {
580
- warnBotCheckInactiveOnce();
498
+ const verdict = await runBotCheck({
499
+ source: "relay",
500
+ target: entry.path,
501
+ scope: entry === BOOK_ROUTE ? "booking" : "write",
502
+ });
503
+ if (verdict === "unavailable") {
504
+ return refusal(503, "bot_check_unavailable", "The bot check is unavailable");
581
505
  }
506
+ if (verdict === "bot") return refusal(403, "bot_detected", "Request refused");
582
507
  }
583
508
 
584
509
  try {
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The route-family helper (#10051 D5) — the generic half of a content hub
3
+ * that lists its detail pages automatically: read every detail page of one
4
+ * type, and slot a generated section in before the page's closing CTAs.
5
+ *
6
+ * The /templates hub (#10051) is its first caller. It is shaped for the next
7
+ * two: #10043 (a services category page listing its child `Service` pages)
8
+ * and #10044 (a pricing hub listing its offering `Pricing` pages). The
9
+ * /alternatives and /reports routes keep their own local copies (#10051
10
+ * § Out of scope).
11
+ *
12
+ * Base tree: no paid import, no `oss: false`.
13
+ */
14
+ import {
15
+ getMdxSlugs,
16
+ getMdxSource,
17
+ type getPageSections,
18
+ type SchemaOrgType,
19
+ } from "@working-theory/page-renderer";
20
+
21
+ type Section = Awaited<ReturnType<typeof getPageSections>>[number];
22
+
23
+ /** One detail page as a hub lists it: its slug and raw frontmatter. */
24
+ export interface DetailPage {
25
+ slug: string;
26
+ frontmatter: Record<string, unknown>;
27
+ }
28
+
29
+ /**
30
+ * Every detail page of `type` for `locale`, loaded through `load` (the
31
+ * content adapter by default), a slug whose load is `null` dropped, sorted
32
+ * by slug so the list is stable. `listSlugs` defaults to the content adapter
33
+ * too; a route passes its own to stay injectable in tests.
34
+ */
35
+ export async function listDetailPages(
36
+ type: SchemaOrgType,
37
+ locale: string,
38
+ load: typeof getMdxSource = getMdxSource,
39
+ listSlugs: typeof getMdxSlugs = getMdxSlugs,
40
+ ): Promise<DetailPage[]> {
41
+ const slugs = await listSlugs(type, locale);
42
+ const pages = await Promise.all(
43
+ slugs.map(async (slug): Promise<DetailPage | null> => {
44
+ const source = await load(type, slug, locale);
45
+ if (source === null) return null;
46
+ return {
47
+ slug,
48
+ frontmatter: source.frontmatter as Record<string, unknown>,
49
+ };
50
+ }),
51
+ );
52
+ return pages
53
+ .filter((page): page is DetailPage => page !== null)
54
+ .sort((a, b) => a.slug.localeCompare(b.slug));
55
+ }
56
+
57
+ /**
58
+ * Insert `section` before the trailing run of `cta` sections — the page's
59
+ * closing call to action stays last — or append it when the page does not end
60
+ * in a CTA (the /reports rule, #7745 D4).
61
+ */
62
+ export function insertBeforeClosingCtas(
63
+ sections: Section[],
64
+ section: Section,
65
+ ): Section[] {
66
+ let at = sections.length;
67
+ while (at > 0 && sections[at - 1]?.type === "cta") at -= 1;
68
+ return [...sections.slice(0, at), section, ...sections.slice(at)];
69
+ }