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.
- package/README.md +86 -9
- package/dist/add-module-core.js +2 -1
- package/dist/add-module.d.ts +2 -1
- package/dist/add-module.js +9 -4
- package/dist/add-route.js +46 -19
- package/dist/body-heading.d.ts +56 -0
- package/dist/body-heading.js +394 -0
- package/dist/cli.js +28 -1
- package/dist/content-payloads.js +2 -2
- package/dist/derive-architecture.js +8 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/lib/auth-secret-file.d.ts +6 -2
- package/dist/lib/auth-secret-file.js +46 -20
- package/dist/lib/env-catalog.js +20 -0
- package/dist/lib/site-modules.d.ts +8 -0
- package/dist/lib/site-modules.js +34 -0
- package/dist/module-architecture.js +25 -14
- package/dist/paid-sections.d.ts +17 -0
- package/dist/paid-sections.js +39 -0
- package/dist/programmatic.d.ts +16 -0
- package/dist/programmatic.js +73 -4
- package/dist/remove-module.d.ts +98 -0
- package/dist/remove-module.js +620 -0
- package/dist/seed-flows.d.ts +2 -2
- package/dist/seed-flows.js +16 -9
- package/dist/site-roster.js +4 -1
- package/dist/starter-grid.d.ts +26 -0
- package/dist/starter-grid.js +34 -0
- package/dist/surfaces.d.ts +26 -0
- package/dist/surfaces.js +112 -11
- package/dist/sync-routes.d.ts +7 -0
- package/dist/sync-routes.js +53 -6
- package/dist/template-pages.d.ts +36 -0
- package/dist/template-pages.js +129 -0
- package/dist/topology.d.ts +2 -1
- package/dist/topology.js +4 -2
- package/dist/update.d.ts +3 -1
- package/dist/update.js +90 -6
- package/package.json +3 -3
- package/release-scope.json +2 -1
- package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
- package/templates/.env.example +12 -2
- package/templates/CLAUDE.md +20 -20
- package/templates/README.md +19 -19
- package/templates/apps/api/.env.example +16 -5
- package/templates/apps/api/CLAUDE.md +32 -11
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
- package/templates/apps/api/src/vercel-handler.ts +8 -1
- package/templates/apps/api/vercel.json +7 -1
- package/templates/apps/mcp/CLAUDE.md +18 -10
- package/templates/apps/web/.env.example +27 -0
- package/templates/apps/web/CLAUDE.md +56 -8
- package/templates/apps/web/__tests__/setup.ts +7 -0
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +51 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +10 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +197 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +23 -7
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
- package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
- package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
- package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
- package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
- package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
- package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
- package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
- package/templates/apps/web/app/[locale]/layout.tsx +161 -128
- package/templates/apps/web/app/[locale]/page.tsx +51 -3
- package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
- package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
- package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
- package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
- package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
- package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
- package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
- package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
- package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
- package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
- package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +223 -0
- package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
- package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
- package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +236 -0
- package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +322 -2
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +161 -29
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
- package/templates/apps/web/app/llms.txt/route.ts +41 -3
- package/templates/apps/web/app/robots.txt/route.ts +5 -1
- package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
- package/templates/apps/web/auth.ts +102 -50
- package/templates/apps/web/components/signed-in-nav.tsx +8 -1
- package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
- package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
- package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
- package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +278 -0
- package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
- package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +484 -4
- package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
- package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
- package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
- package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
- package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
- package/templates/apps/web/lib/auth-secret.ts +57 -0
- package/templates/apps/web/lib/bot-check.ts +138 -0
- package/templates/apps/web/lib/consent-visitor.ts +81 -7
- package/templates/apps/web/lib/deck-page.ts +6 -3
- package/templates/apps/web/lib/docs-index.ts +100 -0
- package/templates/apps/web/lib/docs-path.ts +94 -0
- package/templates/apps/web/lib/hub-children.tsx +318 -0
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +22 -2
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +11 -8
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/nav-label-check.ts +61 -0
- package/templates/apps/web/lib/nav-label-keys.ts +78 -0
- package/templates/apps/web/lib/newsletter.actions.ts +31 -6
- package/templates/apps/web/lib/offerings-pricing.ts +135 -6
- package/templates/apps/web/lib/page-chrome.ts +367 -0
- package/templates/apps/web/lib/posting-title.ts +26 -4
- package/templates/apps/web/lib/pricing-page.ts +430 -0
- package/templates/apps/web/lib/relay.ts +44 -4
- package/templates/apps/web/lib/route-family.ts +69 -0
- package/templates/apps/web/lib/service-listing.ts +89 -0
- package/templates/apps/web/lib/services-path.ts +97 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +17 -6
- package/templates/apps/web/lib/site-header.ts +94 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
- package/templates/apps/web/lib/template-listing.ts +169 -0
- package/templates/apps/web/messages/en.json +144 -3
- package/templates/apps/web/middleware.ts +179 -12
- package/templates/apps/web/next.config.mjs +220 -3
- package/templates/apps/web/package.json +3 -2
- package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
- package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
- package/templates/apps/web/sentry.server.config.ts +30 -2
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
- package/templates/apps/web/src/lib/rate-limit.ts +68 -27
- package/templates/apps/web/src/lib/routes.ts +15 -1
- 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("
|
|
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.
|
|
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.
|
|
64
|
+
).toBe("198.51.100.1");
|
|
58
65
|
});
|
|
59
66
|
|
|
60
|
-
it("
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 (
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
51
|
-
if (bucket.resetAt <= now)
|
|
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 (
|
|
56
|
-
const oldestKey =
|
|
57
|
-
if (oldestKey !== undefined)
|
|
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: `
|
|
68
|
-
* `x-forwarded-for`,
|
|
69
|
-
* `"unknown"
|
|
70
|
-
*
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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.
|
|
3
|
-
"@working-theory/aeo": "0.
|
|
4
|
-
"@working-theory/agent-context": "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.
|
|
9
|
-
"@working-theory/api": "0.
|
|
10
|
-
"@working-theory/api-client": "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.
|
|
13
|
-
"@working-theory/common": "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.
|
|
16
|
-
"@working-theory/content-projection": "0.
|
|
17
|
-
"@working-theory/email": "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.
|
|
20
|
-
"@working-theory/fumadocs-schema": "0.
|
|
21
|
-
"@working-theory/ids": "0.16.
|
|
22
|
-
"@working-theory/jsonld": "0.
|
|
23
|
-
"@working-theory/license": "0.
|
|
24
|
-
"@working-theory/mcp-server": "0.
|
|
25
|
-
"@working-theory/metadata": "0.
|
|
26
|
-
"@working-theory/observability": "0.
|
|
27
|
-
"@working-theory/page-renderer": "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.
|
|
30
|
-
"@working-theory/schema-pack-registry": "0.
|
|
31
|
-
"@working-theory/schema-validator": "0.
|
|
32
|
-
"@working-theory/sections-registry": "0.
|
|
33
|
-
"@working-theory/site-config": "0.
|
|
34
|
-
"@working-theory/spoke-checkout": "0.7.
|
|
35
|
-
"@working-theory/stripe-client": "0.
|
|
36
|
-
"@working-theory/templates": "0.
|
|
37
|
-
"@working-theory/theme": "0.
|
|
38
|
-
"@working-theory/ui": "0.
|
|
39
|
-
"@working-theory/validation": "0.
|
|
40
|
-
"@working-theory/web-i18n": "0.
|
|
41
|
-
"@working-theory/admin": "0.
|
|
42
|
-
"@working-theory/api-commerce": "0.1.
|
|
43
|
-
"@working-theory/api-content-write": "0.1.
|
|
44
|
-
"@working-theory/api-hub": "0.
|
|
45
|
-
"@working-theory/api-multi-tenant": "0.1.
|
|
46
|
-
"@working-theory/api-scheduling": "0.
|
|
47
|
-
"@working-theory/api-signal-collectors": "0.2.
|
|
48
|
-
"@working-theory/api-workspace": "0.
|
|
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.
|
|
54
|
-
"@working-theory/mcp-signal-collectors": "0.2.
|
|
55
|
-
"@working-theory/mcp-workspace": "0.3.
|
|
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.
|
|
59
|
-
"@working-theory/ui-docs-blog": "0.23.
|
|
60
|
-
"@working-theory/ui-scheduling": "0.25.
|
|
61
|
-
"@working-theory/ui-signal-collectors": "0.27.
|
|
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.
|
|
65
|
+
"@working-theory/database": "0.50.0"
|
|
66
66
|
}
|