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
@@ -19,6 +19,29 @@
19
19
  // here rather than in the matcher, because this middleware is the only place
20
20
  // the security headers are stamped.
21
21
  //
22
+ // The spoke file routes (#9162) — `/reports/<slug>/file`,
23
+ // `/account/inbox/message/<id>/file` and
24
+ // `/account/artifacts/<pieceId>/versions/<version>/file` — get every baseline
25
+ // header but the CSP. Their handlers set the hub's sandbox policy, and this
26
+ // middleware runs before them: Next puts its headers on the response first and
27
+ // adds a handler header only where none is set yet, so a site CSP stamped here
28
+ // would replace the sandbox one on the wire.
29
+ //
30
+ // Docs path (#10041 D3). A site that sets `docsPath` in `_site.mdx` serves
31
+ // its docs there: a request at or under the public path (bare or after an
32
+ // enabled-locale prefix) is handed to next-intl as the internal `/docs` route,
33
+ // so its rewrite lands on `app/[locale]/docs/` while the browser keeps the
34
+ // public URL; a redirect's `Location` and every `Link` hreflang entry are
35
+ // mapped back onto the public path. The internal `/docs` itself answers with
36
+ // the site's 404, before next-intl, so no locale redirect intervenes. A site
37
+ // with no `docsPath` takes none of these branches.
38
+ //
39
+ // Services path (#10043 D5). The same mapping for a site that sets
40
+ // `services.path` in `_site.mdx`, except that the internal `/services` (and
41
+ // everything under it) answers a 308 onto the same rest under the public
42
+ // base, so old links keep working. A site with no `services.path` takes none
43
+ // of these branches.
44
+ //
22
45
  // S16 spec: docs/internal/specs/ailk-pre-oss-security-phase-3-s16-security-headers-spec.md
23
46
  // S2 spec: docs/internal/specs/i18n-phase1-s2-next-intl-foundation-spec.md
24
47
  // P2 S1 spec: docs/internal/specs/i18n-phase2-s1-hreflang-metadata-spec.md §3
@@ -27,7 +50,19 @@ import { NextRequest, NextResponse } from "next/server";
27
50
  import createMiddleware from "next-intl/middleware";
28
51
 
29
52
  import { isBookingPath } from "./lib/auth-routes";
30
- import { SCHEDULING_PAGES } from "./lib/modules.generated";
53
+ import {
54
+ DOCS_PATH,
55
+ DOCS_ROUTE,
56
+ toInternalDocsPath,
57
+ toPublicDocsHref,
58
+ } from "./lib/docs-path";
59
+ import { PUBLIC_PAGES, SCHEDULING_PAGES } from "./lib/modules.generated";
60
+ import {
61
+ SERVICES_PATH,
62
+ SERVICES_ROUTE,
63
+ toInternalServicesPath,
64
+ toPublicServicesHref,
65
+ } from "./lib/services-path";
31
66
  import {
32
67
  DEFAULT_SECURITY_HEADERS,
33
68
  SCHEDULING_SECURITY_HEADERS,
@@ -70,6 +105,76 @@ export function isNonLocaleRoute(pathname: string): boolean {
70
105
  );
71
106
  }
72
107
 
