create-ailk 0.26.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 (224) hide show
  1. package/README.md +86 -9
  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 +25 -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 +112 -11
  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 +90 -6
  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 +12 -2
  44. package/templates/CLAUDE.md +20 -20
  45. package/templates/README.md +19 -19
  46. package/templates/apps/api/.env.example +16 -5
  47. package/templates/apps/api/CLAUDE.md +32 -11
  48. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
  49. package/templates/apps/api/src/vercel-handler.ts +8 -1
  50. package/templates/apps/api/vercel.json +7 -1
  51. package/templates/apps/mcp/CLAUDE.md +18 -10
  52. package/templates/apps/web/.env.example +27 -0
  53. package/templates/apps/web/CLAUDE.md +56 -8
  54. package/templates/apps/web/__tests__/setup.ts +7 -0
  55. package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
  56. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
  57. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +51 -0
  58. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
  59. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +10 -4
  60. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +197 -4
  61. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +23 -7
  62. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
  63. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
  64. package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
  65. package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
  66. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  67. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
  68. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
  69. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
  70. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
  71. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
  72. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
  73. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
  74. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
  75. package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
  76. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
  77. package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
  78. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
  79. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  80. package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
  81. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
  82. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
  83. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
  84. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
  85. package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
  86. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
  87. package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
  88. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
  89. package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
  90. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
  91. package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
  92. package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
  93. package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
  94. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
  95. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
  96. package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
  97. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
  98. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
  99. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
  100. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
  101. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
  102. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
  103. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
  104. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
  105. package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
  106. package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
  107. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
  108. package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
  109. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
  110. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  111. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
  112. package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
  113. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
  114. package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
  115. package/templates/apps/web/app/[locale]/layout.tsx +161 -128
  116. package/templates/apps/web/app/[locale]/page.tsx +51 -3
  117. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
  118. package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
  119. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
  120. package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
  121. package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
  122. package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
  123. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
  124. package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
  125. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  126. package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
  127. package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
  128. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
  129. package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
  130. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
  131. package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
  132. package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
  133. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
  134. package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
  135. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
  136. package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
  137. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
  138. package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
  139. package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
  140. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
  141. package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
  142. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
  143. package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +223 -0
  144. package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
  145. package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
  146. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  147. package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
  148. package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
  149. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
  150. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  151. package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
  152. package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
  153. package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +236 -0
  154. package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
  155. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  156. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
  157. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  158. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +322 -2
  159. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +161 -29
  160. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
  161. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
  162. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
  163. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
  164. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
  165. package/templates/apps/web/app/llms.txt/route.ts +41 -3
  166. package/templates/apps/web/app/robots.txt/route.ts +5 -1
  167. package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
  168. package/templates/apps/web/auth.ts +102 -50
  169. package/templates/apps/web/components/signed-in-nav.tsx +8 -1
  170. package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
  171. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
  172. package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
  173. package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
  174. package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
  175. package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +278 -0
  176. package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
  177. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
  178. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
  179. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
  180. package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
  181. package/templates/apps/web/lib/__tests__/relay.test.ts +484 -4
  182. package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
  183. package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
  184. package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
  185. package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
  186. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
  187. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
  188. package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
  189. package/templates/apps/web/lib/auth-secret.ts +57 -0
  190. package/templates/apps/web/lib/bot-check.ts +138 -0
  191. package/templates/apps/web/lib/consent-visitor.ts +81 -7
  192. package/templates/apps/web/lib/deck-page.ts +6 -3
  193. package/templates/apps/web/lib/docs-index.ts +100 -0
  194. package/templates/apps/web/lib/docs-path.ts +94 -0
  195. package/templates/apps/web/lib/hub-children.tsx +318 -0
  196. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +22 -2
  197. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +11 -8
  198. package/templates/apps/web/lib/modules.generated.ts +9 -0
  199. package/templates/apps/web/lib/nav-label-check.ts +61 -0
  200. package/templates/apps/web/lib/nav-label-keys.ts +78 -0
  201. package/templates/apps/web/lib/newsletter.actions.ts +31 -6
  202. package/templates/apps/web/lib/offerings-pricing.ts +135 -6
  203. package/templates/apps/web/lib/page-chrome.ts +367 -0
  204. package/templates/apps/web/lib/posting-title.ts +26 -4
  205. package/templates/apps/web/lib/pricing-page.ts +430 -0
  206. package/templates/apps/web/lib/relay.ts +44 -4
  207. package/templates/apps/web/lib/route-family.ts +69 -0
  208. package/templates/apps/web/lib/service-listing.ts +89 -0
  209. package/templates/apps/web/lib/services-path.ts +97 -0
  210. package/templates/apps/web/lib/sign-up-agreements.ts +17 -6
  211. package/templates/apps/web/lib/site-header.ts +94 -0
  212. package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
  213. package/templates/apps/web/lib/template-listing.ts +169 -0
  214. package/templates/apps/web/messages/en.json +144 -3
  215. package/templates/apps/web/middleware.ts +179 -12
  216. package/templates/apps/web/next.config.mjs +220 -3
  217. package/templates/apps/web/package.json +3 -2
  218. package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
  219. package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
  220. package/templates/apps/web/sentry.server.config.ts +30 -2
  221. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
  222. package/templates/apps/web/src/lib/rate-limit.ts +68 -27
  223. package/templates/apps/web/src/lib/routes.ts +15 -1
  224. package/versions.json +48 -48
