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
@@ -43,6 +43,7 @@ import {
43
43
  isSignUpOpen,
44
44
  resetSendConfigured,
45
45
  } from "./lib/auth-env";
46
+ import { resolveSecret } from "./lib/auth-secret";
46
47
  import {
47
48
  signUpAgreementHooks,
48
49
  signUpAgreementsPlugin,
@@ -181,55 +182,102 @@ function buildSocialProviders(): SocialProviders {
181
182
  return providers;
182
183
  }
183
184
 
185
+ // `resolveSecret()` lives in `lib/auth-secret.ts` (#8556) so the consent
186
+ // visitor id can be signed without loading Better Auth; re-exported here for
187
+ // every caller that already imports it from this module.
188
+ export { resolveSecret };
189
+
190
+ /** A loopback host, where `http:` is the only scheme a local site has. */
191
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
192
+
184
193
  /**
185
- * Resolve the secret used to sign session cookies AND — since #5962 § D9 — the
186
- * email-verification JWTs.
187
- *
188
- * THROWS in production when BETTER_AUTH_SECRET is unset (§ D16). Better Auth's
189
- * own `validateSecret` throws only when the secret equals ITS default; this
190
- * repo's literal is a different string, so it cleared that check and produced
191
- * nothing but a low-entropy warning — while sitting in a repository whose
192
- * `oss`-tier tree ships publicly. Anyone who can read this file could forge
193
- * both a session cookie and a verification token on any deployment that
194
- * omitted the variable.
195
- *
196
- * This changes boot behaviour: a production deployment with no
197
- * BETTER_AUTH_SECRET boots today and does not after this. That is the correct
198
- * failure — a site signing sessions with a published string is not usefully
199
- * "up" — and `apps/web/.env.example` has always documented the variable as
200
- * REQUIRED in production. The change makes the file honest rather than adding
201
- * a requirement.
194
+ * Resolve Better Auth's `baseURL` — and so the one trusted origin — from
195
+ * BETTER_AUTH_URL (#8620, #9965 D10), modelled on `resolveSecret()`.
202
196
  *
203
- * Exported (#8146 D5) so the hub's sign-in continuation seals its ref under the
204
- * same resolved secret instead of re-reading the variable: the second consumer
205
- * after `betterAuth({ secret })`.
197
+ * Unset, Better Auth derives the base URL from each REQUEST's own host (the
198
+ * § D13 account-takeover chain), so a production process that is about to
199
+ * answer requests THROWS without one, as it does without the secret. There
200
+ * the value must be an `https:` URL, or `http:` on a loopback host; anything
201
+ * else counts as unset. The build phase and development read it as before —
202
+ * as set, or `undefined` — so `resetSendConfigured()`'s raw read still
203
+ * agrees with it. create-ailk writes a loopback value beside the secret, so a
204
+ * fresh scaffold's `next start` boots; a production process serving on one
205
+ * WARNS, because an `http:` base URL also drops the session cookie's
206
+ * `Secure` flag and points every emailed link at this machine.
206
207
  */
207
- export function resolveSecret(): string {
208
- const configured = process.env.BETTER_AUTH_SECRET;
209
- if (configured) return configured;
210
-
211
- // SERVING, not BUILDING. `next build` collects page data with
212
- // NODE_ENV=production, and that evaluates `app/api/auth/[...all]/route.ts`,
213
- // which resolves the lazy `auth` proxy — so an unqualified production throw
214
- // fails the BUILD of any tree that has not set the variable. That is much
215
- // broader than the risk: a build produces an artifact, it does not serve a
216
- // session, and it would break CI, preview builds, and `pnpm build` on a
217
- // freshly scaffolded site that has not configured auth at all.
218
- //
219
- // Next sets NEXT_PHASE to PHASE_PRODUCTION_BUILD ("phase-production-build")
220
- // for the duration of the build, so excluding that phase keeps the guard
221
- // exactly where it was aimed: a production process that is about to answer
222
- // requests. Such a process still throws on the first access to `auth`, which
223
- // is the intended failure — a site signing sessions with a string published
224
- // in this repository is not usefully "up".
208
+ function resolveBaseURL(): string | undefined {
209
+ const configured = process.env.BETTER_AUTH_URL;
225
210
  const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";
226
- if (process.env.NODE_ENV === "production" && !isBuildPhase) {
227
- throw new Error(
228
- "BETTER_AUTH_SECRET is required in production: it signs session cookies and email-verification tokens, and the development fallback is published in this repository.",
229
- );
211
+ const serving = process.env.NODE_ENV === "production" && !isBuildPhase;
212
+ const url = configured ? trustableBaseURL(configured) : undefined;
213
+ if (url) {
214
+ if (serving && url.protocol === "http:") {
215
+ console.warn(
216
+ `BETTER_AUTH_URL is the loopback ${url.origin} in production: session cookies are not Secure and emailed links point at this machine. Set it to the site's public https: origin.`,
217
+ );
218
+ }
219
+ return configured;
230
220
  }
231
221
 
232
- return "ailk-dev-fallback-secret-published-in-this-repo-never-use-in-production";
222
+ if (!serving) return configured || undefined;
223
+ throw new Error(
224
+ "BETTER_AUTH_URL is required in production: it is the origin Better Auth builds callbacks and emailed links on, and must be an https: URL (http: only on a loopback host).",
225
+ );
226
+ }
227
+
228
+ /** `value` parsed, when it is `https:` or `http:` on a loopback host. */
229
+ function trustableBaseURL(value: string): URL | undefined {
230
+ let url: URL;
231
+ try {
232
+ url = new URL(value);
233
+ } catch {
234
+ return undefined;
235
+ }
236
+ const trustable = url.protocol === "https:" || (url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname));
237
+ return trustable ? url : undefined;
238
+ }
239
+
240
+ /**
241
+ * The user-write hook (#8602, #9965 D6): a non-empty `image` must be an
242
+ * `http:` or `https:` URL, whatever the client checked. The users table is
243
+ * shared with the Admin's Better Auth, which applies the same rule, so a
244
+ * `javascript:` or `data:` value never reaches an `<img>` or a link. The
245
+ * refusal is code-shaped — no English string reaches a page.
246
+ */
247
+ async function rejectNonHttpImage(user: { image?: unknown }): Promise<void> {
248
+ const { image } = user;
249
+ if (typeof image !== "string" || image === "") return;
250
+ let protocol: string | undefined;
251
+ try {
252
+ protocol = new URL(image).protocol;
253
+ } catch {
254
+ protocol = undefined;
255
+ }
256
+ if (protocol === "https:" || protocol === "http:") return;
257
+ const { APIError } = await import("better-auth/api");
258
+ throw new APIError("BAD_REQUEST", { code: "INVALID_IMAGE_URL", message: "INVALID_IMAGE_URL" });
259
+ }
260
+
261
+ /**
262
+ * The sign-up hooks, with the image check on every user write: on update, and
263
+ * on create (the sign-up endpoint accepts `image` too) ahead of the agreement
264
+ * hook.
265
+ */
266
+ function databaseHooks(): ReturnType<typeof signUpAgreementHooks> {
267
+ const hooks = signUpAgreementHooks();
268
+ const createBefore = hooks.user?.create?.before;
269
+ return {
270
+ ...hooks,
271
+ user: {
272
+ create: {
273
+ before: async (user, context) => {
274
+ await rejectNonHttpImage(user);
275
+ return createBefore?.(user, context);
276
+ },
277
+ },
278
+ update: { before: rejectNonHttpImage },
279
+ },
280
+ };
233
281
  }
234
282
 
235
283
  /**
@@ -313,6 +361,11 @@ export function createAuth(deps: CreateAuthDeps = {}) {
313
361
  */
314
362
  const resetConfigured = resetSendConfigured();
315
363
 
364
+ // Resolved in this order so a production process missing both names the
365
+ // secret first, as it always has.
366
+ const secret = resolveSecret();
367
+ const baseURL = resolveBaseURL();
368
+
316
369
  /**
317
370
  * The emailed sign-in link (#7435 § D1). The SAME predicate `/sign-in` and
318
371
  * the `(authed)` gate read, so the form renders iff the endpoint exists.
@@ -461,11 +514,12 @@ export function createAuth(deps: CreateAuthDeps = {}) {
461
514
  * user carries agreements, the account hook writes them on the captured
462
515
  * transaction client. A magic-link sign-up creates no Account, so the
463
516
  * session hook writes its grants, in their own transaction, before the
464
- * session exists (#9667 D5). No other database hook is installed.
517
+ * session exists (#9667 D5). The one other database hook refuses a
518
+ * non-http(s) image on user create and update (#9965 D6).
465
519
  */
466
- databaseHooks: signUpAgreementHooks(),
467
- secret: resolveSecret(),
468
- baseURL: process.env.BETTER_AUTH_URL,
520
+ databaseHooks: databaseHooks(),
521
+ secret,
522
+ baseURL,
469
523
  socialProviders: buildSocialProviders(),
470
524
  /**
471
525
  * The emitted reset link's host and the `redirectTo` allow-list are
@@ -483,9 +537,7 @@ export function createAuth(deps: CreateAuthDeps = {}) {
483
537
  * `BETTER_AUTH_URL` is also a conjunct of `resetSendConfigured()`, so a
484
538
  * deployment that has not pinned its origin never offers reset at all.
485
539
  */
486
- ...(process.env.BETTER_AUTH_URL
487
- ? { trustedOrigins: [process.env.BETTER_AUTH_URL] }
488
- : {}),
540
+ ...(baseURL ? { trustedOrigins: [baseURL] } : {}),
489
541
  emailAndPassword: {
490
542
  enabled: passwordMethodActive,
491
543
  // Turning the password method on also opens POST /sign-up/email —
@@ -23,11 +23,18 @@
23
23
  import { Nav, type NavProps } from "@working-theory/ui/chrome";
24
24
  import type { ReactElement } from "react";
25
25
 
26
+ import { withPublicDocsPaths } from "../lib/docs-path";
26
27
  import { navItems } from "../lib/nav-config";
28
+ import { withPublicServicesPaths } from "../lib/services-path";
27
29
  import { siteChrome } from "../lib/site-chrome";
28
30
 
29
31
  import { SignOutTrigger } from "./sign-out-trigger";
30
32
 
33
+ // #10041 D10 — nav paths at `/docs` render at the site's docs path.
34
+ // `docs-path.ts` imports nothing, so it is safe on this side of the boundary.
35
+ // #10043 D9 — likewise nav paths at `/services`, from `services-path.ts`.
36
+ const publicNavItems = withPublicServicesPaths(withPublicDocsPaths(navItems));
37
+
31
38
  /** Nav's props minus every function-typed one — this type IS the boundary. */
32
39
  export type SignedInNavProps = Omit<
33
40
  NavProps,
@@ -48,7 +55,7 @@ export function SignedInNav({
48
55
  return (
49
56
  <Nav
50
57
  {...props}
51
- navItems={siteChrome.nav === "mark-only" ? [] : navItems}
58
+ navItems={siteChrome.nav === "mark-only" ? [] : publicNavItems}
52
59
  // Both signed-in actions are flows with no lightbox: Sign out becomes a
53
60
  // SignOutTrigger, the account action a non-navigating name label.
54
61
  renderFlowTrigger={(action, trigger) =>
@@ -0,0 +1,254 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+ // The spoke edge's one bot check (#9255, spec
5
+ // docs/specs/9255-an-edge-bot-challenge-in-spec.md §5.1 AC-1, AC-4): the
6
+ // shared verdict rule, its timeout, both switches and BotID's client list.
7
+
8
+ import { BOT_CLIENT_PROTECT, botCheckOn, runBotCheck } from "../bot-check";
9
+
10
+ // `checkBotId` reads Vercel's request context, which jest has none of, so
11
+ // every case decides the verdict here.
12
+ const checkBotIdMock = jest.fn();
13
+ jest.mock("botid/server", () => ({
14
+ checkBotId: (...args: unknown[]) => checkBotIdMock(...args),
15
+ }));
16
+
17
+ const BOT = {
18
+ isHuman: false,
19
+ isBot: true,
20
+ isVerifiedBot: false,
21
+ bypassed: false,
22
+ };
23
+ const HUMAN = { ...BOT, isHuman: true, isBot: false };
24
+
25
+ const ENV_KEYS = [
26
+ "VERCEL",
27
+ "AILK_BOT_CHECK",
28
+ "AILK_BOOKING_BOT_CHECK",
29
+ ] as const;
30
+ const priorEnv = new Map<string, string | undefined>(
31
+ ENV_KEYS.map((name) => [name, process.env[name]]),
32
+ );
33
+
34
+ const CHECK = {
35
+ source: "bot-check",
36
+ target: "testAction",
37
+ scope: "write",
38
+ } as const;
39
+
40
+ let errorSpy: jest.SpyInstance;
41
+ let warnSpy: jest.SpyInstance;
42
+
43
+ beforeEach(() => {
44
+ checkBotIdMock.mockReset();
45
+ for (const name of ENV_KEYS) delete process.env[name];
46
+ process.env.VERCEL = "1";
47
+ errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
48
+ warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
49
+ });
50
+
51
+ afterEach(() => {
52
+ errorSpy.mockRestore();
53
+ warnSpy.mockRestore();
54
+ });
55
+
56
+ afterAll(() => {
57
+ for (const [name, value] of priorEnv) {
58
+ if (value === undefined) delete process.env[name];
59
+ else process.env[name] = value;
60
+ }
61
+ });
62
+
63
+ describe("AC-1 — runBotCheck's verdict (#9889 D3, one rule for every write)", () => {
64
+ it("a human passes", async () => {
65
+ checkBotIdMock.mockResolvedValue(HUMAN);
66
+ expect(await runBotCheck(CHECK)).toBe("pass");
67
+ expect(checkBotIdMock).toHaveBeenCalledTimes(1);
68
+ expect(errorSpy).not.toHaveBeenCalled();
69
+ expect(warnSpy).not.toHaveBeenCalled();
70
+ });
71
+
72
+ it.each([
73
+ ["a bot", BOT],
74
+ ["a verified bot", { ...BOT, isVerifiedBot: true }],
75
+ ])(
76
+ "%s is bot, logged with the target and no detail",
77
+ async (_label, verdict) => {
78
+ checkBotIdMock.mockResolvedValue(verdict);
79
+ expect(await runBotCheck(CHECK)).toBe("bot");
80
+ expect(warnSpy).toHaveBeenCalledTimes(1);
81
+ expect(warnSpy).toHaveBeenCalledWith("[bot-check] bot refused", {
82
+ path: "testAction",
83
+ outcome: "bot-detected",
84
+ });
85
+ },
86
+ );
87
+
88
+ function expectUnavailableLog(error: string): void {
89
+ expect(errorSpy).toHaveBeenCalledTimes(1);
90
+ expect(errorSpy).toHaveBeenCalledWith("[bot-check] bot check failed", {
91
+ path: "testAction",
92
+ outcome: "bot-check-failed",
93
+ error,
94
+ });
95
+ }
96
+
97
+ it("a throw is unavailable, logging the error's name, never its message", async () => {
98
+ checkBotIdMock.mockRejectedValue(new Error("token abc"));
99
+ expect(await runBotCheck(CHECK)).toBe("unavailable");
100
+ expectUnavailableLog("Error");
101
+ expect(JSON.stringify(errorSpy.mock.calls)).not.toContain("token abc");
102
+ });
103
+
104
+ it.each([
105
+ ["an empty verdict", {}],
106
+ ["a non-boolean isBot", { ...HUMAN, isBot: "false" }],
107
+ ])("%s is unavailable (InvalidVerdictError)", async (_label, verdict) => {
108
+ checkBotIdMock.mockResolvedValue(verdict);
109
+ expect(await runBotCheck(CHECK)).toBe("unavailable");
110
+ expectUnavailableLog("InvalidVerdictError");
111
+ });
112
+
113
+ describe("with fake timers", () => {
114
+ beforeEach(() => {
115
+ jest.useFakeTimers({
116
+ doNotFake: ["nextTick", "queueMicrotask", "setImmediate"],
117
+ });
118
+ });
119
+ afterEach(() => {
120
+ jest.useRealTimers();
121
+ });
122
+
123
+ it("no verdict within 5 000 ms is unavailable (TimeoutError)", async () => {
124
+ checkBotIdMock.mockReturnValue(new Promise(() => {}));
125
+ const pending = runBotCheck(CHECK);
126
+ await jest.advanceTimersByTimeAsync(5_000);
127
+ expect(await pending).toBe("unavailable");
128
+ expectUnavailableLog("TimeoutError");
129
+ });
130
+
131
+ it("a verdict in time leaves no timer pending", async () => {
132
+ checkBotIdMock.mockResolvedValue(BOT);
133
+ expect(await runBotCheck(CHECK)).toBe("bot");
134
+ expect(jest.getTimerCount()).toBe(0);
135
+ });
136
+ });
137
+
138
+ it("off Vercel it passes without asking BotID", async () => {
139
+ delete process.env.VERCEL;
140
+ checkBotIdMock.mockResolvedValue(BOT);
141
+ expect(await runBotCheck(CHECK)).toBe("pass");
142
+ expect(checkBotIdMock).not.toHaveBeenCalled();
143
+ });
144
+
145
+ it("AILK_BOT_CHECK=off passes the booking too, without asking BotID", async () => {
146
+ process.env.AILK_BOT_CHECK = "off";
147
+ checkBotIdMock.mockResolvedValue(BOT);
148
+ expect(await runBotCheck({ ...CHECK, scope: "booking" })).toBe("pass");
149
+ expect(checkBotIdMock).not.toHaveBeenCalled();
150
+ });
151
+
152
+ describe("in production, off Vercel", () => {
153
+ const env = process.env as Record<string, string | undefined>;
154
+ const priorNodeEnv = env.NODE_ENV;
155
+ beforeEach(() => {
156
+ env.NODE_ENV = "production";
157
+ delete process.env.VERCEL;
158
+ });
159
+ afterEach(() => {
160
+ env.NODE_ENV = priorNodeEnv;
161
+ });
162
+
163
+ async function twoChecks(scope: "write" | "booking"): Promise<void> {
164
+ await jest.isolateModulesAsync(async () => {
165
+ const fresh = await import("../bot-check");
166
+ for (let i = 0; i < 2; i++) {
167
+ expect(await fresh.runBotCheck({ ...CHECK, scope })).toBe("pass");
168
+ }
169
+ });
170
+ }
171
+
172
+ it("warns once per process that the check is not running", async () => {
173
+ await twoChecks("write");
174
+ expect(checkBotIdMock).not.toHaveBeenCalled();
175
+ expect(warnSpy).toHaveBeenCalledTimes(1);
176
+ expect(warnSpy).toHaveBeenCalledWith(
177
+ "[relay] booking bot check inactive",
178
+ { outcome: "not-on-vercel" },
179
+ );
180
+ });
181
+
182
+ it("with AILK_BOT_CHECK=off, warns nothing", async () => {
183
+ process.env.AILK_BOT_CHECK = "off";
184
+ await twoChecks("write");
185
+ expect(warnSpy).not.toHaveBeenCalled();
186
+ });
187
+
188
+ it("with AILK_BOOKING_BOT_CHECK=off, the booking warns nothing", async () => {
189
+ process.env.AILK_BOOKING_BOT_CHECK = "off";
190
+ await twoChecks("booking");
191
+ expect(warnSpy).not.toHaveBeenCalled();
192
+ });
193
+ });
194
+ });
195
+
196
+ describe("AC-4 — the switches and BotID's client list", () => {
197
+ it.each(["off", " OFF "])(
198
+ "AILK_BOT_CHECK=%j turns every check off",
199
+ (value) => {
200
+ process.env.AILK_BOT_CHECK = value;
201
+ expect(botCheckOn("write")).toBe(false);
202
+ expect(botCheckOn("booking")).toBe(false);
203
+ },
204
+ );
205
+
206
+ it("AILK_BOOKING_BOT_CHECK=off turns the booking's alone off", () => {
207
+ process.env.AILK_BOOKING_BOT_CHECK = "off";
208
+ expect(botCheckOn("write")).toBe(true);
209
+ expect(botCheckOn("booking")).toBe(false);
210
+ });
211
+
212
+ it.each([
213
+ ["AILK_BOT_CHECK", "on"],
214
+ ["AILK_BOT_CHECK", "0"],
215
+ ["AILK_BOOKING_BOT_CHECK", "on"],
216
+ ["AILK_BOOKING_BOT_CHECK", "0"],
217
+ ])("%s=%s leaves both on", (name, value) => {
218
+ process.env[name] = value;
219
+ expect(botCheckOn("write")).toBe(true);
220
+ expect(botCheckOn("booking")).toBe(true);
221
+ });
222
+
223
+ it("off Vercel both are off", () => {
224
+ delete process.env.VERCEL;
225
+ expect(botCheckOn("write")).toBe(false);
226
+ expect(botCheckOn("booking")).toBe(false);
227
+ });
228
+
229
+ it("BOT_CLIENT_PROTECT is every same-origin POST, frozen", () => {
230
+ expect(BOT_CLIENT_PROTECT).toEqual([{ path: "/*", method: "POST" }]);
231
+ expect(Object.isFrozen(BOT_CLIENT_PROTECT)).toBe(true);
232
+ });
233
+
234
+ // `botid/client/core`'s own rule: escape the regex characters, `*` → `.*`,
235
+ // anchored.
236
+ function compileBotIdPath(path: string): RegExp {
237
+ const body = path
238
+ .replace(/[.?+^$[\]\\(){}|-]/g, "\\$&")
239
+ .split("*")
240
+ .join(".*");
241
+ return new RegExp(`^${body}$`);
242
+ }
243
+
244
+ it.each([
245
+ "/",
246
+ "/contact",
247
+ "/fr/flows/quiz/results/abc",
248
+ "/v1/leads/submit",
249
+ "/v1/schedule/book",
250
+ ])("its path matches %s", (pathname) => {
251
+ const [entry] = BOT_CLIENT_PROTECT;
252
+ expect(compileBotIdPath(entry?.path ?? "").test(pathname)).toBe(true);
253
+ });
254
+ });