108
+ // The file routes' paths after an enabled-locale prefix; a route group adds no
109
+ // segment, so `(authed)` is not in them. Kept in step with every route handler
110
+ // that sets its own CSP by the drift test in
111
+ // __tests__/middleware-file-routes.test.ts.
112
+ const FILE_ROUTE_PATHS = [
113
+ /^\/reports\/[^/]+\/file$/,
114
+ /^\/account\/inbox\/message\/[^/]+\/file$/,
115
+ /^\/account\/artifacts\/[^/]+\/versions\/[^/]+\/file$/,
116
+ ];
117
+
118
+ const LOCALE_PREFIX = new RegExp(`^/(${enabledLocales.join("|")})(?=/)`);
119
+
120
+ export function isFileRoutePath(pathname: string): boolean {
121
+ const bare = pathname.replace(LOCALE_PREFIX, "");
122
+ return FILE_ROUTE_PATHS.some((path) => path.test(bare));
123
+ }
124
+
125
+ // The path the hidden docs route is rewritten to. Its first segment is no
126
+ // enabled locale, so the locale layout's `assertEnabledLocale` answers it with
127
+ // the site's not-found page (#10041 D3).
128
+ const DOCS_UNMOUNTED_PATH = "/_ailk-docs-unmounted";
129
+
130
+ function splitLocalePrefix(pathname: string): { prefix: string; bare: string } {
131
+ const prefix = LOCALE_PREFIX.exec(pathname)?.[0] ?? "";
132
+ return { prefix, bare: pathname.slice(prefix.length) };
133
+ }
134
+
135
+ /**
136
+ * `url` (absolute) with its locale-less pathname mapped back onto a public
137
+ * path by `toPublicHref` — `toPublicDocsHref` or `toPublicServicesHref`.
138
+ */
139
+ function toPublicUrl(url: string, toPublicHref: (bare: string) => string): string {
140
+ const parsed = new URL(url);
141
+ const { prefix, bare } = splitLocalePrefix(parsed.pathname);
142
+ parsed.pathname = prefix + toPublicHref(bare);
143
+ return parsed.toString();
144
+ }
145
+
146
+ /**
147
+ * next-intl's response for a mapped docs or services request, with the
148
+ * internal path it was handed mapped back onto the public one: a redirect's
149
+ * `Location` and each `<url>` of the hreflang `Link` header. A rewrite is
150
+ * left as is.
151
+ */
152
+ function withPublicUrls(
153
+ response: NextResponse,
154
+ toPublicHref: (bare: string) => string,
155
+ ): NextResponse {
156
+ const location = response.headers.get("location");
157
+ if (location) response.headers.set("location", toPublicUrl(location, toPublicHref));
158
+ const link = response.headers.get("Link");
159
+ if (link) {
160
+ response.headers.set(
161
+ "Link",
162
+ link.replace(/<([^>]+)>/g, (_entry, url: string) => `<${toPublicUrl(url, toPublicHref)}>`),
163
+ );
164
+ }
165
+ return response;
166
+ }
167
+
168
+ /** The baseline map without its CSP, for the file routes. */
169
+ const FILE_ROUTE_SECURITY_HEADERS: Readonly<Record<string, string>> =
170
+ Object.freeze(
171
+ Object.fromEntries(
172
+ Object.entries(DEFAULT_SECURITY_HEADERS).filter(
173
+ ([name]) => name !== "Content-Security-Policy",
174
+ ),
175
+ ),
176
+ );
177
+
73
178
  export function middleware(request: NextRequest): NextResponse {
74
179
  // Step 1: Forward x-pathname to server components so generateMetadata can
75
180
  // build path-aware hreflang alternates (P2 S1). Clone the request headers
@@ -82,27 +187,82 @@ export function middleware(request: NextRequest): NextResponse {
82
187
  if (isNonLocaleRoute(request.nextUrl.pathname)) {
83
188
  const res = NextResponse.next({ request: { headers: requestHeaders } });
84
189
  applyHeaders(res.headers, DEFAULT_SECURITY_HEADERS);
190
+ if (!PUBLIC_PAGES) res.headers.set("X-Robots-Tag", "noindex");
85
191
  return res;
86
192
  }
87
193
 
88
- const requestWithPathname = new NextRequest(request.url, {
89
- headers: requestHeaders,
90
- method: request.method,
91
- body: request.body,
92
- });
194
+ // #10041 D3 — on a site that moved its docs, map the public docs path onto
195
+ // the internal route, and answer the internal route itself with the 404.
196
+ let docsInternalUrl: URL | null = null;
197
+ if (DOCS_PATH !== DOCS_ROUTE) {
198
+ const { prefix, bare } = splitLocalePrefix(request.nextUrl.pathname);
199
+ const internal = toInternalDocsPath(bare);
200
+ if (internal !== null) {
201
+ docsInternalUrl = request.nextUrl.clone();
202
+ docsInternalUrl.pathname = prefix + internal;
203
+ } else if (bare === DOCS_ROUTE || bare.startsWith(`${DOCS_ROUTE}/`)) {
204
+ const res = NextResponse.rewrite(new URL(DOCS_UNMOUNTED_PATH, request.url), {
205
+ request: { headers: requestHeaders },
206
+ });
207
+ applyHeaders(res.headers, DEFAULT_SECURITY_HEADERS);
208
+ if (!PUBLIC_PAGES) res.headers.set("X-Robots-Tag", "noindex");
209
+ return res;
210
+ }
211
+ }
212
+
213
+ // #10043 D5 — on a site that moved its services, map the public base onto
214
+ // the internal route, and answer the internal route itself with a 308 onto
215
+ // the base (same origin, same locale prefix and query, so it cannot leave
216
+ // the site).
217
+ let servicesInternalUrl: URL | null = null;
218
+ if (docsInternalUrl === null && SERVICES_PATH !== SERVICES_ROUTE) {
219
+ const { prefix, bare } = splitLocalePrefix(request.nextUrl.pathname);
220
+ const internal = toInternalServicesPath(bare);
221
+ if (internal !== null) {
222
+ servicesInternalUrl = request.nextUrl.clone();
223
+ servicesInternalUrl.pathname = prefix + internal;
224
+ } else if (bare === SERVICES_ROUTE || bare.startsWith(`${SERVICES_ROUTE}/`)) {
225
+ const target = request.nextUrl.clone();
226
+ target.pathname = prefix + toPublicServicesHref(bare);
227
+ const res = NextResponse.redirect(target, 308);
228
+ applyHeaders(res.headers, DEFAULT_SECURITY_HEADERS);
229
+ if (!PUBLIC_PAGES) res.headers.set("X-Robots-Tag", "noindex");
230
+ return res;
231
+ }
232
+ }
233
+
234
+ const requestWithPathname = new NextRequest(
235
+ docsInternalUrl ?? servicesInternalUrl ?? request.url,
236
+ {
237
+ headers: requestHeaders,
238
+ method: request.method,
239
+ body: request.body,
240
+ },
241
+ );
93
242
 
94
243
  // Step 2: run next-intl middleware (locale detection + rewrite/redirect)
95
- const intlResponse = intlMiddleware(requestWithPathname) as NextResponse;
244
+ let intlResponse = intlMiddleware(requestWithPathname) as NextResponse;
245
+ if (docsInternalUrl) intlResponse = withPublicUrls(intlResponse, toPublicDocsHref);
246
+ if (servicesInternalUrl) {
247
+ intlResponse = withPublicUrls(intlResponse, toPublicServicesHref);
248
+ }
96
249
 
97
250
  // Step 3: apply the 7 baseline security headers to the response. The booking
98
251
  // pages of a site that carries the scheduling module get the same map with
99
- // Cal.com's origin added to the CSP (#8843 D12); no other page does.
252
+ // Cal.com's origin added to the CSP (#8843 D12); no other page does. The
253
+ // file routes get the six others and keep their own sandbox CSP (#9162).
100
254
  applyHeaders(
101
255
  intlResponse.headers,
102
- SCHEDULING_PAGES && isBookingPath(request.nextUrl.pathname, enabledLocales)
103
- ? SCHEDULING_SECURITY_HEADERS
104
- : DEFAULT_SECURITY_HEADERS,
256
+ isFileRoutePath(request.nextUrl.pathname)
257
+ ? FILE_ROUTE_SECURITY_HEADERS
258
+ : SCHEDULING_PAGES &&
259
+ isBookingPath(request.nextUrl.pathname, enabledLocales)
260
+ ? SCHEDULING_SECURITY_HEADERS
261
+ : DEFAULT_SECURITY_HEADERS,
105
262
  );
263
+ // #10040 decision 7 — a site with neither marketing nor docs-blog (a hub)
264
+ // asks every crawler to keep out of every page, read from the module list.
265
+ if (!PUBLIC_PAGES) intlResponse.headers.set("X-Robots-Tag", "noindex");
106
266
 
107
267
  return intlResponse;
108
268
  }
@@ -118,6 +278,13 @@ export function middleware(request: NextRequest): NextResponse {
118
278
  // og-default.png — and each 404'd (#4090 item 4). robots.txt and sitemap.xml are
119
279
  // route handlers rather than public/ files, but they carry extensions too and
120
280
  // are equally not locale-scoped, so the same rule covers them.
281
+ //
282
+ // BotID's proxy prefix (`withBotId`, `botid/next/config`, #9889 D7) is
283
+ // excluded too, exactly that two-segment prefix and nothing wider, so
284
+ // next-intl never rewrites it and the security headers never stamp
285
+ // `frame-ancestors 'none'` on it; `withBotId` sets that prefix's own
286
+ // `X-Frame-Options: SAMEORIGIN` and `frame-ancestors 'self'`.
121
287
  export const config = {
122
- matcher: "/((?!_next/static|_next/image|_next/server|.*\\..*).*)",
288
+ matcher:
289
+ "/((?!_next/static|_next/image|_next/server|149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/|.*\\..*).*)",
123
290
  };
@@ -1,11 +1,13 @@
1
- import { readFileSync } from "node:fs";
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { parse as parseYaml } from "yaml";
4
4
  import bundleAnalyzer from "@next/bundle-analyzer";
5
5
  import { withSentryConfig } from "@sentry/nextjs";
6
6
  import createNextIntlPlugin from "next-intl/plugin";
7
- import { legacyRedirects } from "@working-theory/templates/routes";
7
+ import { DocsPathSchema, ServicesPathSchema } from "@working-theory/site-config/schema";
8
+ import { legacyRedirects, ROUTES, SERVICES_ROUTE } from "@working-theory/templates/routes";
8
9
  import { parseStealth } from "@working-theory/web-i18n/stealth";
10
+ import { withBotId } from "botid/next/config";
9
11
 
10
12
  // @next/bundle-analyzer — enabled when ANALYZE=true (set by ci-bundle.yml).
11
13
  // analyzerMode 'json' writes .next/analyze/client.json (and nodejs.json,
@@ -80,6 +82,203 @@ function readDefaultLocaleFromProjectYaml() {
80
82
  const enabledLocalesAtBuild = readLocalesFromProjectYaml();
81
83
  const defaultLocaleAtBuild = readDefaultLocaleFromProjectYaml();
82
84
 
85
+ // #10041 D4 — where the docs live. A site sets `docsPath` in
86
+ // content/_site.mdx (its own file, which `create-ailk update` never rewrites);
87
+ // it is read here, refused by name when it cannot be served, and baked into
88
+ // NEXT_PUBLIC_AILK_DOCS_PATH for the middleware and every docs-URL reader
89
+ // (lib/docs-path.ts). Absent — the file, its frontmatter or the key — is
90
+ // `/docs`, today's path. A refusal throws, so `next build` fails before a page
91
+ // renders (the parseStealth posture).
92
+ const DOCS_ROUTE = "/docs";
93
+
94
+ function refuseDocsPath(value, reason) {
95
+ throw new Error(`content/_site.mdx docsPath ${value}: ${reason}`);
96
+ }
97
+
98
+ /**
99
+ * The mount checks a public path takes before it is served (#10041 D4, shared
100
+ * with `services.path` by #10043 D4), each refused through `refuse`:
101
+ *
102
+ * 1. A locale code as the first segment would be read as the locale.
103
+ * 2. A path that shadows another page, or sits under a catch-all. A route is
104
+ * shadowed when the path matches its leading segments, a `:param` segment
105
+ * matching any one segment (`/products/docs` hides the `/products/:slug`
106
+ * page whose slug is `docs`). `ownRoutes` are the setting's own rows.
107
+ */
108
+ function refuseUnmountablePath(value, ownRoutes, refuse) {
109
+ const firstSegment = value.split("/")[1];
110
+ const locales = enabledLocalesAtBuild.split(",").map((l) => l.toLowerCase());
111
+ if (locales.includes(firstSegment)) {
112
+ refuse(value, `its first segment is the enabled locale ${firstSegment}`);
113
+ }
114
+
115
+ const valueSegments = value.split("/").slice(1);
116
+ for (const { route } of ROUTES) {
117
+ if (ownRoutes.includes(route)) continue;
118
+ const catchAll = /^(.*)\/:\.\.\.[^/]+$/.exec(route);
119
+ if (catchAll) {
120
+ if (value.startsWith(`${catchAll[1]}/`)) {
121
+ refuse(value, `it is under the ${catchAll[1]} catch-all route`);
122
+ }
123
+ continue;
124
+ }
125
+ const routeSegments = route.split("/").slice(1);
126
+ const shadows =
127
+ valueSegments.length <= routeSegments.length &&
128
+ valueSegments.every(
129
+ (segment, i) =>
130
+ routeSegments[i].startsWith(":") || routeSegments[i] === segment,
131
+ );
132
+ if (shadows) refuse(value, `it shadows the ${route} route`);
133
+ }
134
+ }
135
+
136
+ const contentRootAtBuild =
137
+ process.env.AILK_CONTENT_ROOT || join(process.cwd(), "../../content");
138
+
139
+ /** A file's YAML frontmatter, parsed; `undefined` when absent or unreadable. */
140
+ function readFrontmatter(path) {
141
+ let raw;
142
+ try {
143
+ raw = readFileSync(path, "utf8");
144
+ } catch {
145
+ return undefined;
146
+ }
147
+ // The frontmatter is the text between the first two `---` lines.
148
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(raw);
149
+ return match ? parseYaml(match[1]) : undefined;
150
+ }
151
+
152
+ const siteFrontmatterAtBuild = readFrontmatter(join(contentRootAtBuild, "_site.mdx"));
153
+
154
+ function readDocsPathFromSiteMdx() {
155
+ const value = siteFrontmatterAtBuild?.docsPath;
156
+ if (value === undefined || value === null) return DOCS_ROUTE;
157
+
158
+ const parsed = DocsPathSchema.safeParse(value);
159
+ if (!parsed.success) {
160
+ refuseDocsPath(
161
+ value,
162
+ "must be one to four lowercase kebab segments with a leading slash and no trailing slash, not starting with api, og or v1, and not under /docs",
163
+ );
164
+ }
165
+ const docsPath = parsed.data;
166
+ refuseUnmountablePath(
167
+ docsPath,
168
+ [DOCS_ROUTE, `${DOCS_ROUTE}/:...slug`],
169
+ refuseDocsPath,
170
+ );
171
+
172
+ // 3. The site's docs-blog files must follow the setting: its docs source
173
+ // must take `DOCS_PATH` as its base.
174
+ if (docsPath !== DOCS_ROUTE) {
175
+ const sourcePath = join(process.cwd(), "lib/source.ts");
176
+ if (!existsSync(sourcePath)) {
177
+ refuseDocsPath(docsPath, "the site carries no docs-blog module");
178
+ }
179
+ if (!/\bbaseUrl:\s*DOCS_PATH\b/.test(readFileSync(sourcePath, "utf8"))) {
180
+ refuseDocsPath(
181
+ docsPath,
182
+ "this site's docs-blog files predate docsPath; re-install the docs-blog module's files",
183
+ );
184
+ }
185
+ }
186
+ return docsPath;
187
+ }
188
+
189
+ const docsPathAtBuild = readDocsPathFromSiteMdx();
190
+
191
+ // #10043 D4 — where the services live, and their categories. A site sets
192
+ // `services: { path: /capabilities }` in content/_site.mdx; it is read here,
193
+ // refused by name when it cannot be served, and baked into
194
+ // NEXT_PUBLIC_AILK_SERVICES_PATH for the middleware, the page renderer and
195
+ // every services-URL reader (lib/services-path.ts). Absent is `/services`.
196
+ // A Service's `parent` (its category) is checked here too, for the default
197
+ // locale's MDX files: it must name a Service with no parent of its own, and
198
+ // the site must carry the nested route.
199
+
200
+ function refuseServicesPath(value, reason) {
201
+ throw new Error(`content/_site.mdx services.path ${value}: ${reason}`);
202
+ }
203
+
204
+ function readServicesPathFromSiteMdx() {
205
+ const value = siteFrontmatterAtBuild?.services?.path;
206
+ if (value === undefined || value === null) return SERVICES_ROUTE;
207
+
208
+ const parsed = ServicesPathSchema.safeParse(value);
209
+ if (!parsed.success) {
210
+ refuseServicesPath(
211
+ value,
212
+ "must be one to four lowercase kebab segments with a leading slash and no trailing slash, not starting with api, og or v1, and not under /services",
213
+ );
214
+ }
215
+ const servicesPath = parsed.data;
216
+ refuseUnmountablePath(
217
+ servicesPath,
218
+ [SERVICES_ROUTE, `${SERVICES_ROUTE}/:slug`, `${SERVICES_ROUTE}/:slug/:child`],
219
+ refuseServicesPath,
220
+ );
221
+ // Two moved mounts must not overlap, in either direction.
222
+ const overlaps = (a, b) => a === b || a.startsWith(`${b}/`);
223
+ if (overlaps(servicesPath, docsPathAtBuild) || overlaps(docsPathAtBuild, servicesPath)) {
224
+ refuseServicesPath(servicesPath, `it overlaps docsPath ${docsPathAtBuild}`);
225
+ }
226
+ return servicesPath;
227
+ }
228
+
229
+ function refuseServiceParent(file, value, reason) {
230
+ throw new Error(`${file} parent ${value}: ${reason}`);
231
+ }
232
+
233
+ function readServiceParents() {
234
+ // Under the database adapter the pages are not on disk; the nested route
235
+ // answers 404 for a bad parent at request time instead.
236
+ if (process.env.CONTENT_ADAPTER === "database") return;
237
+ const dirs = [`${defaultLocaleAtBuild}/Service`, "Service"];
238
+ const nestedRoute = join(process.cwd(), "app/[locale]/services/[slug]/[child]/page.tsx");
239
+ for (const dir of dirs) {
240
+ let files;
241
+ try {
242
+ files = readdirSync(join(contentRootAtBuild, dir)).filter((f) => f.endsWith(".mdx"));
243
+ } catch {
244
+ continue;
245
+ }
246
+ const pages = files.map((file) => {
247
+ const frontmatter = readFrontmatter(join(contentRootAtBuild, dir, file)) ?? {};
248
+ return {
249
+ file: `content/${dir}/${file}`,
250
+ slug: frontmatter.slug ?? file.replace(/\.mdx$/, ""),
251
+ parent: frontmatter.parent,
252
+ };
253
+ });
254
+ const bySlug = new Map(pages.map((page) => [page.slug, page]));
255
+ for (const page of pages) {
256
+ if (page.parent === undefined || page.parent === null) continue;
257
+ const category = bySlug.get(page.parent);
258
+ if (category === undefined) {
259
+ refuseServiceParent(page.file, page.parent, `it names no Service in content/${dir}`);
260
+ }
261
+ if (category.parent !== undefined && category.parent !== null) {
262
+ refuseServiceParent(
263
+ page.file,
264
+ page.parent,
265
+ "that Service has its own parent; services take one category level",
266
+ );
267
+ }
268
+ if (!existsSync(nestedRoute)) {
269
+ refuseServiceParent(
270
+ page.file,
271
+ page.parent,
272
+ "this site's services routes predate service categories; run create-ailk sync-routes",
273
+ );
274
+ }
275
+ }
276
+ }
277
+ }
278
+
279
+ const servicesPathAtBuild = readServicesPathFromSiteMdx();
280
+ readServiceParents();
281
+
83
282
  /** @type {import('next').NextConfig} */
84
283
  const nextConfig = {
85
284
  reactStrictMode: true,
@@ -104,6 +303,10 @@ const nextConfig = {
104
303
  // output shipped without them and postbuild's assert-content-bundled failed.
105
304
  outputFileTracingIncludes: {
106
305
  "/**": ["../../content/**/*.mdx"],
306
+ // #10041 D8 — /sitemap.xml and /llms.txt list the docs by reading this
307
+ // app's own content/<locale>/docs/ at request time.
308
+ "/sitemap.xml": ["./content/**/*.mdx"],
309
+ "/llms.txt": ["./content/**/*.mdx"],
107
310
  },
108
311
  // Workspace packages live outside this app's directory. Next.js must
109
312
  // transpile them from source so that "use client" / "use server" directives
@@ -160,6 +363,13 @@ const nextConfig = {
160
363
  // onto the hub's. Part of the source, not an operator setting: a build
161
364
  // that carries the relay always carries the flag, and no other build does.
162
365
  NEXT_PUBLIC_AILK_SAME_ORIGIN_RELAY: "1",
366
+ // The docs' public path (#10041), from content/_site.mdx's docsPath, or
367
+ // `/docs`. Read by middleware.ts and lib/docs-path.ts.
368
+ NEXT_PUBLIC_AILK_DOCS_PATH: docsPathAtBuild,
369
+ // The services' public base (#10043), from content/_site.mdx's
370
+ // services.path, or `/services`. Read by middleware.ts,
371
+ // lib/services-path.ts and @working-theory/page-renderer.
372
+ NEXT_PUBLIC_AILK_SERVICES_PATH: servicesPathAtBuild,
163
373
  },
164
374
  async headers() {
165
375
  if (!STEALTH) return [];
@@ -197,7 +407,14 @@ const sentryBuildOptions = {
197
407
  : {}),
198
408
  };
199
409
 
410
+ // #9889 D6 — `withBotId` adds BotID's two rewrites (the challenge script and
411
+ // its proxy) and one header rule on its own proxy prefix, which middleware.ts
412
+ // keeps out of its matcher. It is not gated on `AILK_BOOKING_BOT_CHECK`: this
413
+ // config is read at build time, the switch at run time, and an unused rewrite
414
+ // answers nothing. It sits directly inside Sentry so the substrings
415
+ // (the MDX wrap and the next-intl wrap, quoted nowhere else) that create-ailk
416
+ // edits stay intact.
200
417
  export default withSentryConfig(
201
- withBundleAnalyzer(withNextIntl(nextConfig)),
418
+ withBotId(withBundleAnalyzer(withNextIntl(nextConfig))),
202
419
  sentryBuildOptions,
203
420
  );
@@ -6,7 +6,7 @@
6
6
  "license": "Apache-2.0",
7
7
  "scripts": {
8
8
  "dev": "next dev --webpack",
9
- "build": "next build --webpack",
9
+ "build": "tsx scripts/check-nav-labels.ts && next build --webpack",
10
10
  "postbuild": "tsx scripts/assert-content-bundled.ts",
11
11
  "start": "next start",
12
12
  "type-check": "tsc --noEmit",
@@ -37,8 +37,9 @@
37
37
  "@working-theory/validation": "workspace:*",
38
38
  "@working-theory/web-i18n": "workspace:*",
39
39
  "better-auth": "1.6.22",
40
+ "botid": "1.5.11",
40
41
  "lucide-react": "^0.577.0",
41
- "next": "^16.3.8",
42
+ "next": "^16.4.3",
42
43
  "next-intl": "^3.26.3",
43
44
  "next-mdx-remote": "^6.0.0",
44
45
  "pg": "^8.22.0",
@@ -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;