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
@@ -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
+ }
package/versions.json CHANGED
@@ -1,66 +1,66 @@
1
1
  {
2
- "@working-theory/admin-ui": "0.48.0",
3
- "@working-theory/aeo": "0.48.0",
4
- "@working-theory/agent-context": "0.48.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.48.0",
9
- "@working-theory/api": "0.48.0",
10
- "@working-theory/api-client": "0.48.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.48.0",
13
- "@working-theory/common": "0.48.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.48.0",
16
- "@working-theory/content-projection": "0.48.0",
17
- "@working-theory/email": "0.48.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.48.0",
20
- "@working-theory/fumadocs-schema": "0.48.0",
21
- "@working-theory/ids": "0.16.0",
22
- "@working-theory/jsonld": "0.48.0",
23
- "@working-theory/license": "0.48.0",
24
- "@working-theory/mcp-server": "0.48.0",
25
- "@working-theory/metadata": "0.48.0",
26
- "@working-theory/observability": "0.48.0",
27
- "@working-theory/page-renderer": "0.48.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.48.0",
30
- "@working-theory/schema-pack-registry": "0.48.0",
31
- "@working-theory/schema-validator": "0.48.0",
32
- "@working-theory/sections-registry": "0.48.0",
33
- "@working-theory/site-config": "0.48.0",
34
- "@working-theory/spoke-checkout": "0.7.4",
35
- "@working-theory/stripe-client": "0.48.0",
36
- "@working-theory/templates": "0.48.0",
37
- "@working-theory/theme": "0.48.0",
38
- "@working-theory/ui": "0.48.0",
39
- "@working-theory/validation": "0.48.0",
40
- "@working-theory/web-i18n": "0.48.0",
41
- "@working-theory/admin": "0.5.0",
42
- "@working-theory/api-commerce": "0.1.2",
43
- "@working-theory/api-content-write": "0.1.2",
44
- "@working-theory/api-hub": "0.2.1",
45
- "@working-theory/api-multi-tenant": "0.1.2",
46
- "@working-theory/api-scheduling": "0.3.0",
47
- "@working-theory/api-signal-collectors": "0.2.1",
48
- "@working-theory/api-workspace": "0.2.1",
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.0",
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.1",
54
- "@working-theory/mcp-signal-collectors": "0.2.2",
55
- "@working-theory/mcp-workspace": "0.3.1",
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.26",
59
- "@working-theory/ui-docs-blog": "0.23.26",
60
- "@working-theory/ui-scheduling": "0.25.0",
61
- "@working-theory/ui-signal-collectors": "0.27.4",
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.48.0"
65
+ "@working-theory/database": "0.50.0"
66
66
  }