create-ailk 0.27.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 +84 -7
- 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 +23 -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 +105 -9
- 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 +38 -4
- 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 +5 -2
- package/templates/apps/api/.env.example +9 -5
- package/templates/apps/api/CLAUDE.md +14 -9
- 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/mcp/CLAUDE.md +18 -10
- package/templates/apps/web/.env.example +25 -11
- package/templates/apps/web/CLAUDE.md +45 -4
- 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]/(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 +157 -139
- 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 +204 -2
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +151 -33
- 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 +202 -10
- 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/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 +31 -106
- 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/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 +86 -30
- package/templates/apps/web/middleware.ts +171 -11
- package/templates/apps/web/next.config.mjs +211 -2
- package/templates/apps/web/package.json +1 -1
- 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
|
@@ -11,8 +11,13 @@
|
|
|
11
11
|
* #7874 D5). The rest of the ladder is pinned in `../../__tests__/route.test.ts`.
|
|
12
12
|
*
|
|
13
13
|
* Each case loads the route in its own module registry, so no case reads
|
|
14
|
-
* another's shared shown-set answer
|
|
15
|
-
* of both generated seams, as
|
|
14
|
+
* another's shared shown-set answer or spends another's start budget; a spoke
|
|
15
|
+
* case swaps in the spoke render of both generated seams, as
|
|
16
|
+
* `lib/__tests__/relay.test.ts` does.
|
|
17
|
+
*
|
|
18
|
+
* The per-visitor start budget (#9965: #9059, #9063) is pinned at the end:
|
|
19
|
+
* keyed only on the address Vercel's edge writes, never skipped for a visitor
|
|
20
|
+
* with none, and spent only by a start the API made.
|
|
16
21
|
*/
|
|
17
22
|
import { workspaceShownSetSchema } from "@working-theory/validation";
|
|
18
23
|
|
|
@@ -71,6 +76,7 @@ const ENV_NAMES = [
|
|
|
71
76
|
"NODE_ENV",
|
|
72
77
|
"OSS_API_INTERNAL_SECRET",
|
|
73
78
|
"AILK_SPOKE_KEY",
|
|
79
|
+
"VERCEL",
|
|
74
80
|
] as const;
|
|
75
81
|
const savedEnv = Object.fromEntries(ENV_NAMES.map((name) => [name, env[name]]));
|
|
76
82
|
|
|
@@ -83,16 +89,21 @@ function json(status: number, body: unknown): Promise<Response> {
|
|
|
83
89
|
);
|
|
84
90
|
}
|
|
85
91
|
|
|
86
|
-
|
|
92
|
+
function started(): Promise<Response> {
|
|
93
|
+
return json(201, {
|
|
94
|
+
sessionId: "cs_test_a1",
|
|
95
|
+
url: "https://checkout.stripe.com/c/pay/cs_test_a1",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The shown-set read answers `shownStatus`; the session POST `sessionAnswer`. */
|
|
87
100
|
let shownStatus: "unconnected" | "shown";
|
|
101
|
+
let sessionAnswer: () => Promise<Response>;
|
|
88
102
|
const realFetch = global.fetch;
|
|
89
103
|
const fetchMock = jest.fn(
|
|
90
104
|
(input: string | URL | Request, _init?: RequestInit) => {
|
|
91
105
|
if (new URL(String(input)).pathname === "/v1/checkout/sessions") {
|
|
92
|
-
return
|
|
93
|
-
sessionId: "cs_test_a1",
|
|
94
|
-
url: "https://checkout.stripe.com/c/pay/cs_test_a1",
|
|
95
|
-
});
|
|
106
|
+
return sessionAnswer();
|
|
96
107
|
}
|
|
97
108
|
return json(200, {
|
|
98
109
|
ok: true,
|
|
@@ -119,6 +130,7 @@ beforeEach(() => {
|
|
|
119
130
|
env.NODE_ENV = "production";
|
|
120
131
|
env.NEXT_PUBLIC_BASE_URL = "https://shop.example.com";
|
|
121
132
|
shownStatus = "unconnected";
|
|
133
|
+
sessionAnswer = started;
|
|
122
134
|
fetchMock.mockClear();
|
|
123
135
|
warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
|
|
124
136
|
});
|
|
@@ -163,6 +175,53 @@ async function buy(topology: "hub" | "spoke"): Promise<Response> {
|
|
|
163
175
|
return res;
|
|
164
176
|
}
|
|
165
177
|
|
|
178
|
+
type Get = (typeof import("../route"))["GET"];
|
|
179
|
+
|
|
180
|
+
/** The hub route in one module registry, so its calls share one budget. */
|
|
181
|
+
async function loadHubRoute(): Promise<Get> {
|
|
182
|
+
let get: Get | undefined;
|
|
183
|
+
await jest.isolateModulesAsync(async () => {
|
|
184
|
+
jest.dontMock(ORIGIN_SEAM);
|
|
185
|
+
jest.dontMock(TOPOLOGY_SEAM);
|
|
186
|
+
({ GET: get } = await import("../route"));
|
|
187
|
+
});
|
|
188
|
+
if (get === undefined) throw new Error("the route did not load");
|
|
189
|
+
return get;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** A buyer's click on `growth_monthly` carrying `headers`. */
|
|
193
|
+
function click(
|
|
194
|
+
get: Get,
|
|
195
|
+
headers: Record<string, string> = {},
|
|
196
|
+
): Promise<Response> {
|
|
197
|
+
const request = new Request(
|
|
198
|
+
"https://shop.example.com/api/offerings/checkout/growth_monthly",
|
|
199
|
+
{
|
|
200
|
+
headers: {
|
|
201
|
+
"sec-fetch-mode": "navigate",
|
|
202
|
+
"sec-fetch-dest": "document",
|
|
203
|
+
"sec-fetch-site": "same-origin",
|
|
204
|
+
...headers,
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
return get(request, {
|
|
209
|
+
params: Promise.resolve({ lookupKey: "growth_monthly" }),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function postCount(): number {
|
|
214
|
+
return fetchMock.mock.calls.filter(
|
|
215
|
+
([input]) => new URL(String(input)).pathname === "/v1/checkout/sessions",
|
|
216
|
+
).length;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function refusedFor(reason: string): number {
|
|
220
|
+
return warnSpy.mock.calls.filter(
|
|
221
|
+
([line]) => line === `[offerings-checkout] refused reason=${reason}`,
|
|
222
|
+
).length;
|
|
223
|
+
}
|
|
224
|
+
|
|
166
225
|
function postHeaders(): Record<string, string> {
|
|
167
226
|
const post = fetchMock.mock.calls.find(
|
|
168
227
|
([input]) => new URL(String(input)).pathname === "/v1/checkout/sessions",
|
|
@@ -220,3 +279,170 @@ describe("the route's API origin comes from the topology seam", () => {
|
|
|
220
279
|
expect(postHeaders()["x-internal-auth"]).toBeUndefined();
|
|
221
280
|
});
|
|
222
281
|
});
|
|
282
|
+
|
|
283
|
+
describe("the per-visitor start budget (#9059, #9063)", () => {
|
|
284
|
+
beforeEach(() => {
|
|
285
|
+
env.AILK_API_URL = HUB_API;
|
|
286
|
+
shownStatus = "shown";
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("on Vercel, keys on x-vercel-forwarded-for, never a client-chosen x-forwarded-for", async () => {
|
|
290
|
+
env.VERCEL = "1";
|
|
291
|
+
const get = await loadHubRoute();
|
|
292
|
+
|
|
293
|
+
for (let i = 0; i < 6; i += 1) {
|
|
294
|
+
await click(get, {
|
|
295
|
+
"x-vercel-forwarded-for": "198.51.100.1",
|
|
296
|
+
"x-forwarded-for": `203.0.113.${i}`,
|
|
297
|
+
"x-real-ip": `192.0.2.${i}`,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
expect(postCount()).toBe(5);
|
|
302
|
+
expect(refusedFor("rate_limited")).toBe(1);
|
|
303
|
+
|
|
304
|
+
await click(get, { "x-vercel-forwarded-for": "198.51.100.2" });
|
|
305
|
+
expect(postCount()).toBe(6);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("off Vercel, reads no address header, so every visitor shares the one wide bucket", async () => {
|
|
309
|
+
const get = await loadHubRoute();
|
|
310
|
+
|
|
311
|
+
for (let i = 0; i < 51; i += 1) {
|
|
312
|
+
await click(get, {
|
|
313
|
+
"x-vercel-forwarded-for": `198.51.100.${i}`,
|
|
314
|
+
"x-forwarded-for": `203.0.113.${i}`,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
expect(postCount()).toBe(50);
|
|
319
|
+
expect(refusedFor("rate_limited")).toBe(1);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("charges a visitor with no readable address to the shared bucket, never skips one", async () => {
|
|
323
|
+
env.VERCEL = "1";
|
|
324
|
+
const get = await loadHubRoute();
|
|
325
|
+
|
|
326
|
+
for (let i = 0; i < 51; i += 1) await click(get);
|
|
327
|
+
|
|
328
|
+
expect(postCount()).toBe(50);
|
|
329
|
+
expect(refusedFor("rate_limited")).toBe(1);
|
|
330
|
+
|
|
331
|
+
// An addressed visitor keeps a budget of its own.
|
|
332
|
+
await click(get, { "x-vercel-forwarded-for": "198.51.100.1" });
|
|
333
|
+
expect(postCount()).toBe(51);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("spends a start only when the API starts a Checkout", async () => {
|
|
337
|
+
env.VERCEL = "1";
|
|
338
|
+
const get = await loadHubRoute();
|
|
339
|
+
// The same address in both headers, so the unfixed keying counts it too.
|
|
340
|
+
const visitor = {
|
|
341
|
+
"x-vercel-forwarded-for": "198.51.100.1",
|
|
342
|
+
"x-forwarded-for": "198.51.100.1",
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const failures = [
|
|
346
|
+
() => json(502, { error: "upstream" }),
|
|
347
|
+
() => json(503, { error: "unavailable" }),
|
|
348
|
+
() => json(500, { error: "internal" }),
|
|
349
|
+
() => Promise.reject(new TypeError("fetch failed")),
|
|
350
|
+
() => Promise.reject(new TypeError("fetch failed")),
|
|
351
|
+
];
|
|
352
|
+
for (const failure of failures) {
|
|
353
|
+
sessionAnswer = failure;
|
|
354
|
+
await click(get, visitor);
|
|
355
|
+
}
|
|
356
|
+
expect(postCount()).toBe(5);
|
|
357
|
+
expect(refusedFor("rate_limited")).toBe(0);
|
|
358
|
+
|
|
359
|
+
sessionAnswer = started;
|
|
360
|
+
for (let i = 0; i < 5; i += 1) {
|
|
361
|
+
const res = await click(get, visitor);
|
|
362
|
+
expect(res.headers.get("location")).toBe(
|
|
363
|
+
"https://checkout.stripe.com/c/pay/cs_test_a1",
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
expect(postCount()).toBe(10);
|
|
367
|
+
|
|
368
|
+
const sixth = await click(get, visitor);
|
|
369
|
+
expect(sixth.headers.get("location")).toBe("/pricing");
|
|
370
|
+
expect(postCount()).toBe(10);
|
|
371
|
+
expect(refusedFor("rate_limited")).toBe(1);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("holds a start in flight against the budget, so concurrent clicks cannot overrun it", async () => {
|
|
375
|
+
env.VERCEL = "1";
|
|
376
|
+
const get = await loadHubRoute();
|
|
377
|
+
// The same address in both headers, so the unfixed keying counts it too.
|
|
378
|
+
const visitor = {
|
|
379
|
+
"x-vercel-forwarded-for": "198.51.100.1",
|
|
380
|
+
"x-forwarded-for": "198.51.100.1",
|
|
381
|
+
};
|
|
382
|
+
const answers: Array<(res: Response) => void> = [];
|
|
383
|
+
sessionAnswer = () =>
|
|
384
|
+
new Promise<Response>((resolve) => {
|
|
385
|
+
answers.push(resolve);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
const clicks = Array.from({ length: 6 }, () => click(get, visitor));
|
|
389
|
+
// The sixth is refused while the other five still wait on the API.
|
|
390
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
391
|
+
expect(answers).toHaveLength(5);
|
|
392
|
+
expect(refusedFor("rate_limited")).toBe(1);
|
|
393
|
+
|
|
394
|
+
for (const answer of answers) {
|
|
395
|
+
answer(
|
|
396
|
+
new Response(JSON.stringify({ error: "upstream" }), {
|
|
397
|
+
status: 502,
|
|
398
|
+
headers: { "content-type": "application/json" },
|
|
399
|
+
}),
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
await Promise.all(clicks);
|
|
403
|
+
|
|
404
|
+
// Five failed starts spent nothing, so the visitor can start again.
|
|
405
|
+
sessionAnswer = started;
|
|
406
|
+
await click(get, visitor);
|
|
407
|
+
expect(postCount()).toBe(6);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("a start in flight across the window's end settles on the window it holds", async () => {
|
|
411
|
+
env.VERCEL = "1";
|
|
412
|
+
const get = await loadHubRoute();
|
|
413
|
+
const visitor = { "x-vercel-forwarded-for": "198.51.100.1" };
|
|
414
|
+
const answers: Array<(res: Response) => void> = [];
|
|
415
|
+
sessionAnswer = () =>
|
|
416
|
+
new Promise<Response>((resolve) => {
|
|
417
|
+
answers.push(resolve);
|
|
418
|
+
});
|
|
419
|
+
const now = jest.spyOn(Date, "now");
|
|
420
|
+
try {
|
|
421
|
+
now.mockReturnValue(1_000_000);
|
|
422
|
+
const first = click(get, visitor);
|
|
423
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
424
|
+
now.mockReturnValue(1_000_000 + 10 * 60_000);
|
|
425
|
+
const second = click(get, visitor);
|
|
426
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
427
|
+
expect(answers).toHaveLength(2);
|
|
428
|
+
|
|
429
|
+
for (const answer of answers) {
|
|
430
|
+
answer(
|
|
431
|
+
new Response(JSON.stringify({ error: "upstream" }), {
|
|
432
|
+
status: 502,
|
|
433
|
+
headers: { "content-type": "application/json" },
|
|
434
|
+
}),
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
await Promise.all([first, second]);
|
|
438
|
+
|
|
439
|
+
// Both failed, so nothing is held: the window admits its full five.
|
|
440
|
+
sessionAnswer = started;
|
|
441
|
+
for (let i = 0; i < 6; i += 1) await click(get, visitor);
|
|
442
|
+
expect(postCount()).toBe(7);
|
|
443
|
+
expect(refusedFor("rate_limited")).toBe(1);
|
|
444
|
+
} finally {
|
|
445
|
+
now.mockRestore();
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
});
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
// Only a buyer's page load starts anything: a prefetch, a crawler, a
|
|
7
7
|
// cross-site load or an ungrammatical key is refused before any call. The
|
|
8
8
|
// route then confirms, from a shown-set read shared for 15 seconds, that the
|
|
9
|
-
// site is connected and shows the key —
|
|
9
|
+
// site is connected and shows the key — and, for a bundle link, the required
|
|
10
|
+
// product's key it carries as `?with=` (#10028 D10), sent as the body's
|
|
11
|
+
// `requires` — an unconnected site's POST would take
|
|
10
12
|
// the API's env path (#8931 A9) — and POSTs `/v1/checkout/sessions` with
|
|
11
13
|
// success and cancel URLs on the site's configured origin, never the
|
|
12
14
|
// request's. Every refusal is a 303 to `/pricing` with no body and one fixed
|
|
@@ -19,6 +21,10 @@ import { isShownLookupKey } from "../../../../../lib/offerings-pricing";
|
|
|
19
21
|
import { FALLBACK_BASE_URL } from "../../../../../lib/site-brand";
|
|
20
22
|
import { TOPOLOGY } from "../../../../../lib/topology.generated";
|
|
21
23
|
import { resolveCommerceClaim } from "../../../../../src/lib/commerce-claim";
|
|
24
|
+
import {
|
|
25
|
+
clientIpFromHeaders,
|
|
26
|
+
UNKNOWN_VISITOR_FACTOR,
|
|
27
|
+
} from "../../../../../src/lib/rate-limit";
|
|
22
28
|
import { resolveBaseUrl } from "../../../../../src/lib/routes";
|
|
23
29
|
|
|
24
30
|
export const runtime = "nodejs";
|
|
@@ -166,37 +172,75 @@ function siteOrigin(): string | null {
|
|
|
166
172
|
const BUDGET_STARTS = 5;
|
|
167
173
|
const BUDGET_WINDOW_MS = 10 * 60_000;
|
|
168
174
|
const BUDGET_MAX_ADDRESSES = 10_000;
|
|
169
|
-
/** An address key is cut to this length, so the Map cannot grow by key size. */
|
|
170
|
-
const BUDGET_ADDRESS_LENGTH = 64;
|
|
171
175
|
|
|
172
|
-
|
|
176
|
+
/** One visitor's window: the starts it made, and the ones still in flight. */
|
|
177
|
+
interface StartWindow {
|
|
178
|
+
windowStart: number;
|
|
179
|
+
count: number;
|
|
180
|
+
pending: number;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const starts = new Map<string, StartWindow>();
|
|
184
|
+
/** The address-less visitors' one window, which no address key can evict. */
|
|
185
|
+
const unknownStarts: StartWindow = { windowStart: 0, count: 0, pending: 0 };
|
|
173
186
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
187
|
+
/** Starts `window` afresh once its time is up, in place, so a start in flight settles on it. */
|
|
188
|
+
function rollOver(window: StartWindow, now: number): void {
|
|
189
|
+
if (now - window.windowStart < BUDGET_WINDOW_MS) return;
|
|
190
|
+
window.windowStart = now;
|
|
191
|
+
window.count = 0;
|
|
179
192
|
}
|
|
180
193
|
|
|
181
194
|
/**
|
|
182
|
-
*
|
|
183
|
-
*
|
|
195
|
+
* The window for `address`, re-inserted when it rolls over so the
|
|
196
|
+
* oldest-inserted window evicted at BUDGET_MAX_ADDRESSES is the stalest.
|
|
184
197
|
*/
|
|
185
|
-
function
|
|
186
|
-
const
|
|
187
|
-
if (
|
|
188
|
-
if (
|
|
189
|
-
|
|
190
|
-
|
|
198
|
+
function addressWindow(address: string, now: number): StartWindow {
|
|
199
|
+
const window = starts.get(address);
|
|
200
|
+
if (window) {
|
|
201
|
+
if (now - window.windowStart >= BUDGET_WINDOW_MS) {
|
|
202
|
+
rollOver(window, now);
|
|
203
|
+
starts.delete(address);
|
|
204
|
+
starts.set(address, window);
|
|
205
|
+
}
|
|
206
|
+
return window;
|
|
191
207
|
}
|
|
192
|
-
if (
|
|
193
|
-
starts.delete(address);
|
|
194
|
-
} else if (starts.size >= BUDGET_MAX_ADDRESSES) {
|
|
208
|
+
if (starts.size >= BUDGET_MAX_ADDRESSES) {
|
|
195
209
|
const oldest = starts.keys().next().value;
|
|
196
210
|
if (oldest !== undefined) starts.delete(oldest);
|
|
197
211
|
}
|
|
198
|
-
|
|
199
|
-
|
|
212
|
+
const fresh = { windowStart: now, count: 0, pending: 0 };
|
|
213
|
+
starts.set(address, fresh);
|
|
214
|
+
return fresh;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Reserves one start for the visitor, or answers null when its window is
|
|
219
|
+
* spent (#9059). The key is `clientIpFromHeaders`'s (#9967 D1): Vercel's own
|
|
220
|
+
* `x-vercel-forwarded-for`, read only on Vercel, never a header the client
|
|
221
|
+
* chooses. A visitor with no readable address shares one window
|
|
222
|
+
* UNKNOWN_VISITOR_FACTOR times as wide, never skipped. Starts in flight hold
|
|
223
|
+
* their place, so concurrent clicks cannot overrun the window.
|
|
224
|
+
*/
|
|
225
|
+
function reserveStart(headers: Headers, now: number): StartWindow | null {
|
|
226
|
+
const address = clientIpFromHeaders(headers);
|
|
227
|
+
let window = unknownStarts;
|
|
228
|
+
let limit = UNKNOWN_VISITOR_FACTOR * BUDGET_STARTS;
|
|
229
|
+
if (address === "unknown") {
|
|
230
|
+
rollOver(window, now);
|
|
231
|
+
} else {
|
|
232
|
+
window = addressWindow(address, now);
|
|
233
|
+
limit = BUDGET_STARTS;
|
|
234
|
+
}
|
|
235
|
+
if (window.count + window.pending >= limit) return null;
|
|
236
|
+
window.pending += 1;
|
|
237
|
+
return window;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Only a Checkout the API started spends the start (#9063). */
|
|
241
|
+
function settleStart(window: StartWindow, started: boolean): void {
|
|
242
|
+
window.pending -= 1;
|
|
243
|
+
if (started) window.count += 1;
|
|
200
244
|
}
|
|
201
245
|
|
|
202
246
|
// ─── D2 steps 8 and 9 — the start and its answer ────────────────────────────
|
|
@@ -263,9 +307,12 @@ async function startCheckout(
|
|
|
263
307
|
ctx: BuyContext,
|
|
264
308
|
): Promise<Response> {
|
|
265
309
|
const { lookupKey } = await ctx.params;
|
|
310
|
+
// #10028 D10 — a bundle link names the required product's key as `?with=`.
|
|
311
|
+
const withKey = new URL(request.url).searchParams.get("with");
|
|
266
312
|
|
|
267
313
|
if (!isBuyerNavigation(request)) return refuse("unintended");
|
|
268
314
|
if (!parseLookupKey(lookupKey)) return refuse("ungrammatical");
|
|
315
|
+
if (withKey !== null && !parseLookupKey(withKey)) return refuse("ungrammatical");
|
|
269
316
|
if (!(await resolveCommerceClaim())) return refuse("no_claim");
|
|
270
317
|
if (process.env.NODE_ENV === "production" && !configuredApiBase()) {
|
|
271
318
|
return refuse("unconnected");
|
|
@@ -276,29 +323,33 @@ async function startCheckout(
|
|
|
276
323
|
if (shown.kind !== "shown" || !isShownLookupKey(shown.shownSet, lookupKey)) {
|
|
277
324
|
return refuse("not_shown");
|
|
278
325
|
}
|
|
326
|
+
if (withKey !== null && !isShownLookupKey(shown.shownSet, withKey)) {
|
|
327
|
+
return refuse("not_shown");
|
|
328
|
+
}
|
|
279
329
|
|
|
280
330
|
const origin = siteOrigin();
|
|
281
331
|
if (!origin) return refuse("no_origin");
|
|
282
332
|
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
333
|
+
const headers = startHeaders(request.headers);
|
|
334
|
+
const reserved = reserveStart(request.headers, Date.now());
|
|
335
|
+
if (!reserved) return refuse("rate_limited");
|
|
336
|
+
|
|
337
|
+
// Settled on the API's own answer, not the timeout: a start that answers
|
|
338
|
+
// late still spends, and one in flight still holds its place.
|
|
339
|
+
const start = checkout
|
|
340
|
+
.createSession(
|
|
341
|
+
{
|
|
342
|
+
productCode: lookupKey,
|
|
343
|
+
...(withKey !== null && { requires: withKey }),
|
|
344
|
+
successUrl: `${origin}/purchase/success?session_id={CHECKOUT_SESSION_ID}`,
|
|
345
|
+
cancelUrl: `${origin}/pricing`,
|
|
346
|
+
},
|
|
347
|
+
{ headers },
|
|
348
|
+
)
|
|
349
|
+
.catch(() => null);
|
|
350
|
+
void start.then((answer) => settleStart(reserved, answer?.ok === true));
|
|
351
|
+
|
|
352
|
+
const started = await settleWithin(start, START_TIMEOUT_MS, null);
|
|
302
353
|
if (!started) return refuse("unreachable");
|
|
303
354
|
if (!started.ok) return refuse(`api_${started.status}`);
|
|
304
355
|
|
|
@@ -29,6 +29,7 @@ jest.mock("@working-theory/license", () => ({
|
|
|
29
29
|
checkLicense: jest.fn(),
|
|
30
30
|
}));
|
|
31
31
|
|
|
32
|
+
import { TOPOLOGY } from "../../../../../lib/topology.generated";
|
|
32
33
|
import { GET } from "../[lookupKey]/route";
|
|
33
34
|
|
|
34
35
|
const mockCheckLicense = checkLicense as jest.MockedFunction<
|
|
@@ -209,6 +210,7 @@ const ENV_NAMES = [
|
|
|
209
210
|
"NODE_ENV",
|
|
210
211
|
"OSS_API_INTERNAL_SECRET",
|
|
211
212
|
"AILK_SPOKE_KEY",
|
|
213
|
+
"VERCEL",
|
|
212
214
|
] as const;
|
|
213
215
|
const savedEnv = Object.fromEntries(ENV_NAMES.map((name) => [name, env[name]]));
|
|
214
216
|
|
|
@@ -248,6 +250,7 @@ beforeEach(() => {
|
|
|
248
250
|
env.AILK_API_URL = API;
|
|
249
251
|
env.NEXT_PUBLIC_BASE_URL = "https://shop.example.com";
|
|
250
252
|
env.NODE_ENV = "test";
|
|
253
|
+
env.VERCEL = "1";
|
|
251
254
|
shownAnswer = SHOWN;
|
|
252
255
|
postAnswer = STARTED;
|
|
253
256
|
fetchMock.mockClear();
|
|
@@ -267,8 +270,9 @@ afterEach(() => {
|
|
|
267
270
|
|
|
268
271
|
/**
|
|
269
272
|
* A buyer's card click on `key`: a top-level same-origin navigation to the
|
|
270
|
-
* wrong host, with a session cookie and
|
|
271
|
-
* set to `null` is
|
|
273
|
+
* wrong host, with a session cookie and fresh forwarded addresses, Vercel's
|
|
274
|
+
* included, so no two cases share a visitor budget. A header set to `null` is
|
|
275
|
+
* left out.
|
|
272
276
|
*/
|
|
273
277
|
function buyRequest(
|
|
274
278
|
key: string,
|
|
@@ -281,6 +285,7 @@ function buyRequest(
|
|
|
281
285
|
"sec-fetch-dest": "document",
|
|
282
286
|
"sec-fetch-site": "same-origin",
|
|
283
287
|
"x-forwarded-for": freshAddress(),
|
|
288
|
+
"x-vercel-forwarded-for": freshAddress(),
|
|
284
289
|
...options.headers,
|
|
285
290
|
};
|
|
286
291
|
const sent = new Headers();
|
|
@@ -411,11 +416,18 @@ describe("AC-1 — a shown key on a connected site starts its Checkout", () => {
|
|
|
411
416
|
);
|
|
412
417
|
});
|
|
413
418
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
+
// #9601 — hub-only: a spoke render of the seam withholds the secret even
|
|
420
|
+
// with no spoke key, so a spoke scaffold skips this case. The sibling
|
|
421
|
+
// suite's "a spoke with no spoke key never sends the internal secret"
|
|
422
|
+
// pins the spoke side.
|
|
423
|
+
(TOPOLOGY === "hub" ? it : it.skip)(
|
|
424
|
+
"sends x-internal-auth from OSS_API_INTERNAL_SECRET to an https API",
|
|
425
|
+
async () => {
|
|
426
|
+
env.OSS_API_INTERNAL_SECRET = "s3cret";
|
|
427
|
+
await expectStarted(await buy("growth_monthly"));
|
|
428
|
+
expect(postHeaders(postCalls()[0]!)["x-internal-auth"]).toBe("s3cret");
|
|
429
|
+
},
|
|
430
|
+
);
|
|
419
431
|
|
|
420
432
|
it("sends no x-internal-auth when OSS_API_INTERNAL_SECRET is unset", async () => {
|
|
421
433
|
await expectStarted(await buy("growth_monthly"));
|
|
@@ -649,6 +661,46 @@ describe("AC-4 — every other outcome returns to /pricing, without throwing and
|
|
|
649
661
|
|
|
650
662
|
// ─── AC-5 ───────────────────────────────────────────────────────────────────
|
|
651
663
|
|
|
664
|
+
// ── #10028 AC-10 — the bundle link's `?with=` ───────────────────────────────
|
|
665
|
+
|
|
666
|
+
describe("#10028 AC-10 — a shown ?with= key is forwarded as requires; an ungrammatical or unshown one is refused", () => {
|
|
667
|
+
/** A buyer's click on a bundle link: the plan key with `?with=<required>` in the URL. */
|
|
668
|
+
function bundleRequest(key: string, withKey: string): Request {
|
|
669
|
+
const base = buyRequest(key);
|
|
670
|
+
return new Request(`${base.url}?with=${withKey}`, { method: "GET", headers: base.headers });
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
it("sends the shown ?with= key as the body's requires, beside the plan key", async () => {
|
|
674
|
+
const res = await buy("growth_monthly", bundleRequest("growth_monthly", "growth_yearly"));
|
|
675
|
+
await expectStarted(res);
|
|
676
|
+
const [call] = postCalls();
|
|
677
|
+
expect(JSON.parse(String(call!.init.body))).toEqual({
|
|
678
|
+
productCode: "growth_monthly",
|
|
679
|
+
requires: "growth_yearly",
|
|
680
|
+
successUrl: "https://shop.example.com/purchase/success?session_id={CHECKOUT_SESSION_ID}",
|
|
681
|
+
cancelUrl: "https://shop.example.com/pricing",
|
|
682
|
+
});
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it("builds exactly today's body with no ?with=", async () => {
|
|
686
|
+
await expectStarted(await buy("growth_monthly"));
|
|
687
|
+
const [call] = postCalls();
|
|
688
|
+
expect(JSON.parse(String(call!.init.body))).not.toHaveProperty("requires");
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it("refuses an ungrammatical ?with= before any call", async () => {
|
|
692
|
+
const res = await buy("growth_monthly", bundleRequest("growth_monthly", "Growth%20Yearly"));
|
|
693
|
+
await expectRefused(res, "Growth Yearly", "ungrammatical");
|
|
694
|
+
expect(postCalls()).toHaveLength(0);
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it("refuses a grammatical ?with= the shown set does not hold, with no POST", async () => {
|
|
698
|
+
const res = await buy("growth_monthly", bundleRequest("growth_monthly", "managed_monthly"));
|
|
699
|
+
await expectRefused(res, "managed_monthly", "not_shown");
|
|
700
|
+
expect(postCalls()).toHaveLength(0);
|
|
701
|
+
});
|
|
702
|
+
});
|
|
703
|
+
|
|
652
704
|
describe("AC-5 — only a buyer's page load starts anything", () => {
|
|
653
705
|
it.each<
|
|
654
706
|
[string, { method?: string; headers?: Record<string, string | null> }]
|
|
@@ -735,7 +787,9 @@ describe("AC-6 — one shared read, a switched-off product stops within it, a pe
|
|
|
735
787
|
|
|
736
788
|
it("starts at most 5 Checkouts per visitor per 10 minutes", async () => {
|
|
737
789
|
const from = (address: string) =>
|
|
738
|
-
buyRequest("growth_monthly", {
|
|
790
|
+
buyRequest("growth_monthly", {
|
|
791
|
+
headers: { "x-vercel-forwarded-for": address },
|
|
792
|
+
});
|
|
739
793
|
|
|
740
794
|
for (let i = 0; i < 5; i += 1) {
|
|
741
795
|
await expectStarted(await buy("growth_monthly", from("192.0.2.50")));
|
|
@@ -757,43 +811,27 @@ describe("AC-6 — one shared read, a switched-off product stops within it, a pe
|
|
|
757
811
|
expect(postCalls()).toHaveLength(7);
|
|
758
812
|
});
|
|
759
813
|
|
|
760
|
-
it("
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
),
|
|
770
|
-
);
|
|
814
|
+
it("charges a request with no visitor address to one shared budget, ten times as wide", async () => {
|
|
815
|
+
jest.setSystemTime(Date.now() + 10 * 60_000);
|
|
816
|
+
const addressless = () =>
|
|
817
|
+
buyRequest("growth_monthly", {
|
|
818
|
+
headers: { "x-forwarded-for": null, "x-vercel-forwarded-for": null },
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
for (let i = 0; i < 50; i += 1) {
|
|
822
|
+
await expectStarted(await buy("growth_monthly", addressless()));
|
|
771
823
|
}
|
|
824
|
+
expect(postCalls()).toHaveLength(50);
|
|
825
|
+
|
|
772
826
|
await expectRefused(
|
|
773
|
-
await buy(
|
|
774
|
-
"growth_monthly",
|
|
775
|
-
buyRequest("growth_monthly", {
|
|
776
|
-
headers: { "x-forwarded-for": `${prefix}x` },
|
|
777
|
-
}),
|
|
778
|
-
),
|
|
827
|
+
await buy("growth_monthly", addressless()),
|
|
779
828
|
"growth_monthly",
|
|
780
829
|
"rate_limited",
|
|
781
830
|
);
|
|
782
|
-
expect(postCalls()).toHaveLength(
|
|
783
|
-
});
|
|
831
|
+
expect(postCalls()).toHaveLength(50);
|
|
784
832
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
await expectStarted(
|
|
788
|
-
await buy(
|
|
789
|
-
"growth_monthly",
|
|
790
|
-
buyRequest("growth_monthly", {
|
|
791
|
-
headers: { "x-forwarded-for": null },
|
|
792
|
-
}),
|
|
793
|
-
),
|
|
794
|
-
);
|
|
795
|
-
}
|
|
796
|
-
expect(postCalls()).toHaveLength(7);
|
|
833
|
+
await expectStarted(await buy("growth_monthly"));
|
|
834
|
+
expect(postCalls()).toHaveLength(51);
|
|
797
835
|
});
|
|
798
836
|
|
|
799
837
|
it("reaches neither Stripe nor the session cookie itself", () => {
|