@@ -0,0 +1,84 @@
1
+ // #9162 (PR #9152's security review) — the deliverable grant id in the PATH of
2
+ // `<hub>/v1/deliverables/<grantId>`, which `deliverables.openGrant` fetches
3
+ // server-side, is redacted before a URL reaches Sentry. The query-param scrub
4
+ // has its own suite, `__tests__/telemetry-scrub.test.ts`.
5
+
6
+ import { scrubTelemetryUrl } from "../telemetry-scrub";
7
+
8
+ const GRANT = "dgrant_01920000-0000-7000-8000-000000000001";
9
+
10
+ describe("scrubTelemetryUrl — the /v1/deliverables/<grantId> path (#9162)", () => {
11
+ it("redacts the grant id of an absolute hub URL", () => {
12
+ expect(
13
+ scrubTelemetryUrl(`https://hub.example.com/v1/deliverables/${GRANT}`),
14
+ ).toBe("https://hub.example.com/v1/deliverables/:grantId");
15
+ });
16
+
17
+ it("redacts the grant id of a relative URL", () => {
18
+ expect(scrubTelemetryUrl(`/v1/deliverables/${GRANT}`)).toBe(
19
+ "/v1/deliverables/:grantId",
20
+ );
21
+ });
22
+
23
+ it("keeps the query string and fragment around the redacted id", () => {
24
+ expect(
25
+ scrubTelemetryUrl(
26
+ `https://hub.example.com/v1/deliverables/${GRANT}?x=1#top`,
27
+ ),
28
+ ).toBe("https://hub.example.com/v1/deliverables/:grantId?x=1#top");
29
+ });
30
+
31
+ it("redacts the id and strips a token query param together", () => {
32
+ const out = scrubTelemetryUrl(
33
+ `https://hub.example.com/v1/deliverables/${GRANT}?g=${GRANT}&x=1`,
34
+ );
35
+ expect(out).toBe("https://hub.example.com/v1/deliverables/:grantId?x=1");
36
+ expect(out).not.toContain(GRANT);
37
+ });
38
+
39
+ it("redacts an id the api-client percent-encoded", () => {
40
+ const out = scrubTelemetryUrl(
41
+ `https://hub.example.com/v1/deliverables/${encodeURIComponent("a b%c")}`,
42
+ );
43
+ expect(out).toBe("https://hub.example.com/v1/deliverables/:grantId");
44
+ });
45
+
46
+ it("redacts the id under an API base path", () => {
47
+ expect(
48
+ scrubTelemetryUrl(`https://hub.example.com/api/v1/deliverables/${GRANT}`),
49
+ ).toBe("https://hub.example.com/api/v1/deliverables/:grantId");
50
+ });
51
+
52
+ it("redacts the id inside a span description (`METHOD url`)", () => {
53
+ expect(
54
+ scrubTelemetryUrl(`GET https://hub.example.com/v1/deliverables/${GRANT}`),
55
+ ).toBe("GET https://hub.example.com/v1/deliverables/:grantId");
56
+ });
57
+
58
+ it("leaves the ungated open read's slug alone — a slug is not a credential", () => {
59
+ for (const url of [
60
+ "https://hub.example.com/v1/deliverables/open/q3",
61
+ "/v1/deliverables/open/q3?x=1",
62
+ "GET https://hub.example.com/v1/deliverables/open/q3",
63
+ ]) {
64
+ expect(scrubTelemetryUrl(url)).toBe(url);
65
+ }
66
+ });
67
+
68
+ it("redacts a grant id that only starts with `open`", () => {
69
+ expect(scrubTelemetryUrl("/v1/deliverables/openly_a_grant")).toBe(
70
+ "/v1/deliverables/:grantId",
71
+ );
72
+ });
73
+
74
+ it("leaves other paths unchanged", () => {
75
+ for (const url of [
76
+ "https://hub.example.com/v1/deliverables",
77
+ "https://hub.example.com/v1/deliverables/",
78
+ "/v1/leads/submit",
79
+ `/reports/q3/file`,
80
+ ]) {
81
+ expect(scrubTelemetryUrl(url)).toBe(url);
82
+ }
83
+ });
84
+ });
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The template-listing mapping (#10051 D6, AC-6) — a TemplatePage's
3
+ * frontmatter to a hub card, an action label and the detail page's
4
+ * generated sections. Pure; no I/O.
5
+ */
6
+ import { PageSectionsSchema } from "@working-theory/validation";
7
+
8
+ import {
9
+ templateActionLabel,
10
+ templateCard,
11
+ templateDetailSections,
12
+ type TemplateLabels,
13
+ } from "../template-listing";
14
+
15
+ const labels: TemplateLabels = {
16
+ getIt: "Get it",
17
+ startFree: "Start free",
18
+ viewDemo: "View demo",
19
+ viewTemplate: "View template",
20
+ included: "What's included",
21
+ demoBadge: "Live demo",
22
+ moreCategories: (count) => `+${count} more`,
23
+ };
24
+
25
+ const full = {
26
+ slug: "a",
27
+ frontmatter: {
28
+ title: "Template A",
29
+ description: "Description A.",
30
+ summary: "Summary A.",
31
+ thumbnail: "/img/a.png",
32
+ categories: ["accounting", "not-a-category", "legal"],
33
+ demoUrl: "https://a.salessmyth.com",
34
+ getItUrl: "/contact",
35
+ price: 0,
36
+ includes: [
37
+ { title: "Base site", description: "Pages and structured data." },
38
+ ],
39
+ },
40
+ };
41
+
42
+ const bare = {
43
+ slug: "b",
44
+ frontmatter: {
45
+ title: "Template B",
46
+ description: "Description B.",
47
+ getItUrl: "https://buy.example.com/b",
48
+ includes: [
49
+ { title: "Base site", description: "Pages and structured data." },
50
+ ],
51
+ },
52
+ };
53
+
54
+ describe("templateCard", () => {
55
+ it("maps every present field", () => {
56
+ expect(templateCard(full, labels)).toEqual({
57
+ title: "Template A",
58
+ description: "Summary A.",
59
+ image: { src: "/img/a.png", alt: "Template A" },
60
+ eyebrow: "Accounting · Legal",
61
+ badge: "Live demo",
62
+ link: { label: "View template", href: "/templates/a" },
63
+ });
64
+ });
65
+
66
+ it("omits image, eyebrow and badge when their fields are absent, and falls back to description", () => {
67
+ expect(templateCard(bare, labels)).toEqual({
68
+ title: "Template B",
69
+ description: "Description B.",
70
+ link: { label: "View template", href: "/templates/b" },
71
+ });
72
+ });
73
+
74
+ it("omits the eyebrow when every category slug is unknown", () => {
75
+ const card = templateCard(
76
+ { slug: "c", frontmatter: { ...bare.frontmatter, categories: ["nope"] } },
77
+ labels,
78
+ );
79
+ expect(card).not.toHaveProperty("eyebrow");
80
+ });
81
+
82
+ it("shows at most three category names, then +N more", () => {
83
+ const card = templateCard(
84
+ {
85
+ slug: "d",
86
+ frontmatter: {
87
+ ...bare.frontmatter,
88
+ categories: ["accounting", "legal", "consulting", "hvac", "roofing"],
89
+ },
90
+ },
91
+ labels,
92
+ );
93
+ expect(card.eyebrow).toBe("Accounting · Legal · Consulting · +2 more");
94
+ });
95
+ });
96
+
97
+ describe("templateActionLabel", () => {
98
+ it("is Start free only for a price of exactly 0", () => {
99
+ expect(templateActionLabel(0, labels)).toBe("Start free");
100
+ expect(templateActionLabel(undefined, labels)).toBe("Get it");
101
+ expect(templateActionLabel(5, labels)).toBe("Get it");
102
+ });
103
+ });
104
+
105
+ describe("templateDetailSections", () => {
106
+ it("builds a hero and a What's included section", () => {
107
+ const [hero, included] = templateDetailSections(full.frontmatter, labels);
108
+ expect(hero).toMatchObject({
109
+ type: "hero",
110
+ data: {
111
+ headline: "Template A",
112
+ subheadline: "Summary A.",
113
+ eyebrow: "Accounting · Legal",
114
+ image: { src: "/img/a.png", alt: "Template A" },
115
+ primaryCta: { label: "Start free", href: "/contact" },
116
+ secondaryCta: {
117
+ label: "View demo",
118
+ href: "https://a.salessmyth.com",
119
+ variant: "outline",
120
+ },
121
+ },
122
+ });
123
+ expect(included).toEqual({
124
+ type: "items",
125
+ id: "template-included",
126
+ data: {
127
+ headline: "What's included",
128
+ variant: "minimal",
129
+ items: [
130
+ { title: "Base site", description: "Pages and structured data." },
131
+ ],
132
+ },
133
+ });
134
+ });
135
+
136
+ it("omits the demo link, image and eyebrow when absent", () => {
137
+ const [hero] = templateDetailSections(bare.frontmatter, labels);
138
+ const data = (hero as { data: Record<string, unknown> }).data;
139
+ expect(data.primaryCta).toEqual({
140
+ label: "Get it",
141
+ href: "https://buy.example.com/b",
142
+ });
143
+ expect(data).not.toHaveProperty("secondaryCta");
144
+ expect(data).not.toHaveProperty("image");
145
+ expect(data).not.toHaveProperty("eyebrow");
146
+ expect(data.subheadline).toBe("Description B.");
147
+ });
148
+
149
+ it.each([
150
+ ["full", full.frontmatter],
151
+ ["bare", bare.frontmatter],
152
+ ])("the %s result parses PageSectionsSchema", (_label, frontmatter) => {
153
+ expect(
154
+ PageSectionsSchema.safeParse(templateDetailSections(frontmatter, labels))
155
+ .success,
156
+ ).toBe(true);
157
+ });
158
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * auth-secret.ts — `resolveSecret()`, moved verbatim out of `auth.ts`
3
+ * (#8556, spec docs/specs/9965-sweep-a-trust-or-authz-spec.md D7) so a
4
+ * consumer that only needs the secret does not load Better Auth, which is
5
+ * ESM-only. `auth.ts` re-exports it, so every existing import is unchanged.
6
+ */
7
+
8
+ /**
9
+ * Resolve the secret used to sign session cookies AND — since #5962 § D9 — the
10
+ * email-verification JWTs.
11
+ *
12
+ * THROWS in production when BETTER_AUTH_SECRET is unset (§ D16). Better Auth's
13
+ * own `validateSecret` throws only when the secret equals ITS default; this
14
+ * repo's literal is a different string, so it cleared that check and produced
15
+ * nothing but a low-entropy warning — while sitting in a repository whose
16
+ * `oss`-tier tree ships publicly. Anyone who can read this file could forge
17
+ * both a session cookie and a verification token on any deployment that
18
+ * omitted the variable.
19
+ *
20
+ * This changes boot behaviour: a production deployment with no
21
+ * BETTER_AUTH_SECRET boots today and does not after this. That is the correct
22
+ * failure — a site signing sessions with a published string is not usefully
23
+ * "up" — and `apps/web/.env.example` has always documented the variable as
24
+ * REQUIRED in production. The change makes the file honest rather than adding
25
+ * a requirement.
26
+ *
27
+ * Exported (#8146 D5) so the hub's sign-in continuation seals its ref under the
28
+ * same resolved secret instead of re-reading the variable: the second consumer
29
+ * after `betterAuth({ secret })`.
30
+ */
31
+ export function resolveSecret(): string {
32
+ const configured = process.env.BETTER_AUTH_SECRET;
33
+ if (configured) return configured;
34
+
35
+ // SERVING, not BUILDING. `next build` collects page data with
36
+ // NODE_ENV=production, and that evaluates `app/api/auth/[...all]/route.ts`,
37
+ // which resolves the lazy `auth` proxy — so an unqualified production throw
38
+ // fails the BUILD of any tree that has not set the variable. That is much
39
+ // broader than the risk: a build produces an artifact, it does not serve a
40
+ // session, and it would break CI, preview builds, and `pnpm build` on a
41
+ // freshly scaffolded site that has not configured auth at all.
42
+ //
43
+ // Next sets NEXT_PHASE to PHASE_PRODUCTION_BUILD ("phase-production-build")
44
+ // for the duration of the build, so excluding that phase keeps the guard
45
+ // exactly where it was aimed: a production process that is about to answer
46
+ // requests. Such a process still throws on the first access to `auth`, which
47
+ // is the intended failure — a site signing sessions with a string published
48
+ // in this repository is not usefully "up".
49
+ const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";
50
+ if (process.env.NODE_ENV === "production" && !isBuildPhase) {
51
+ throw new Error(
52
+ "BETTER_AUTH_SECRET is required in production: it signs session cookies and email-verification tokens, and the development fallback is published in this repository.",
53
+ );
54
+ }
55
+
56
+ return "ailk-dev-fallback-secret-published-in-this-repo-never-use-in-production";
57
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The spoke edge's one bot check (#9889, #9255, spec
3
+ * docs/specs/9255-an-edge-bot-challenge-in-spec.md).
4
+ *
5
+ * Vercel BotID's verdict on a public write, asked on this site's own server
6
+ * before the hub is called: by the relay for its checked pairs (`relay.ts`'s
7
+ * `BOT_CHECKED_ROUTES`) and by each public lead, flow and checkout Server
8
+ * Action immediately before its hub call. MCP tools and key-bearing API calls
9
+ * never pass through a site, so they are never challenged.
10
+ *
11
+ * It fails closed (#9889 D3): only `isBot === false` passes. A bot is `bot`;
12
+ * a check that throws, answers no boolean or takes over 5 seconds is
13
+ * `unavailable`. It runs only on Vercel (`VERCEL=1`); `AILK_BOT_CHECK=off`
14
+ * turns every check off and `AILK_BOOKING_BOT_CHECK=off` the booking's alone.
15
+ *
16
+ * NO SECRET IS LOGGED. A refusal logs `{ path, outcome }` and, for a failed
17
+ * check, the error's name — never its message.
18
+ */
19
+ import { checkBotId } from "botid/server";
20
+
21
+ export type BotCheckScope = "write" | "booking";
22
+ export type BotCheckOutcome = "pass" | "bot" | "unavailable";
23
+
24
+ /**
25
+ * BotID's browser `protect` list: every same-origin POST. A Server Action
26
+ * posts to the URL of the page it was submitted from (the footer's newsletter
27
+ * signup is on every page), and a checked request the client did not protect
28
+ * carries no challenge header and is refused as a bot.
29
+ */
30
+ export const BOT_CLIENT_PROTECT = Object.freeze([
31
+ Object.freeze({ path: "/*", method: "POST" }),
32
+ ]) as { path: string; method: string }[];
33
+
34
+ /** The longest a check waits for a verdict; the library's own fetch has no bound. */
35
+ const BOT_CHECK_TIMEOUT_MS = 5_000;
36
+
37
+ /** Unset, empty or any other value leaves a check on, so a typo fails toward protection. */
38
+ function isOff(value: string | undefined): boolean {
39
+ return value?.trim().toLowerCase() === "off";
40
+ }
41
+
42
+ function switchedOff(scope: BotCheckScope): boolean {
43
+ return (
44
+ isOff(process.env.AILK_BOT_CHECK) ||
45
+ (scope === "booking" && isOff(process.env.AILK_BOOKING_BOT_CHECK))
46
+ );
47
+ }
48
+
49
+ /** True when the check runs for `scope`. Read on every call, never cached. */
50
+ export function botCheckOn(scope: BotCheckScope): boolean {
51
+ return process.env.VERCEL === "1" && !switchedOff(scope);
52
+ }
53
+
54
+ let warnedBotCheckInactive = false;
55
+
56
+ /**
57
+ * Called when the check is off. Off Vercel it cannot run (#9889 D2): a
58
+ * production site whose switch is not `off` logs that once per process, so an
59
+ * owner who believes it is on can find out. It never refuses.
60
+ */
61
+ function warnBotCheckInactiveOnce(scope: BotCheckScope): void {
62
+ if (warnedBotCheckInactive || process.env.NODE_ENV !== "production") return;
63
+ if (switchedOff(scope)) return;
64
+ warnedBotCheckInactive = true;
65
+ console.warn("[relay] booking bot check inactive", {
66
+ outcome: "not-on-vercel",
67
+ });
68
+ }
69
+
70
+ function namedError(name: string): Error {
71
+ const err = new Error(name);
72
+ err.name = name;
73
+ return err;
74
+ }
75
+
76
+ /**
77
+ * BotID's verdict on this request: `true` for a bot, `false` for a human.
78
+ * Throws `TimeoutError` past `BOT_CHECK_TIMEOUT_MS` and `InvalidVerdictError`
79
+ * when `isBot` is not a boolean, as `checkBotId` returns for an error answer
80
+ * from Vercel.
81
+ */
82
+ async function isBotVerdict(): Promise<boolean> {
83
+ let timer: ReturnType<typeof setTimeout> | undefined;
84
+ try {
85
+ const verdict = await Promise.race([
86
+ checkBotId(),
87
+ new Promise<never>((_resolve, reject) => {
88
+ timer = setTimeout(
89
+ () => reject(namedError("TimeoutError")),
90
+ BOT_CHECK_TIMEOUT_MS,
91
+ );
92
+ }),
93
+ ]);
94
+ const isBot = (verdict as { isBot?: unknown } | undefined)?.isBot;
95
+ if (typeof isBot !== "boolean") throw namedError("InvalidVerdictError");
96
+ return isBot;
97
+ } finally {
98
+ clearTimeout(timer);
99
+ }
100
+ }
101
+
102
+ /**
103
+ * The check for one write. `source` names the log prefix and `target` the
104
+ * relayed path or the action's name — never a field value. Never throws.
105
+ */
106
+ export async function runBotCheck({
107
+ source,
108
+ target,
109
+ scope,
110
+ }: {
111
+ source: string;
112
+ target: string;
113
+ scope: BotCheckScope;
114
+ }): Promise<BotCheckOutcome> {
115
+ if (!botCheckOn(scope)) {
116
+ warnBotCheckInactiveOnce(scope);
117
+ return "pass";
118
+ }
119
+ let isBot: boolean;
120
+ try {
121
+ isBot = await isBotVerdict();
122
+ } catch (err) {
123
+ console.error(`[${source}] bot check failed`, {
124
+ path: target,
125
+ outcome: "bot-check-failed",
126
+ error: err instanceof Error ? err.name : "unknown",
127
+ });
128
+ return "unavailable";
129
+ }
130
+ if (isBot) {
131
+ console.warn(`[${source}] bot refused`, {
132
+ path: target,
133
+ outcome: "bot-detected",
134
+ });
135
+ return "bot";
136
+ }
137
+ return "pass";
138
+ }
@@ -10,9 +10,43 @@
10
10
  *
11
11
  * httpOnly, `Secure` in production, `SameSite=Lax`, path `/`, one year — a
12
12
  * Server Action can read and mint it; client script never sees its value.
13
+ *
14
+ * The cookie is `<uuid>.<sig>`, signed by this site (#8556, spec
15
+ * docs/specs/9965-sweep-a-trust-or-authz-spec.md D7): `sig` is the base64url
16
+ * HMAC-SHA256 of the UUID under a subkey derived from the site's own secret
17
+ * with a fixed label, so that secret is never used raw for a second purpose.
18
+ * A read returns the bare UUID only when the signature verifies, so a visitor
19
+ * id the browser made up is never forwarded as `x-ailk-consent-visitor`.
20
+ *
21
+ * The secret is one the site already holds, taken in this fixed order
22
+ * (operator decision on #9965, option (b)):
23
+ *
24
+ * 1. `BETTER_AUTH_SECRET`, via `resolveSecret()` — a hub, or any site that
25
+ * builds Better Auth;
26
+ * 2. `AILK_HUB_SESSION_SECRET` — a spoke that signs in through its hub;
27
+ * 3. `AILK_SPOKE_KEY` — any other spoke;
28
+ *
29
+ * each of 2 and 3 only at 32 characters or more once trimmed (the floor
30
+ * `hub-session/config.ts` sets; a minted spoke key is longer), so a short
31
+ * value a visitor could brute-force from their own cookie is never a key;
32
+ * 4. otherwise `resolveSecret()` again, which is the development fallback
33
+ * outside production and THROWS in a production process.
34
+ *
35
+ * So a production spoke, which create-ailk deliberately gives no
36
+ * `BETTER_AUTH_SECRET`, still signs and verifies its own ids. With none of
37
+ * the three set a read is `undefined` and a mint throws, so nothing unsigned
38
+ * is ever written or forwarded (fail closed). A verifier recomputes the
39
+ * signature under its own secret, so it accepts only what its own site signed.
13
40
  */
14
41
 
42
+ import { createHmac, timingSafeEqual } from "node:crypto";
43
+
44
+ import { resolveSecret } from "./auth-secret";
45
+
15
46
  const CONSENT_VISITOR_COOKIE_NAME = "ailk-vid";
47
+ const SIGNING_LABEL = "ailk-consent-visitor-v1";
48
+ /** The shortest spoke-held secret this module signs with. */
49
+ const MIN_FALLBACK_SECRET_LENGTH = 32;
16
50
  const ONE_YEAR_SECONDS = 60 * 60 * 24 * 365;
17
51
 
18
52
  const V4_UUID =
@@ -23,6 +57,42 @@ export function isValidConsentVisitorId(value: string): boolean {
23
57
  return V4_UUID.test(value);
24
58
  }
25
59
 
60
+ /** The site's own signing secret, in the order the header documents. */
61
+ function signingSecret(): string {
62
+ if (process.env.BETTER_AUTH_SECRET) return resolveSecret();
63
+ const fallback = [process.env.AILK_HUB_SESSION_SECRET, process.env.AILK_SPOKE_KEY]
64
+ .map((value) => value?.trim() ?? "")
65
+ .find((value) => value.length >= MIN_FALLBACK_SECRET_LENGTH);
66
+ return fallback ?? resolveSecret();
67
+ }
68
+
69
+ /** The base64url signature of `id`. Throws when no secret is set in production. */
70
+ function sign(id: string): string {
71
+ const subkey = createHmac("sha256", signingSecret()).update(SIGNING_LABEL).digest();
72
+ return createHmac("sha256", subkey).update(id).digest("base64url");
73
+ }
74
+
75
+ /**
76
+ * The UUID a signed cookie value carries, or `undefined` for a bare UUID, a
77
+ * bad or wrong-length signature, or a secret that cannot be resolved.
78
+ */
79
+ function verifiedId(value: string): string | undefined {
80
+ const dot = value.indexOf(".");
81
+ if (dot === -1) return undefined;
82
+ const id = value.slice(0, dot);
83
+ if (!isValidConsentVisitorId(id)) return undefined;
84
+ let expected: Buffer;
85
+ try {
86
+ expected = Buffer.from(sign(id));
87
+ } catch {
88
+ return undefined;
89
+ }
90
+ const presented = Buffer.from(value.slice(dot + 1));
91
+ // timingSafeEqual throws on unequal lengths, so that is checked first.
92
+ if (presented.length !== expected.length) return undefined;
93
+ return timingSafeEqual(presented, expected) ? id : undefined;
94
+ }
95
+
26
96
  /** The subset of Next's `cookies()` jar a read needs. */
27
97
  export interface ConsentVisitorCookieReader {
28
98
  get(name: string): { value: string } | undefined;
@@ -50,16 +120,21 @@ export interface ConsentVisitorCookieWriter extends ConsentVisitorCookieReader {
50
120
  set(name: string, value: string, options: ConsentVisitorCookieOptions): void;
51
121
  }
52
122
 
53
- /** The existing visitor id, or `undefined` with none set or an invalid value. */
123
+ /**
124
+ * The existing visitor id, or `undefined` with none set or a value whose
125
+ * signature does not verify.
126
+ */
54
127
  export function readConsentVisitor(
55
128
  cookies: ConsentVisitorCookieReader,
56
129
  ): string | undefined {
57
130
  const value = cookies.get(CONSENT_VISITOR_COOKIE_NAME)?.value;
58
- return value !== undefined && isValidConsentVisitorId(value) ? value : undefined;
131
+ return value !== undefined ? verifiedId(value) : undefined;
59
132
  }
60
133
 
61
134
  /**
62
- * The visitor id, minting and setting a fresh one when absent or invalid.
135
+ * The visitor id, minting and setting a fresh signed one when absent or when
136
+ * the stored value does not verify (a cookie minted before signing included).
137
+ * Throws, setting nothing, when a production site holds none of the secrets.
63
138
  * Server Actions only — Next refuses a cookie write outside one (the same
64
139
  * posture `hub-session/reseal.ts` documents).
65
140
  */
@@ -67,7 +142,7 @@ export function ensureConsentVisitor(cookies: ConsentVisitorCookieWriter): strin
67
142
  const existing = readConsentVisitor(cookies);
68
143
  if (existing !== undefined) return existing;
69
144
  const id = crypto.randomUUID();
70
- cookies.set(CONSENT_VISITOR_COOKIE_NAME, id, {
145
+ cookies.set(CONSENT_VISITOR_COOKIE_NAME, `${id}.${sign(id)}`, {
71
146
  httpOnly: true,
72
147
  secure: process.env.NODE_ENV === "production",
73
148
  sameSite: "lax",
@@ -85,7 +160,7 @@ export function consentVisitorHeader(id: string): Record<string, string> {
85
160
  /**
86
161
  * The visitor id out of a raw `Cookie:` header string (the relay's own
87
162
  * `Request`, which carries no Next `cookies()` jar). `undefined` with no
88
- * header, no matching cookie, or a value that fails the UUID shape.
163
+ * header, no matching cookie, or a value whose signature does not verify.
89
164
  */
90
165
  export function readConsentVisitorFromCookieHeader(
91
166
  header: string | null | undefined,
@@ -96,8 +171,7 @@ export function readConsentVisitorFromCookieHeader(
96
171
  if (eq === -1) continue;
97
172
  const name = part.slice(0, eq).trim();
98
173
  if (name !== CONSENT_VISITOR_COOKIE_NAME) continue;
99
- const value = part.slice(eq + 1).trim();
100
- return isValidConsentVisitorId(value) ? value : undefined;
174
+ return verifiedId(part.slice(eq + 1).trim());
101
175
  }
102
176
  return undefined;
103
177
  }
@@ -42,11 +42,14 @@ export const DECK_ROUTES = [
42
42
  "/faq",
43
43
  "/launch-service",
44
44
  "/pricing",
45
+ "/pricing/:slug",
45
46
  "/privacy",
46
47
  "/projects",
47
48
  "/projects/:slug",
48
49
  "/purchase-policy",
49
50
  "/reports",
51
+ "/templates",
52
+ "/templates/:slug",
50
53
  "/terms",
51
54
  "/thanks/:slug",
52
55
  "/working-agreement",
@@ -65,7 +68,7 @@ function routeSegments(route: string): string[] {
65
68
  return route.split("/").filter((segment) => segment.length > 0);
66
69
  }
67
70
 
68
- function matchesRoute(routeDef: string, pathSegments: readonly string[]): boolean {
71
+ export function matchesRoute(routeDef: string, pathSegments: readonly string[]): boolean {
69
72
  const defSegments = routeSegments(routeDef);
70
73
  if (defSegments.length !== pathSegments.length) return false;
71
74
  return defSegments.every(
@@ -74,7 +77,7 @@ function matchesRoute(routeDef: string, pathSegments: readonly string[]): boolea
74
77
  }
75
78
 
76
79
  /** The type and slug a matched routes-map row resolves to for this request. */
77
- function resolveTypeAndSlug(
80
+ export function resolveTypeAndSlug(
78
81
  row: RouteRow,
79
82
  routeDef: string,
80
83
  pathSegments: readonly string[],
@@ -94,7 +97,7 @@ function resolveTypeAndSlug(
94
97
  }
95
98
 
96
99
  /** Whether a routes-map row at this exact route is always scaffolded (never absent from a site). */
97
- function isAlwaysScaffolded(path: string): boolean {
100
+ export function isAlwaysScaffolded(path: string): boolean {
98
101
  const row = ROUTES.find((candidate) => candidate.route === path);
99
102
  return row !== undefined && row.gateable !== true && row.module === "marketing";
100
103
  }