create-ailk 0.27.0 → 0.28.1

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 (219) 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/chrome-readers.d.ts +21 -5
  16. package/dist/lib/chrome-readers.js +128 -20
  17. package/dist/lib/declared-env.d.ts +6 -0
  18. package/dist/lib/declared-env.js +2 -0
  19. package/dist/lib/env-catalog.js +20 -0
  20. package/dist/lib/import-graph.d.ts +16 -0
  21. package/dist/lib/import-graph.js +53 -0
  22. package/dist/lib/site-modules.d.ts +8 -0
  23. package/dist/lib/site-modules.js +34 -0
  24. package/dist/module-architecture.js +23 -14
  25. package/dist/paid-sections.d.ts +17 -0
  26. package/dist/paid-sections.js +39 -0
  27. package/dist/programmatic.d.ts +16 -0
  28. package/dist/programmatic.js +73 -4
  29. package/dist/remove-module.d.ts +98 -0
  30. package/dist/remove-module.js +620 -0
  31. package/dist/seed-flows.d.ts +2 -2
  32. package/dist/seed-flows.js +16 -9
  33. package/dist/site-roster.js +4 -1
  34. package/dist/starter-grid.d.ts +26 -0
  35. package/dist/starter-grid.js +34 -0
  36. package/dist/surfaces.d.ts +26 -0
  37. package/dist/surfaces.js +105 -9
  38. package/dist/sync-routes.d.ts +7 -0
  39. package/dist/sync-routes.js +53 -6
  40. package/dist/template-pages.d.ts +36 -0
  41. package/dist/template-pages.js +129 -0
  42. package/dist/template-record.d.ts +13 -4
  43. package/dist/template-record.js +8 -4
  44. package/dist/topology.d.ts +2 -1
  45. package/dist/topology.js +4 -2
  46. package/dist/update.d.ts +34 -2
  47. package/dist/update.js +395 -48
  48. package/package.json +3 -3
  49. package/release-scope.json +2 -1
  50. package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
  51. package/templates/.env.example +5 -2
  52. package/templates/apps/api/.env.example +9 -5
  53. package/templates/apps/api/CLAUDE.md +14 -9
  54. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
  55. package/templates/apps/api/src/vercel-handler.ts +8 -1
  56. package/templates/apps/mcp/CLAUDE.md +18 -10
  57. package/templates/apps/web/.env.example +25 -11
  58. package/templates/apps/web/CLAUDE.md +45 -4
  59. package/templates/apps/web/__tests__/setup.ts +7 -0
  60. package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
  61. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
  62. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
  63. package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
  64. package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
  65. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  66. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
  67. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
  68. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
  69. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
  70. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
  71. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
  72. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
  73. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
  74. package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
  75. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
  76. package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
  77. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
  78. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  79. package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
  80. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
  81. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
  82. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
  83. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
  84. package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
  85. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
  86. package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
  87. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
  88. package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
  89. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
  90. package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
  91. package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
  92. package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
  93. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
  94. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
  95. package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
  96. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
  97. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
  98. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
  99. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
  100. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
  101. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
  102. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
  103. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
  104. package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
  105. package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
  106. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
  107. package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
  108. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
  109. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  110. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
  111. package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
  112. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
  113. package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
  114. package/templates/apps/web/app/[locale]/layout.tsx +157 -139
  115. package/templates/apps/web/app/[locale]/page.tsx +51 -3
  116. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
  117. package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
  118. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
  119. package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
  120. package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
  121. package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
  122. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
  123. package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
  124. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  125. package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
  126. package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
  127. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
  128. package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
  129. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
  130. package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
  131. package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
  132. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
  133. package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
  134. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
  135. package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
  136. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
  137. package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
  138. package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
  139. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
  140. package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
  141. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
  142. package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +224 -0
  143. package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
  144. package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
  145. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  146. package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
  147. package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
  148. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
  149. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  150. package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
  151. package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
  152. package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +237 -0
  153. package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
  154. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  155. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
  156. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  157. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +204 -2
  158. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +151 -33
  159. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
  160. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
  161. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
  162. package/templates/apps/web/app/llms.txt/route.ts +41 -3
  163. package/templates/apps/web/app/robots.txt/route.ts +5 -1
  164. package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
  165. package/templates/apps/web/auth.ts +102 -50
  166. package/templates/apps/web/components/signed-in-nav.tsx +8 -1
  167. package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
  168. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
  169. package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
  170. package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
  171. package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
  172. package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +279 -0
  173. package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
  174. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
  175. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
  176. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
  177. package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
  178. package/templates/apps/web/lib/__tests__/relay.test.ts +202 -10
  179. package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
  180. package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
  181. package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
  182. package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
  183. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
  184. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
  185. package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
  186. package/templates/apps/web/lib/auth-secret.ts +57 -0
  187. package/templates/apps/web/lib/bot-check.ts +138 -0
  188. package/templates/apps/web/lib/consent-visitor.ts +81 -7
  189. package/templates/apps/web/lib/deck-page.ts +6 -3
  190. package/templates/apps/web/lib/docs-index.ts +100 -0
  191. package/templates/apps/web/lib/docs-path.ts +94 -0
  192. package/templates/apps/web/lib/hub-children.tsx +318 -0
  193. package/templates/apps/web/lib/modules.generated.ts +9 -0
  194. package/templates/apps/web/lib/nav-label-check.ts +61 -0
  195. package/templates/apps/web/lib/nav-label-keys.ts +78 -0
  196. package/templates/apps/web/lib/newsletter.actions.ts +31 -6
  197. package/templates/apps/web/lib/offerings-pricing.ts +135 -6
  198. package/templates/apps/web/lib/page-chrome.ts +367 -0
  199. package/templates/apps/web/lib/posting-title.ts +26 -4
  200. package/templates/apps/web/lib/pricing-page.ts +430 -0
  201. package/templates/apps/web/lib/relay.ts +31 -106
  202. package/templates/apps/web/lib/route-family.ts +69 -0
  203. package/templates/apps/web/lib/service-listing.ts +89 -0
  204. package/templates/apps/web/lib/services-path.ts +97 -0
  205. package/templates/apps/web/lib/site-header.ts +94 -0
  206. package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
  207. package/templates/apps/web/lib/template-listing.ts +169 -0
  208. package/templates/apps/web/messages/en.json +86 -30
  209. package/templates/apps/web/middleware.ts +171 -11
  210. package/templates/apps/web/next.config.mjs +211 -2
  211. package/templates/apps/web/package.json +1 -1
  212. package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
  213. package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
  214. package/templates/apps/web/sentry.server.config.ts +30 -2
  215. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
  216. package/templates/apps/web/src/lib/rate-limit.ts +68 -27
  217. package/templates/apps/web/src/lib/routes.ts +15 -1
  218. package/templates/tools/declared-env.ts +7 -0
  219. package/versions.json +48 -48
