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
@@ -8,8 +8,8 @@
8
8
  * See: docs/runbooks/page-renderer-deploy-verification.md §1 Cause 2
9
9
  */
10
10
 
11
- import { existsSync } from "node:fs";
12
- import { join, resolve } from "node:path";
11
+ import { existsSync, readdirSync } from "node:fs";
12
+ import { join, relative, resolve } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
 
15
15
  // apps/web/ is one level up from scripts/
@@ -18,10 +18,32 @@ const scriptDir = resolve(fileURLToPath(import.meta.url), "..");
18
18
  const appWebDir = resolve(scriptDir, "..");
19
19
  const standaloneDir = join(appWebDir, ".next", "standalone");
20
20
 
21
- // Sentinel file: content/en/HomePage/home.mdx
22
- // Traced from ../../content/en/HomePage/home.mdx (relative to apps/web),
23
- // placed at standaloneDir/content/en/HomePage/home.mdx by Next.js file tracing.
24
- const sentinelRelative = join("content", "en", "HomePage", "home.mdx");
21
+ // Sentinel: the first MDX file under <repo-root>/content on disk — one traced
22
+ // file proves the tracing; it does not count the others. A site that
23
+ // dropped the marketing module (#10040) has no HomePage — only its legal
24
+ // pages — so the sentinel is whichever page this site carries, traced from
25
+ // ../../content/<locale>/<Type>/<page>.mdx (relative to apps/web) and placed
26
+ // at standaloneDir/content/... by Next.js file tracing.
27
+ const contentDir = resolve(appWebDir, "..", "..", "content");
28
+ function firstMdx(dir: string): string | undefined {
29
+ if (!existsSync(dir)) return undefined;
30
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
31
+ const abs = join(dir, entry.name);
32
+ if (entry.isDirectory()) {
33
+ const found = firstMdx(abs);
34
+ if (found !== undefined) return found;
35
+ } else if (entry.name.endsWith(".mdx")) {
36
+ return abs;
37
+ }
38
+ }
39
+ return undefined;
40
+ }
41
+ const sentinelAbs = firstMdx(contentDir);
42
+ if (sentinelAbs === undefined) {
43
+ console.error("[assert-content-bundled] ERROR: no .mdx file under content/ to trace.");
44
+ process.exit(1);
45
+ }
46
+ const sentinelRelative = join("content", relative(contentDir, sentinelAbs));
25
47
  const sentinelPath = join(standaloneDir, sentinelRelative);
26
48
 
