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
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* ?response_type=code&client_id=…&redirect_uri=…&state=…
|
|
13
13
|
* &code_challenge=…&code_challenge_method=S256
|
|
14
14
|
* [&login_hint=<email>] (optional, #8757 D9 — see below)
|
|
15
|
+
* [&prompt=create] (optional, #9912 D3 — see below)
|
|
15
16
|
*
|
|
16
17
|
* - a malformed query is a 400 and NEVER a redirect;
|
|
17
18
|
* - no Better Auth session: the six validated keys are sealed into a short
|
|
@@ -27,6 +28,11 @@
|
|
|
27
28
|
* the REGISTERED redirect URI the API echoes back, with `code` and the
|
|
28
29
|
* same `state` — never to the query's own `redirect_uri` until the API has
|
|
29
30
|
* confirmed it is the client's;
|
|
31
|
+
* - a person who has not said yes to this spoke (403 `consent_required`,
|
|
32
|
+
* #9966 D2) goes to the hub's own consent page, `/account/connect?ref=…`,
|
|
33
|
+
* with the six keys sealed as the no-session branch seals them. The GET
|
|
34
|
+
* never sends `consent`: a spoke can send a visitor here on page load, so
|
|
35
|
+
* a GET must never be the yes;
|
|
30
36
|
* - a person whose email is not verified (403 `email_unverified`) goes to the
|
|
31
37
|
* hub's own `/account/settings`, whose For-you item says how to verify —
|
|
32
38
|
* never to the spoke;
|
|
@@ -41,6 +47,24 @@
|
|
|
41
47
|
* route rebuilds the query from the ref, so the hint is gone after sign-in —
|
|
42
48
|
* and with a session it is ignored.
|
|
43
49
|
*
|
|
50
|
+
* `prompt=create` (#9912 D3) is the spoke's "Sign up": read beside
|
|
51
|
+
* `login_hint` by the same once-only rule (repeated → 400, before the session
|
|
52
|
+
* read). On the no-session branch exactly `create` sends the visitor to
|
|
53
|
+
* `/sign-up?next=<the same continue path>` instead of `/sign-in`, with no
|
|
54
|
+
* `email`; any other value does nothing. It never enters the ref — so a
|
|
55
|
+
* password sign-up, which ends with no session, comes back through the
|
|
56
|
+
* continuation to `/sign-in` — nor a code or a cookie, and with a session it
|
|
57
|
+
* is ignored: the person has an account, and the spoke signs them in.
|
|
58
|
+
*
|
|
59
|
+
* POST /api/auth/hub/authorize (form field `ref`, #9966 D2)
|
|
60
|
+
*
|
|
61
|
+
* - the consent page's Continue. Accepted only with `Sec-Fetch-Site:
|
|
62
|
+
* same-origin` and an `Origin` equal to the pinned origin (a missing or
|
|
63
|
+
* `null` Origin is refused), before any session read; the ref is opened
|
|
64
|
+
* and its query re-validated, and the session branch runs as GET's does,
|
|
65
|
+
* with `consent: true` — and `x-internal-auth` where the API requires it —
|
|
66
|
+
* on the code request. This is the one call site that sends consent.
|
|
67
|
+
*
|
|
44
68
|
* `BETTER_AUTH_URL` pins the `Origin` sent with the cookie-authenticated write
|
|
45
69
|
* (Node's `fetch` sets none). Unset, the step answers 503: it needs a pinned
|
|
46
70
|
* origin, exactly as the emailed link does.
|
|
@@ -54,13 +78,17 @@ import { hubSignIn } from "@working-theory/api-client";
|
|
|
54
78
|
import {
|
|
55
79
|
accountEmailHintSchema,
|
|
56
80
|
hubAuthorizeQuerySchema,
|
|
81
|
+
type HubAuthorizeQuery,
|
|
57
82
|
} from "@working-theory/validation";
|
|
58
83
|
|
|
59
84
|
import { auth, resolveSecret } from "../../../../../auth";
|
|
85
|
+
import { browserApiOrigin } from "../../../../../lib/api-origin.generated";
|
|
86
|
+
import { TOPOLOGY } from "../../../../../lib/topology.generated";
|
|
60
87
|
import {
|
|
61
88
|
CONTINUATION_REF_MAX,
|
|
62
89
|
CONTINUE_PATH,
|
|
63
90
|
mintContinuation,
|
|
91
|
+
openContinuation,
|
|
64
92
|
} from "../continuation";
|
|
65
93
|
|
|
66
94
|
/** The six query keys, each read exactly once. */
|
|
@@ -76,6 +104,11 @@ const QUERY_KEYS = [
|
|
|
76
104
|
/** Where an unverified person is sent to verify: the hub's own account page. */
|
|
77
105
|
const VERIFY_EMAIL_PATH = "/account/settings";
|
|
78
106
|
|
|
107
|
+
/** The hub's consent page, where a person says yes to one spoke (#9966 D2). */
|
|
108
|
+
const CONSENT_PATH = "/account/connect";
|
|
109
|
+
|
|
110
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1"]);
|
|
111
|
+
|
|
79
112
|
function json(status: number, error: string): Response {
|
|
80
113
|
return new Response(JSON.stringify({ error }), {
|
|
81
114
|
status,
|
|
@@ -110,38 +143,50 @@ function pinnedOrigin(): string | null {
|
|
|
110
143
|
}
|
|
111
144
|
}
|
|
112
145
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
raw[key] = values[0];
|
|
123
|
-
}
|
|
124
|
-
// #8757 D9 — the optional hint: the same once-only rule; refused → dropped.
|
|
125
|
-
const hints = url.searchParams.getAll("login_hint");
|
|
126
|
-
if (hints.length > 1) return json(400, "invalid_request");
|
|
127
|
-
const parsed = hubAuthorizeQuerySchema.safeParse(raw);
|
|
128
|
-
if (!parsed.success) return json(400, "invalid_request");
|
|
129
|
-
const query = parsed.data;
|
|
130
|
-
const hint = accountEmailHintSchema.safeParse(hints[0]);
|
|
146
|
+
/** The six validated keys as a sealed continuation ref, or `null` past the cap. */
|
|
147
|
+
function sealQuery(query: HubAuthorizeQuery): string | null {
|
|
148
|
+
// Only the six validated keys, in QUERY_KEYS order: a ref carries nothing
|
|
149
|
+
// that did not pass the schema, and extra or reordered keys are dropped.
|
|
150
|
+
const canonical = new URLSearchParams();
|
|
151
|
+
for (const key of QUERY_KEYS) canonical.set(key, query[key]);
|
|
152
|
+
const ref = mintContinuation(canonical.toString(), resolveSecret());
|
|
153
|
+
return ref.length > CONTINUATION_REF_MAX ? null : ref;
|
|
154
|
+
}
|
|
131
155
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
156
|
+
/**
|
|
157
|
+
* `x-internal-auth` for the consent-bearing code request, under the Offerings
|
|
158
|
+
* buy route's `startHeaders` rule: only from a site that is not a spoke, and
|
|
159
|
+
* only to an `https:` or loopback API, so the secret never crosses a plain
|
|
160
|
+
* network hop. The API honours `consent: true` only beside it where it is set.
|
|
161
|
+
*/
|
|
162
|
+
function internalAuthHeader(): Record<string, string> {
|
|
163
|
+
const secret = process.env.OSS_API_INTERNAL_SECRET;
|
|
164
|
+
const spoke = TOPOLOGY === "spoke" || !!process.env.AILK_SPOKE_KEY?.trim();
|
|
165
|
+
if (!secret || spoke) return {};
|
|
166
|
+
try {
|
|
167
|
+
const api = new URL(
|
|
168
|
+
process.env.AILK_API_URL ||
|
|
169
|
+
browserApiOrigin() ||
|
|
170
|
+
process.env.NEXT_PUBLIC_API_URL ||
|
|
171
|
+
"http://localhost:3001",
|
|
172
|
+
);
|
|
173
|
+
const safe = api.protocol === "https:" || LOOPBACK_HOSTS.has(api.hostname);
|
|
174
|
+
return safe ? { "x-internal-auth": secret } : {};
|
|
175
|
+
} catch {
|
|
176
|
+
return {};
|
|
143
177
|
}
|
|
178
|
+
}
|
|
144
179
|
|
|
180
|
+
/**
|
|
181
|
+
* The session branch, shared by GET and the consent POST: ask the API for a
|
|
182
|
+
* code and answer with where the visitor goes next. Only the POST passes
|
|
183
|
+
* `consent` (#9966 D2).
|
|
184
|
+
*/
|
|
185
|
+
async function codeRedirect(
|
|
186
|
+
request: Request,
|
|
187
|
+
query: HubAuthorizeQuery,
|
|
188
|
+
consent: boolean,
|
|
189
|
+
): Promise<Response> {
|
|
145
190
|
const origin = pinnedOrigin();
|
|
146
191
|
if (origin === null) return json(503, "temporarily_unavailable");
|
|
147
192
|
|
|
@@ -152,11 +197,13 @@ export async function GET(request: Request): Promise<Response> {
|
|
|
152
197
|
clientId: query.client_id,
|
|
153
198
|
redirectUri: query.redirect_uri,
|
|
154
199
|
codeChallenge: query.code_challenge,
|
|
200
|
+
...(consent ? { consent: true as const } : {}),
|
|
155
201
|
},
|
|
156
202
|
{
|
|
157
203
|
headers: {
|
|
158
204
|
cookie: request.headers.get("cookie") ?? "",
|
|
159
205
|
origin,
|
|
206
|
+
...(consent ? internalAuthHeader() : {}),
|
|
160
207
|
},
|
|
161
208
|
},
|
|
162
209
|
);
|
|
@@ -184,5 +231,90 @@ export async function GET(request: Request): Promise<Response> {
|
|
|
184
231
|
if (result.status === 403 && code === "email_unverified") {
|
|
185
232
|
return see(VERIFY_EMAIL_PATH);
|
|
186
233
|
}
|
|
234
|
+
// Not yet said yes to this spoke: ask, on the hub's own page. The POST that
|
|
235
|
+
// page sends already carries consent, so it never comes back here.
|
|
236
|
+
if (!consent && result.status === 403 && code === "consent_required") {
|
|
237
|
+
const ref = sealQuery(query);
|
|
238
|
+
if (ref === null) return json(400, "invalid_request");
|
|
239
|
+
return see(`${CONSENT_PATH}?${new URLSearchParams({ ref }).toString()}`);
|
|
240
|
+
}
|
|
187
241
|
return json(400, "invalid_request");
|
|
188
242
|
}
|
|
243
|
+
|
|
244
|
+
// platform-decisions-waiver: api-versioning — sign-in redirect endpoint, not a versioned API contract (spec 7894 §7)
|
|
245
|
+
export async function GET(request: Request): Promise<Response> {
|
|
246
|
+
const url = new URL(request.url);
|
|
247
|
+
|
|
248
|
+
// Each declared key exactly once: a repeated key is ambiguous, so 400.
|
|
249
|
+
const raw: Record<string, string | undefined> = {};
|
|
250
|
+
for (const key of QUERY_KEYS) {
|
|
251
|
+
const values = url.searchParams.getAll(key);
|
|
252
|
+
if (values.length > 1) return json(400, "invalid_request");
|
|
253
|
+
raw[key] = values[0];
|
|
254
|
+
}
|
|
255
|
+
// #8757 D9 — the optional hint: the same once-only rule; refused → dropped.
|
|
256
|
+
const hints = url.searchParams.getAll("login_hint");
|
|
257
|
+
if (hints.length > 1) return json(400, "invalid_request");
|
|
258
|
+
// #9912 D3 — the sign-up hint, by the same rule; only `create` acts.
|
|
259
|
+
const prompts = url.searchParams.getAll("prompt");
|
|
260
|
+
if (prompts.length > 1) return json(400, "invalid_request");
|
|
261
|
+
const parsed = hubAuthorizeQuerySchema.safeParse(raw);
|
|
262
|
+
if (!parsed.success) return json(400, "invalid_request");
|
|
263
|
+
const query = parsed.data;
|
|
264
|
+
const hint = accountEmailHintSchema.safeParse(hints[0]);
|
|
265
|
+
|
|
266
|
+
const session = await auth.api.getSession({ headers: request.headers });
|
|
267
|
+
if (!session) {
|
|
268
|
+
const ref = sealQuery(query);
|
|
269
|
+
if (ref === null) return json(400, "invalid_request");
|
|
270
|
+
const signIn = new URLSearchParams({ next: `${CONTINUE_PATH}/${ref}` });
|
|
271
|
+
if (prompts[0] === "create") return see(`/sign-up?${signIn.toString()}`);
|
|
272
|
+
if (hint.success) signIn.set("email", hint.data);
|
|
273
|
+
return see(`/sign-in?${signIn.toString()}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return codeRedirect(request, query, false);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The consent page's Continue (#9966 D2): the one request that says yes to a
|
|
281
|
+
* spoke. Same-origin only, decided before any session read, so neither a
|
|
282
|
+
* cross-site form nor a sandboxed frame (`Origin: null`) can post it.
|
|
283
|
+
*/
|
|
284
|
+
export async function POST(request: Request): Promise<Response> {
|
|
285
|
+
const origin = pinnedOrigin();
|
|
286
|
+
if (origin === null) return json(503, "temporarily_unavailable");
|
|
287
|
+
const sent = request.headers.get("origin");
|
|
288
|
+
if (
|
|
289
|
+
request.headers.get("sec-fetch-site") !== "same-origin" ||
|
|
290
|
+
sent === null ||
|
|
291
|
+
sent !== origin
|
|
292
|
+
) {
|
|
293
|
+
return json(403, "invalid_request");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
let refs: FormDataEntryValue[];
|
|
297
|
+
try {
|
|
298
|
+
refs = (await request.formData()).getAll("ref");
|
|
299
|
+
} catch {
|
|
300
|
+
return json(400, "invalid_request");
|
|
301
|
+
}
|
|
302
|
+
const [ref] = refs;
|
|
303
|
+
if (refs.length !== 1 || typeof ref !== "string") {
|
|
304
|
+
return json(400, "invalid_request");
|
|
305
|
+
}
|
|
306
|
+
const search = openContinuation(ref, resolveSecret());
|
|
307
|
+
if (search === null) return json(400, "invalid_request");
|
|
308
|
+
const params = new URLSearchParams(search);
|
|
309
|
+
const raw: Record<string, string | undefined> = {};
|
|
310
|
+
for (const key of QUERY_KEYS) raw[key] = params.get(key) ?? undefined;
|
|
311
|
+
const parsed = hubAuthorizeQuerySchema.safeParse(raw);
|
|
312
|
+
if (!parsed.success) return json(400, "invalid_request");
|
|
313
|
+
|
|
314
|
+
const session = await auth.api.getSession({ headers: request.headers });
|
|
315
|
+
if (!session) {
|
|
316
|
+
const signIn = new URLSearchParams({ next: `${CONTINUE_PATH}/${ref}` });
|
|
317
|
+
return see(`/sign-in?${signIn.toString()}`);
|
|
318
|
+
}
|
|
319
|
+
return codeRedirect(request, parsed.data, true);
|
|
320
|
+
}
|
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
*
|
|
17
17
|
* #8757 D9 (AC-6): a valid single `?email=` on the sign-in route becomes the
|
|
18
18
|
* authorize URL's `login_hint`; the sealed flow cookie never carries it.
|
|
19
|
+
*
|
|
20
|
+
* #9912 D2 (AC-1): exactly one `?prompt=create` rides to the hub as
|
|
21
|
+
* `prompt=create`, so the hub sends a visitor with no session to /sign-up;
|
|
22
|
+
* any other value adds nothing, and the flow cookie's keys are unchanged.
|
|
19
23
|
*/
|
|
20
24
|
|
|
21
25
|
import { createHash } from "node:crypto";
|
|
@@ -204,6 +208,53 @@ describe("GET /api/hub-session/sign-in — the hinted email (#8757 D9)", () => {
|
|
|
204
208
|
});
|
|
205
209
|
});
|
|
206
210
|
|
|
211
|
+
describe("GET /api/hub-session/sign-in — the sign-up hint (#9912 D2)", () => {
|
|
212
|
+
function startWith(query: string): { authorize: URL; payload: Record<string, unknown> } {
|
|
213
|
+
const res = signIn(new Request(`${SITE}/api/hub-session/sign-in?${query}`));
|
|
214
|
+
expect(res.status).toBe(303);
|
|
215
|
+
const flow = cookieValue(res, "ailk-hub-flow");
|
|
216
|
+
if (flow === undefined) throw new Error("no flow cookie");
|
|
217
|
+
const payload = unseal(flow, SESSION_SECRET, "flow", "ailk-hub-flow");
|
|
218
|
+
if (payload === null || typeof payload !== "object") throw new Error("flow did not open");
|
|
219
|
+
return {
|
|
220
|
+
authorize: new URL(res.headers.get("location") ?? ""),
|
|
221
|
+
payload: payload as Record<string, unknown>,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
it("?next=/account&prompt=create → the authorize URL carries prompt=create; the flow cookie keeps exactly its five keys", () => {
|
|
226
|
+
const { authorize, payload } = startWith("next=/account&prompt=create");
|
|
227
|
+
expect(authorize.pathname).toBe("/api/auth/hub/authorize");
|
|
228
|
+
expect(authorize.searchParams.getAll("prompt")).toEqual(["create"]);
|
|
229
|
+
// The six keys are untouched beside it.
|
|
230
|
+
expect(authorize.searchParams.get("response_type")).toBe("code");
|
|
231
|
+
expect(authorize.searchParams.get("code_challenge_method")).toBe("S256");
|
|
232
|
+
expect(Object.keys(payload).sort()).toEqual(
|
|
233
|
+
["expiresAt", "next", "state", "v", "verifier"],
|
|
234
|
+
);
|
|
235
|
+
expect(payload.next).toBe("/account");
|
|
236
|
+
expect(JSON.stringify(payload)).not.toContain("create");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("forwards the hint beside a valid login_hint", () => {
|
|
240
|
+
const { authorize } = startWith("next=/account&prompt=create&email=a%40b.example");
|
|
241
|
+
expect(authorize.searchParams.get("prompt")).toBe("create");
|
|
242
|
+
expect(authorize.searchParams.get("login_hint")).toBe("a@b.example");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it.each([
|
|
246
|
+
["another OIDC value", "next=/account&prompt=login"],
|
|
247
|
+
["an empty value", "next=/account&prompt="],
|
|
248
|
+
["a repeated value", "next=/account&prompt=create&prompt=create"],
|
|
249
|
+
["a cased value", "next=/account&prompt=Create"],
|
|
250
|
+
["no value", "next=/account"],
|
|
251
|
+
])("%s → no prompt", (_label, query) => {
|
|
252
|
+
const { authorize, payload } = startWith(query);
|
|
253
|
+
expect(authorize.searchParams.has("prompt")).toBe(false);
|
|
254
|
+
expect(payload).not.toHaveProperty("prompt");
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
207
258
|
describe("GET /api/hub-session/callback", () => {
|
|
208
259
|
function exchangeOk(): void {
|
|
209
260
|
mockExchange.mockResolvedValue({
|
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
* the address typed. A refused or repeated value is dropped. The hint goes on
|
|
16
16
|
* the authorize URL only — never into the sealed flow cookie.
|
|
17
17
|
*
|
|
18
|
+
* Sign-up hint (#9912 D2): exactly one `?prompt=create` (the gate's "Sign up")
|
|
19
|
+
* rides to the hub as `prompt=create`, so a visitor with no hub session lands
|
|
20
|
+
* on its sign-up screen instead of sign-in; the round trip is otherwise the
|
|
21
|
+
* same. Any other value, an empty or a repeated one adds nothing, and the hint
|
|
22
|
+
* never enters the flow cookie.
|
|
23
|
+
*
|
|
18
24
|
* Nothing here logs the state, the verifier or the challenge.
|
|
19
25
|
*/
|
|
20
26
|
import { createHash, randomBytes } from "node:crypto";
|
|
@@ -55,6 +61,12 @@ function emailHint(params: URLSearchParams): string | undefined {
|
|
|
55
61
|
return parsed.success ? parsed.data : undefined;
|
|
56
62
|
}
|
|
57
63
|
|
|
64
|
+
/** #9912 D2 — `"create"` when the one `prompt` is exactly that, else none. */
|
|
65
|
+
function signUpHint(params: URLSearchParams): "create" | undefined {
|
|
66
|
+
const values = params.getAll("prompt");
|
|
67
|
+
return values.length === 1 && values[0] === "create" ? "create" : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
58
70
|
// platform-decisions-waiver: api-versioning — sign-in redirect endpoint, not a versioned API contract (spec 7894 §7)
|
|
59
71
|
export function GET(request: Request): Response {
|
|
60
72
|
if (SIGN_IN_PROVIDER !== "hub") return new Response(null, { status: 404 });
|
|
@@ -84,6 +96,7 @@ export function GET(request: Request): Response {
|
|
|
84
96
|
code_challenge: challenge,
|
|
85
97
|
code_challenge_method: "S256",
|
|
86
98
|
});
|
|
99
|
+
if (signUpHint(params) !== undefined) query.set("prompt", "create");
|
|
87
100
|
const loginHint = emailHint(params);
|
|
88
101
|
if (loginHint !== undefined) query.set("login_hint", loginHint);
|
|
89
102
|
authorize.search = query.toString();
|
|
@@ -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
|
+
});
|