@@ -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
  }
@@ -0,0 +1,100 @@
1
+ // docs-index.ts — the docs list `/sitemap.xml` and `/llms.txt` read (#10041 D8).
2
+ //
3
+ // A base file: it reads the docs directory itself rather than `docsSource`,
4
+ // which only a docs-blog site carries, so the sitemap and llms routes stay in
5
+ // the Free template and list the docs on any site that has them. It imports
6
+ // no docs-blog owned file (a relative import of one would drop it, and every
7
+ // file importing it, from the Free template).
8
+ //
9
+ // Default locale only: the docs exist in one locale (`source.config.ts` reads
10
+ // `content/en/docs`).
11
+ import { readdirSync, readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ import { parse as parseYaml } from "yaml";
15
+
16
+ import { DOCS_PATH } from "./docs-path";
17
+
18
+ export interface DocsIndexPage {
19
+ url: string;
20
+ title: string;
21
+ description?: string;
22
+ }
23
+
24
+ /** The two content-adapter types the docs route reads under the database adapter. */
25
+ const DOCS_TYPES = new Set(["DocsList", "DocsDetail"]);
26
+
27
+ export function isDocsType(type: string): boolean {
28
+ return DOCS_TYPES.has(type);
29
+ }
30
+
31
+ /** An adapter docs row's URL: the index at `DOCS_PATH`, a doc under it. */
32
+ export function adapterDocsUrl(type: string, slug: string): string {
33
+ return type === "DocsList" || slug === "index"
34
+ ? DOCS_PATH
35
+ : `${DOCS_PATH}/${slug}`;
36
+ }
37
+
38
+ function frontmatterOf(raw: string): Record<string, unknown> | null {
39
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(raw);
40
+ if (!match) return null;
41
+ const parsed: unknown = parseYaml(match[1]!);
42
+ return parsed && typeof parsed === "object"
43
+ ? (parsed as Record<string, unknown>)
44
+ : null;
45
+ }
46
+
47
+ /**
48
+ * Fumadocs' slug rule: the path segments without the extension, a trailing
49
+ * `index` dropped, a `(group)` segment dropped.
50
+ */
51
+ function urlFor(segments: string[]): string {
52
+ const slugs = segments.filter((s) => !/^\(.+\)$/.test(s));
53
+ if (slugs.at(-1) === "index") slugs.pop();
54
+ return slugs.length === 0 ? DOCS_PATH : `${DOCS_PATH}/${slugs.join("/")}`;
55
+ }
56
+
57
+ function walk(dir: string, segments: string[], out: DocsIndexPage[]): void {
58
+ const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
59
+ a.name.localeCompare(b.name),
60
+ );
61
+ for (const entry of entries) {
62
+ if (entry.isDirectory()) {
63
+ walk(join(dir, entry.name), [...segments, entry.name], out);
64
+ continue;
65
+ }
66
+ if (!entry.isFile() || !entry.name.endsWith(".mdx")) continue;
67
+ const data = frontmatterOf(readFileSync(join(dir, entry.name), "utf8"));
68
+ if (!data || typeof data.title !== "string" || data.title === "") continue;
69
+ out.push({
70
+ url: urlFor([...segments, entry.name.slice(0, -".mdx".length)]),
71
+ title: data.title,
72
+ ...(typeof data.description === "string" && data.description !== ""
73
+ ? { description: data.description }
74
+ : {}),
75
+ });
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Every titled `*.mdx` page under `dir` (default: this app's
81
+ * `content/<default locale>/docs`), at the docs' public path. An absent
82
+ * directory — every Free site — is `[]`; a page with no `title` is skipped.
83
+ */
84
+ export function listDocsPages(
85
+ dir: string = join(
86
+ process.cwd(),
87
+ "content",
88
+ process.env.NEXT_PUBLIC_DEFAULT_LOCALE ?? "en",
89
+ "docs",
90
+ ),
91
+ ): DocsIndexPage[] {
92
+ const pages: DocsIndexPage[] = [];
93
+ try {
94
+ walk(dir, [], pages);
95
+ } catch (error) {
96
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
97
+ throw error;
98
+ }
99
+ return pages;
100
+ }
@@ -0,0 +1,94 @@
1
+ // docs-path.ts — where the docs live (#10041 D2).
2
+ //
3
+ // A site sets `docsPath` in `content/_site.mdx` (e.g. `/working-theory/docs`);
4
+ // `next.config.mjs` parses it at build and bakes it into
5
+ // `NEXT_PUBLIC_AILK_DOCS_PATH`. The docs route itself never moves — it stays
6
+ // at `app/[locale]/docs/` (`DOCS_ROUTE`) — and the middleware maps the public
7
+ // path onto it. Every surface that names a docs URL reads `DOCS_PATH` here.
8
+ //
9
+ // Edge-safe and client-safe: this file imports nothing at runtime (the
10
+ // `NavItem` import is type-only), so the middleware and the client signed-in
11
+ // nav can both read it. It is a base file: it must never import a docs-blog
12
+ // owned file, or it would leave the Free template.
13
+ import type { NavItem } from "@working-theory/ui";
14
+
15
+ /** The internal docs route — the directory under `app/[locale]/`. Always `/docs`. */
16
+ export const DOCS_ROUTE = "/docs";
17
+
18
+ /** The docs' public base: the site's `docsPath`, or `/docs` when it sets none. */
19
+ export const DOCS_PATH: string =
20
+ process.env.NEXT_PUBLIC_AILK_DOCS_PATH || DOCS_ROUTE;
21
+
22
+ /**
23
+ * `href` at the internal docs route — `/docs`, or starting `/docs/`, `/docs?`
24
+ * or `/docs#` — with that prefix replaced by `DOCS_PATH`. Anything else
25
+ * (absolute URLs, `/docsets`, a locale-prefixed path) is returned unchanged.
26
+ * The identity on a site with no `docsPath`.
27
+ */
28
+ export function toPublicDocsHref(href: string): string {
29
+ if (DOCS_PATH === DOCS_ROUTE) return href;
30
+ const rest = href.slice(DOCS_ROUTE.length);
31
+ if (href.startsWith(DOCS_ROUTE) && (rest === "" || /^[/?#]/.test(rest))) {
32
+ return DOCS_PATH + rest;
33
+ }
34
+ return href;
35
+ }
36
+
37
+ /**
38
+ * A locale-less pathname at or under `DOCS_PATH`, mapped onto the internal
39
+ * route (`/docs` + the rest); `null` for anything else, and always `null` on a
40
+ * site with no `docsPath`.
41
+ */
42
+ export function toInternalDocsPath(bare: string): string | null {
43
+ if (DOCS_PATH === DOCS_ROUTE) return null;
44
+ if (bare === DOCS_PATH) return DOCS_ROUTE;
45
+ if (bare.startsWith(`${DOCS_PATH}/`)) {
46
+ return DOCS_ROUTE + bare.slice(DOCS_PATH.length);
47
+ }
48
+ return null;
49
+ }
50
+
51
+ type NavFlyout = NonNullable<NavItem["flyout"]>;
52
+
53
+ function mapPath<T extends { path: string }>(entry: T): T {
54
+ return { ...entry, path: toPublicDocsHref(entry.path) };
55
+ }
56
+
57
+ function mapFlyout(flyout: NavFlyout): NavFlyout {
58
+ return {
59
+ ...flyout,
60
+ features: flyout.features.map(mapPath),
61
+ ...(flyout.groups
62
+ ? {
63
+ groups: flyout.groups.map((group) => ({
64
+ ...group,
65
+ items: group.items.map(mapPath),
66
+ })),
67
+ }
68
+ : {}),
69
+ actions: flyout.actions.map(mapPath),
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Copies of the nav items with every docs path mapped onto `DOCS_PATH` — the
75
+ * item's own `path` and each flyout feature, group item and action. Every
76
+ * other field (icons are functions) is carried by reference.
77
+ */
78
+ export function withPublicDocsPaths(items: NavItem[]): NavItem[] {
79
+ return items.map((item) => ({
80
+ ...item,
81
+ ...(item.path !== undefined ? { path: toPublicDocsHref(item.path) } : {}),
82
+ ...(item.flyout ? { flyout: mapFlyout(item.flyout) } : {}),
83
+ }));
84
+ }
85
+
86
+ /** Copies of the footer columns with every item's docs path mapped onto `DOCS_PATH`. */
87
+ export function withPublicDocsColumns<C extends { items: { path: string }[] }>(
88
+ columns: C[],
89
+ ): C[] {
90
+ return columns.map((column) => ({
91
+ ...column,
92
+ items: column.items.map(mapPath),
93
+ }));
94
+ }