27
49
  if (!existsSync(standaloneDir)) {
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Fail the build when a nav key has no text (#10045 D2).
3
+ *
4
+ * Every `labelKey` / `headingKey` / `descKey` in `lib/nav-config.ts`, and the
5
+ * keys the layout resolves by name, must exist in the `nav` namespace of every
6
+ * `messages/<locale>.json` (or its site overlay). A missing key would render
7
+ * as raw text in the header or footer; this check names the key and the file
8
+ * instead. Runs as the first step of `pnpm build`.
9
+ */
10
+
11
+ import { resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ import {
15
+ findMissingNavLabels,
16
+ formatMissingNavLabels,
17
+ } from "../lib/nav-label-check";
18
+ import { usedNavLabelKeys } from "../lib/nav-label-keys";
19
+
20
+ const appWebDir = resolve(fileURLToPath(import.meta.url), "..", "..");
21
+ const messagesDir = resolve(appWebDir, "messages");
22
+
23
+ const keys = usedNavLabelKeys();
24
+ const missing = findMissingNavLabels(messagesDir, keys);
25
+
26
+ if (missing.length > 0) {
27
+ console.error(
28
+ `[check-nav-labels] ERROR:\n${formatMissingNavLabels(missing)}`,
29
+ );
30
+ console.error(
31
+ "[check-nav-labels] Add each key's text to the `nav` namespace of that file " +
32
+ "(or messages/site/<locale>.json).",
33
+ );
34
+ process.exit(1);
35
+ }
36
+
37
+ console.log(
38
+ `[check-nav-labels] ✓ ${keys.size} nav keys resolve in every locale`,
39
+ );
@@ -12,7 +12,11 @@
12
12
 
13
13
  import * as Sentry from "@sentry/nextjs";
14
14
 
15
- import { scrubTelemetryUrl } from "./lib/telemetry-scrub";
15
+ import {
16
+ scrubSpanDescription,
17
+ scrubTelemetryQuery,
18
+ scrubTelemetryUrl,
19
+ } from "./lib/telemetry-scrub";
16
20
 
17
21
  // #5613 / security review PR #5639: the report-grant token (?g=<id> on
18
22
  // /reports/<slug>, #5562) is a bearer credential for a private customer
@@ -21,13 +25,29 @@ import { scrubTelemetryUrl } from "./lib/telemetry-scrub";
21
25
  // header, the transaction name, navigation breadcrumbs, and any URL
22
26
  // captured as span/trace data (e.g. `url.full`,
23
27
  // `http.request.header.referer`) here too.
28
+ //
29
+ // #9969 D9 (#8329): the Stripe SDK's outgoing requests carry a customer id in
30
+ // their URL and query, so a span's `description` (`<METHOD> <url>`), its raw
31
+ // `url`/`url.path`/`http.target`, and the separate query keys (`http.query`,
32
+ // `url.query`) on spans, the trace and breadcrumbs are scrubbed as well.
33
+ const QUERY_KEYS = ["http.query", "url.query"];
34
+
35
+ function scrubQueryData(data: Record<string, unknown>): void {
36
+ for (const key of QUERY_KEYS) {
37
+ const value = data[key];
38
+ if (typeof value === "string") {
39
+ data[key] = scrubTelemetryQuery(value);
40
+ }
41
+ }
42
+ }
43
+
24
44
  function scrubSentryEvent<
25
45
  T extends {
26
46
  request?: { url?: string; headers?: Record<string, string> };
27
47
  transaction?: string;
28
48
  breadcrumbs?: { category?: string; data?: Record<string, unknown> }[];
29
49
  contexts?: { trace?: { data?: Record<string, unknown> } };
30
- spans?: { data?: Record<string, unknown> }[];
50
+ spans?: { description?: string; data?: Record<string, unknown> }[];
31
51
  },
32
52
  >(event: T): T {
33
53
  if (event.request?.url) {
@@ -60,6 +80,7 @@ function scrubSentryEvent<
60
80
  if (typeof url === "string") {
61
81
  crumb.data["url"] = scrubTelemetryUrl(url);
62
82
  }
83
+ scrubQueryData(crumb.data);
63
84
  }
64
85
  const scrubSpanData = (data?: Record<string, unknown>): void => {
65
86
  if (!data) return;
@@ -67,15 +88,22 @@ function scrubSentryEvent<
67
88
  "url.full",
68
89
  "http.url",
69
90
  "http.request.header.referer",
91
+ "url",
92
+ "url.path",
93
+ "http.target",
70
94
  ]) {
71
95
  const value = data[key];
72
96
  if (typeof value === "string") {
73
97
  data[key] = scrubTelemetryUrl(value);
74
98
  }
75
99
  }
100
+ scrubQueryData(data);
76
101
  };
77
102
  scrubSpanData(event.contexts?.trace?.data);
78
103
  for (const span of event.spans ?? []) {
104
+ if (span.description) {
105
+ span.description = scrubSpanDescription(span.description);
106
+ }
79
107
  scrubSpanData(span.data);
80
108
  }
81
109
  return event;
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * The per-visitor limiter the /contact action runs before it writes a lead
3
3
  * (#8747): a fixed window per key, refusing the call past the limit, and a
4
- * client address read from the platform's own headers.
4
+ * client address read from the platform's own headers — on Vercel only, and
5
+ * a shared, wider bucket for a visitor it cannot read (#9967 D1).
5
6
  */
6
7
  import {
7
8
  __resetRateLimit,
8
9
  checkRateLimit,
9
10
  clientIpFromHeaders,
11
+ takeVisitorLimit,
10
12
  } from "../rate-limit";
11
13
 
12
14
  const WINDOW_MS = 10 * 60 * 1000;
@@ -15,9 +17,13 @@ function headersOf(values: Record<string, string>): { get(name: string): string
15
17
  return { get: (name) => values[name] ?? null };
16
18
  }
17
19
 
20
+ const originalVercel = process.env.VERCEL;
21
+
18
22
  afterEach(() => {
19
23
  __resetRateLimit();
20
24
  jest.useRealTimers();
25
+ if (originalVercel === undefined) delete process.env.VERCEL;
26
+ else process.env.VERCEL = originalVercel;
21
27
  });
22
28
 
23
29
  describe("checkRateLimit", () => {
@@ -46,22 +52,74 @@ describe("checkRateLimit", () => {
46
52
  });
47
53
 
48
54
  describe("clientIpFromHeaders", () => {
49
- it("prefers x-vercel-forwarded-for, first value", () => {
55
+ it("on Vercel, reads x-vercel-forwarded-for, first value", () => {
56
+ process.env.VERCEL = "1";
50
57
  expect(
51
58
  clientIpFromHeaders(
52
59
  headersOf({
53
- "x-vercel-forwarded-for": "198.51.100.7, 10.0.0.1",
60
+ "x-vercel-forwarded-for": "198.51.100.1, 10.0.0.1",
54
61
  "x-forwarded-for": "203.0.113.5",
55
62
  }),
56
63
  ),
57
- ).toBe("198.51.100.7");
64
+ ).toBe("198.51.100.1");
58
65
  });
59
66
 
60
- it("falls back to x-forwarded-for, then x-real-ip, then unknown", () => {
61
- expect(clientIpFromHeaders(headersOf({ "x-forwarded-for": "203.0.113.5, 10.0.0.1" }))).toBe(
62
- "203.0.113.5",
63
- );
64
- expect(clientIpFromHeaders(headersOf({ "x-real-ip": "192.0.2.9" }))).toBe("192.0.2.9");
67
+ it("never keys a client-sent x-forwarded-for or x-real-ip (#9945)", () => {
68
+ process.env.VERCEL = "1";
69
+ expect(clientIpFromHeaders(headersOf({ "x-forwarded-for": "203.0.113.5" }))).toBe("unknown");
70
+ expect(clientIpFromHeaders(headersOf({ "x-real-ip": "192.0.2.9" }))).toBe("unknown");
65
71
  expect(clientIpFromHeaders(headersOf({}))).toBe("unknown");
66
72
  });
73
+
74
+ it("off Vercel, trusts no header at all", () => {
75
+ delete process.env.VERCEL;
76
+ expect(
77
+ clientIpFromHeaders(headersOf({ "x-vercel-forwarded-for": "198.51.100.1, 10.0.0.1" })),
78
+ ).toBe("unknown");
79
+ });
80
+ });
81
+
82
+ describe("takeVisitorLimit (#9967 D1)", () => {
83
+ beforeEach(() => {
84
+ process.env.VERCEL = "1";
85
+ });
86
+
87
+ it("refuses past the limit with the window's remaining wait, not the whole window", () => {
88
+ jest.useFakeTimers();
89
+ jest.setSystemTime(new Date("2026-10-26T10:00:00Z"));
90
+ const h = headersOf({ "x-vercel-forwarded-for": "198.51.100.1" });
91
+ expect(takeVisitorLimit("t", h, 2, 60_000)).toEqual({ allowed: true });
92
+ expect(takeVisitorLimit("t", h, 2, 60_000)).toEqual({ allowed: true });
93
+
94
+ jest.setSystemTime(new Date("2026-10-26T10:00:20Z"));
95
+ expect(takeVisitorLimit("t", h, 2, 60_000)).toEqual({ allowed: false, retryAfterSeconds: 40 });
96
+ // Another address keeps its own bucket.
97
+ expect(takeVisitorLimit("t", headersOf({ "x-vercel-forwarded-for": "198.51.100.2" }), 2, 60_000)).toEqual({
98
+ allowed: true,
99
+ });
100
+ });
101
+
102
+ it("charges an unreadable visitor one shared bucket at ten times the limit, never skips it", () => {
103
+ const none = headersOf({ "x-forwarded-for": "203.0.113.5" });
104
+ for (let i = 0; i < 20; i += 1) {
105
+ expect(takeVisitorLimit("t", none, 2, 60_000).allowed).toBe(true);
106
+ }
107
+ expect(takeVisitorLimit("t", headersOf({ "x-forwarded-for": "203.0.113.6" }), 2, 60_000).allowed).toBe(false);
108
+ });
109
+
110
+ it("keeps the shared bucket out of reach of a flood of address keys", () => {
111
+ const none = headersOf({});
112
+ for (let i = 0; i < 20; i += 1) takeVisitorLimit("t", none, 2, 60_000);
113
+ for (let i = 0; i < 5000; i += 1) {
114
+ takeVisitorLimit("t", headersOf({ "x-vercel-forwarded-for": `10.0.${i >> 8}.${i & 255}` }), 2, 60_000);
115
+ }
116
+ expect(takeVisitorLimit("t", none, 2, 60_000).allowed).toBe(false);
117
+ });
118
+
119
+ it("counts each scope's shared bucket on its own", () => {
120
+ const none = headersOf({});
121
+ for (let i = 0; i < 20; i += 1) takeVisitorLimit("a", none, 2, 60_000);
122
+ expect(takeVisitorLimit("a", none, 2, 60_000).allowed).toBe(false);
123
+ expect(takeVisitorLimit("b", none, 2, 60_000).allowed).toBe(true);
124
+ });
67
125
  });
@@ -17,44 +17,64 @@ type Bucket = { count: number; resetAt: number };
17
17
 
18
18
  const buckets = new Map<string, Bucket>();
19
19
 
20
+ // The `<scope>:unknown` buckets (#9967 D1): one per scope, kept apart from
21
+ // `buckets` so no flood of address keys can evict them through its
22
+ // oldest-first bound.
23
+ const unknownBuckets = new Map<string, Bucket>();
24
+
20
25
  // Bound the map so a caller rotating keys (spoofed addresses) cannot grow it
21
26
  // without limit between resets.
22
27
  const MAX_TRACKED_KEYS = 5000;
23
28
 
29
+ /** A visitor with no readable address shares one bucket this many times as wide. */
30
+ export const UNKNOWN_VISITOR_FACTOR = 10;
31
+
24
32
  /** Test-only reset. Not reachable from a request. */
25
33
  export function __resetRateLimit(): void {
26
34
  buckets.clear();
35
+ unknownBuckets.clear();
27
36
  }
28
37
 
29
- /**
30
- * Fixed-window check. `true` when the caller is under `limit` in the current
31
- * window (and counts the call); `false` once it has used `limit` calls.
32
- */
33
- export function checkRateLimit(key: string, limit: number, windowMs: number): boolean {
34
- const now = Date.now();
35
- const existing = buckets.get(key);
38
+ /** Count one call in `map`'s fixed window for `key`; the window's end either way. */
39
+ function hitWindow(
40
+ map: Map<string, Bucket>,
41
+ key: string,
42
+ limit: number,
43
+ windowMs: number,
44
+ now: number,
45
+ ): { allowed: boolean; resetAt: number } {
46
+ const existing = map.get(key);
36
47
 
37
48
  if (!existing || existing.resetAt <= now) {
38
- if (buckets.size >= MAX_TRACKED_KEYS) pruneExpired(now);
39
- buckets.set(key, { count: 1, resetAt: now + windowMs });
40
- return true;
49
+ if (map.size >= MAX_TRACKED_KEYS) pruneExpired(map, now);
50
+ const resetAt = now + windowMs;
51
+ map.set(key, { count: 1, resetAt });
52
+ return { allowed: true, resetAt };
41
53
  }
42
54
 
43
- if (existing.count >= limit) return false;
55
+ if (existing.count >= limit) return { allowed: false, resetAt: existing.resetAt };
44
56
 
45
57
  existing.count += 1;
46
- return true;
58
+ return { allowed: true, resetAt: existing.resetAt };
59
+ }
60
+
61
+ /**
62
+ * Fixed-window check. `true` when the caller is under `limit` in the current
63
+ * window (and counts the call); `false` once it has used `limit` calls.
64
+ */
65
+ export function checkRateLimit(key: string, limit: number, windowMs: number): boolean {
66
+ return hitWindow(buckets, key, limit, windowMs, Date.now()).allowed;
47
67
  }
48
68
 
49
- function pruneExpired(now: number): void {
50
- for (const [key, bucket] of buckets) {
51
- if (bucket.resetAt <= now) buckets.delete(key);
69
+ function pruneExpired(map: Map<string, Bucket>, now: number): void {
70
+ for (const [key, bucket] of map) {
71
+ if (bucket.resetAt <= now) map.delete(key);
52
72
  }
53
73
  // Nothing expired (keys rotated inside one window): evict the oldest entry
54
74
  // — `Map` iterates in insertion order — so the bound always holds.
55
- if (buckets.size >= MAX_TRACKED_KEYS) {
56
- const oldestKey = buckets.keys().next().value;
57
- if (oldestKey !== undefined) buckets.delete(oldestKey);
75
+ if (map.size >= MAX_TRACKED_KEYS) {
76
+ const oldestKey = map.keys().next().value;
77
+ if (oldestKey !== undefined) map.delete(oldestKey);
58
78
  }
59
79
  }
60
80
 
@@ -64,16 +84,37 @@ export interface HeaderReader {
64
84
  }
65
85
 
66
86
  /**
67
- * The visitor's address, coarse: `x-vercel-forwarded-for`, else
68
- * `x-forwarded-for`, else `x-real-ip` (first value of each), else
69
- * `"unknown"`. Vercel's edge overwrites these with the connecting address;
70
- * behind an edge that appends instead, a caller chooses its own key. An
87
+ * The visitor's address, coarse: on Vercel (`VERCEL=1`), the first value of
88
+ * `x-vercel-forwarded-for`, which Vercel's edge overwrites on every request;
89
+ * else `"unknown"` (#9967 D1). `x-forwarded-for` and `x-real-ip` are never
90
+ * read, and off Vercel no header is, since a client can set any of them. An
71
91
  * abuse-throttling key only — never an authorization decision.
72
92
  */
73
93
  export function clientIpFromHeaders(headers: HeaderReader): string {
74
- for (const name of ["x-vercel-forwarded-for", "x-forwarded-for"]) {
75
- const value = headers.get(name);
76
- if (value) return value.split(",")[0]?.trim() || "unknown";
77
- }
78
- return headers.get("x-real-ip") || "unknown";
94
+ if (process.env.VERCEL !== "1") return "unknown";
95
+ return headers.get("x-vercel-forwarded-for")?.split(",")[0]?.trim() || "unknown";
96
+ }
97
+
98
+ export type VisitorLimit = { allowed: true } | { allowed: false; retryAfterSeconds: number };
99
+
100
+ /**
101
+ * The per-visitor limit (#9967 D1): `limit` calls per window on
102
+ * `<scope>:<address>`, or — for a visitor whose address cannot be read —
103
+ * `UNKNOWN_VISITOR_FACTOR * limit` on one shared `<scope>:unknown` bucket,
104
+ * never a skip. A refusal names the seconds left in the window, at least 1.
105
+ */
106
+ export function takeVisitorLimit(
107
+ scope: string,
108
+ headers: HeaderReader,
109
+ limit: number,
110
+ windowMs: number,
111
+ ): VisitorLimit {
112
+ const now = Date.now();
113
+ const address = clientIpFromHeaders(headers);
114
+ const { allowed, resetAt } =
115
+ address === "unknown"
116
+ ? hitWindow(unknownBuckets, `${scope}:unknown`, UNKNOWN_VISITOR_FACTOR * limit, windowMs, now)
117
+ : hitWindow(buckets, `${scope}:${address}`, limit, windowMs, now);
118
+ if (allowed) return { allowed: true };
119
+ return { allowed: false, retryAfterSeconds: Math.max(1, Math.ceil((resetAt - now) / 1000)) };
79
120
  }
@@ -5,8 +5,9 @@
5
5
  * handlers to avoid copy-paste duplication.
6
6
  */
7
7
 
8
- import { routeFor } from "@working-theory/templates/routes";
8
+ import { pagePath, routeFor } from "@working-theory/templates/routes";
9
9
 
10
+ import { SERVICES_PATH } from "../../lib/services-path";
10
11
  import { SITE_BASE_URL } from "../../lib/site-brand";
11
12
 
12
13
  /**
@@ -43,3 +44,16 @@ export function resolveBaseUrl(): string {
43
44
  * depend on, even though `routeFor`'s own `slug` parameter is optional.
44
45
  */
45
46
  export const slugToPath: (type: string, slug: string) => string = routeFor;
47
+
48
+ /**
49
+ * One listed page's public path (#10043 D9): `slugToPath`, except that the
50
+ * services live at the site's base and a `Service` with a `parent` sits under
51
+ * its category. Drives /sitemap.xml and /llms.txt.
52
+ */
53
+ export function pagePathFor(page: {
54
+ type: string;
55
+ slug: string;
56
+ parent?: string | undefined;
57
+ }): string {
58
+ return pagePath(page, SERVICES_PATH);
59
+ }
@@ -1025,6 +1025,12 @@ export interface PlanEnvRowsOptions {
1025
1025
  * with no reader and so no `scope`; a platform or refused name is skipped.
1026
1026
  */
1027
1027
  declare?: readonly string[];
1028
+ /**
1029
+ * When set, only reads in these site-relative files get a row (#10173):
1030
+ * `update` declares what the files it wrote read, never a name only the
1031
+ * site's own code reads.
1032
+ */
1033
+ onlyFiles?: ReadonlySet<string>;
1028
1034
  }
1029
1035
 
1030
1036
  /** The header the writer puts above a block it creates. */
@@ -1066,6 +1072,7 @@ export function planEnvRows(
1066
1072
  const refused = new Set(opts.topology === "spoke" ? SPOKE_REFUSED_ENV : []);
1067
1073
  const files = new Map<string, string[]>();
1068
1074
  for (const read of collectSiteReads(root).reads) {
1075
+ if (opts.onlyFiles !== undefined && !opts.onlyFiles.has(read.file)) continue;
1069
1076
  if (isPlatformEnvName(read.name) || refused.has(read.name)) continue;
1070
1077
  if (declared.has(read.name)) continue;
1071
1078
  const list = files.get(read.name) ?? [];
package/versions.json CHANGED
@@ -1,66 +1,66 @@
1
1
  {
2
- "@working-theory/admin-ui": "0.49.0",
3
- "@working-theory/aeo": "0.49.0",
4
- "@working-theory/agent-context": "0.49.0",
2
+ "@working-theory/admin-ui": "0.50.0",
3
+ "@working-theory/aeo": "0.50.0",
4
+ "@working-theory/agent-context": "0.50.0",
5
5
  "@working-theory/agent-contract": "0.1.0",
6
6
  "@working-theory/agent-memory": "0.0.0",
7
7
  "@working-theory/ai": "0.1.0",
8
- "@working-theory/analytics": "0.49.0",
9
- "@working-theory/api": "0.49.0",
10
- "@working-theory/api-client": "0.49.0",
8
+ "@working-theory/analytics": "0.50.0",
9
+ "@working-theory/api": "0.50.0",
10
+ "@working-theory/api-client": "0.50.0",
11
11
  "@working-theory/cda-filesystem": "0.0.0",
12
- "@working-theory/cli": "0.49.0",
13
- "@working-theory/common": "0.49.0",
12
+ "@working-theory/cli": "0.50.0",
13
+ "@working-theory/common": "0.50.0",
14
14
  "@working-theory/consent": "0.1.0",
15
- "@working-theory/content-adapters": "0.49.0",
16
- "@working-theory/content-projection": "0.49.0",
17
- "@working-theory/email": "0.49.0",
15
+ "@working-theory/content-adapters": "0.50.0",
16
+ "@working-theory/content-projection": "0.50.0",
17
+ "@working-theory/email": "0.50.0",
18
18
  "@working-theory/env": "0.0.0",
19
- "@working-theory/eslint-config": "0.49.0",
20
- "@working-theory/fumadocs-schema": "0.49.0",
21
- "@working-theory/ids": "0.16.0",
22
- "@working-theory/jsonld": "0.49.0",
23
- "@working-theory/license": "0.49.0",
24
- "@working-theory/mcp-server": "0.49.0",
25
- "@working-theory/metadata": "0.49.0",
26
- "@working-theory/observability": "0.49.0",
27
- "@working-theory/page-renderer": "0.49.0",
19
+ "@working-theory/eslint-config": "0.50.0",
20
+ "@working-theory/fumadocs-schema": "0.50.0",
21
+ "@working-theory/ids": "0.16.1",
22
+ "@working-theory/jsonld": "0.50.0",
23
+ "@working-theory/license": "0.50.0",
24
+ "@working-theory/mcp-server": "0.50.0",
25
+ "@working-theory/metadata": "0.50.0",
26
+ "@working-theory/observability": "0.50.0",
27
+ "@working-theory/page-renderer": "0.50.0",
28
28
  "@working-theory/plugin-mcp": "0.1.3",
29
- "@working-theory/schema": "0.49.0",
30
- "@working-theory/schema-pack-registry": "0.49.0",
31
- "@working-theory/schema-validator": "0.49.0",
32
- "@working-theory/sections-registry": "0.49.0",
33
- "@working-theory/site-config": "0.49.0",
34
- "@working-theory/spoke-checkout": "0.7.5",
35
- "@working-theory/stripe-client": "0.49.0",
36
- "@working-theory/templates": "0.49.0",
37
- "@working-theory/theme": "0.49.0",
38
- "@working-theory/ui": "0.49.0",
39
- "@working-theory/validation": "0.49.0",
40
- "@working-theory/web-i18n": "0.49.0",
41
- "@working-theory/admin": "0.6.0",
42
- "@working-theory/api-commerce": "0.1.3",
43
- "@working-theory/api-content-write": "0.1.3",
44
- "@working-theory/api-hub": "0.3.0",
45
- "@working-theory/api-multi-tenant": "0.1.3",
46
- "@working-theory/api-scheduling": "0.4.0",
47
- "@working-theory/api-signal-collectors": "0.2.2",
48
- "@working-theory/api-workspace": "0.2.2",
29
+ "@working-theory/schema": "0.50.0",
30
+ "@working-theory/schema-pack-registry": "0.50.0",
31
+ "@working-theory/schema-validator": "0.50.0",
32
+ "@working-theory/sections-registry": "0.50.0",
33
+ "@working-theory/site-config": "0.50.0",
34
+ "@working-theory/spoke-checkout": "0.7.6",
35
+ "@working-theory/stripe-client": "0.50.0",
36
+ "@working-theory/templates": "0.50.0",
37
+ "@working-theory/theme": "0.50.0",
38
+ "@working-theory/ui": "0.50.0",
39
+ "@working-theory/validation": "0.50.0",
40
+ "@working-theory/web-i18n": "0.50.0",
41
+ "@working-theory/admin": "0.7.1",
42
+ "@working-theory/api-commerce": "0.1.4",
43
+ "@working-theory/api-content-write": "0.1.4",
44
+ "@working-theory/api-hub": "0.3.1",
45
+ "@working-theory/api-multi-tenant": "0.1.4",
46
+ "@working-theory/api-scheduling": "0.4.1",
47
+ "@working-theory/api-signal-collectors": "0.2.3",
48
+ "@working-theory/api-workspace": "0.3.0",
49
49
  "@working-theory/billing-client": "0.0.0",
50
50
  "@working-theory/branding": "0.0.0",
51
51
  "@working-theory/license-client": "0.0.0",
52
52
  "@working-theory/license-manifest": "0.0.0",
53
- "@working-theory/mcp-scheduling": "0.3.2",
54
- "@working-theory/mcp-signal-collectors": "0.2.3",
55
- "@working-theory/mcp-workspace": "0.3.2",
53
+ "@working-theory/mcp-scheduling": "0.3.3",
54
+ "@working-theory/mcp-signal-collectors": "0.2.4",
55
+ "@working-theory/mcp-workspace": "0.3.3",
56
56
  "@working-theory/partner-session": "0.0.0",
57
57
  "@working-theory/preview-protocol": "0.0.0",
58
- "@working-theory/ui-commerce": "0.23.27",
59
- "@working-theory/ui-docs-blog": "0.23.27",
60
- "@working-theory/ui-scheduling": "0.25.1",
61
- "@working-theory/ui-signal-collectors": "0.27.5",
58
+ "@working-theory/ui-commerce": "0.23.28",
59
+ "@working-theory/ui-docs-blog": "0.23.28",
60
+ "@working-theory/ui-scheduling": "0.25.2",
61
+ "@working-theory/ui-signal-collectors": "0.27.6",
62
62
  "@working-theory/web": "0.0.0",
63
63
  "@working-theory/app-api": "1.0.0",
64
64
  "@working-theory/mcp": "0.0.0",
65
- "@working-theory/database": "0.49.0"
65
+ "@working-theory/database": "0.50.0"
66
66
  }