create-ailk 0.19.0 → 0.20.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 +64 -7
- package/dist/add-module.js +7 -2
- package/dist/cli.js +35 -0
- package/dist/copy.d.ts +41 -0
- package/dist/copy.js +87 -21
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/fetch-template-snapshot.d.ts +21 -0
- package/dist/lib/fetch-template-snapshot.js +205 -0
- package/dist/lib/merge-three-way.d.ts +31 -0
- package/dist/lib/merge-three-way.js +65 -0
- package/dist/lib/scaffold-render-sweep.d.ts +23 -0
- package/dist/lib/scaffold-render-sweep.js +52 -0
- package/dist/module-architecture.js +64 -5
- package/dist/programmatic.d.ts +7 -1
- package/dist/programmatic.js +40 -3
- package/dist/site-roster.js +21 -4
- package/dist/spoke-checkout.d.ts +7 -0
- package/dist/spoke-checkout.js +8 -1
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +98 -3
- package/dist/template-record.d.ts +59 -0
- package/dist/template-record.js +183 -0
- package/dist/topology.js +29 -1
- package/dist/update.d.ts +48 -0
- package/dist/update.js +504 -0
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +9 -0
- package/templates/.claude/hooks/architecture-check.sh +8 -0
- package/templates/.claude/rules/loop-contract.md +129 -0
- package/templates/.claude/settings.json +3 -2
- package/templates/.env.example +13 -0
- package/templates/CLAUDE.md +1 -1
- package/templates/CONVENTIONS.md +3 -2
- package/templates/_gitignore +162 -0
- package/templates/apps/api/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +18 -8
- package/templates/apps/api/package.json +2 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
- package/templates/apps/api/src/__tests__/setup.ts +7 -0
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
- package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
- package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
- package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
- package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
- package/templates/apps/api/src/lib/consent-store.ts +335 -0
- package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
- package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
- package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
- package/templates/apps/api/src/lib/stripe.ts +97 -5
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +72 -14
- package/templates/apps/api/src/middleware/auth.ts +16 -4
- package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
- package/templates/apps/api/src/routes/checkout/session.ts +3 -3
- package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
- package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
- package/templates/apps/api/src/routes/consent/index.ts +18 -0
- package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
- package/templates/apps/api/src/routes/leads/index.ts +15 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
- package/templates/apps/api/src/routes/webhooks/README.md +33 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +201 -6
- package/templates/apps/api/src/server.ts +125 -18
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
- package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
- package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
- package/templates/apps/api/src/services/audit-log.ts +72 -10
- package/templates/apps/api/src/services/consent.ts +488 -3
- package/templates/apps/api/src/services/default-tenant.ts +12 -3
- package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
- package/templates/apps/api/src/services/lead-routing.ts +105 -12
- package/templates/apps/api/src/services/person.ts +501 -1
- package/templates/apps/api/src/services/project-listings.ts +7 -2
- package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
- package/templates/apps/api/src/services/request-confirm.ts +52 -0
- package/templates/apps/api/src/services/revalidation.ts +11 -1
- package/templates/apps/api/vercel.json +1 -1
- package/templates/apps/mcp/CLAUDE.md +19 -7
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
- package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
- package/templates/apps/mcp/src/tools/proposals.ts +27 -0
- package/templates/apps/mcp/src/workspace-server.ts +16 -0
- package/templates/apps/web/_gitignore +6 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +154 -53
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +124 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +56 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +15 -9
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +70 -27
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +44 -34
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
- package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
- package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
- package/templates/apps/web/app/[locale]/layout.tsx +23 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
- package/templates/apps/web/app/robots.txt/route.ts +13 -10
- package/templates/apps/web/auth.ts +20 -1
- package/templates/apps/web/jest.config.cjs +3 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
- package/templates/apps/web/lib/account-config.ts +31 -16
- package/templates/apps/web/lib/auth-email.ts +45 -10
- package/templates/apps/web/lib/consent-visitor.ts +103 -0
- package/templates/apps/web/lib/relay.ts +20 -0
- package/templates/apps/web/lib/topology.generated.ts +9 -0
- package/templates/apps/web/messages/en.json +121 -18
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/database/CHANGELOG.md +55 -0
- package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
- package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
- package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
- package/templates/database/__tests__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person-notes.db.test.ts +249 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
- package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
- package/templates/database/_gitignore +8 -0
- package/templates/database/inbox/schema.prisma +114 -22
- package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
- package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
- package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
- package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
- package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
- package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
- package/templates/database/ops/schema.prisma +16 -0
- package/templates/database/package.json +1 -1
- package/templates/project.yaml +6 -3
- package/versions.json +35 -35
- package/templates/.claude/rules/preserve-existing-style.md +0 -51
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Per-IP rate limit middleware for anonymous write routes.
|
|
3
3
|
*
|
|
4
|
-
* SINGLE-REPLICA CAVEAT:
|
|
5
|
-
* counts. Each Fastify instance maintains its own bucket state.
|
|
6
|
-
*
|
|
7
|
-
* up to N × perIpPerMinute requests per minute per IP,
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* SINGLE-REPLICA CAVEAT: the memory store below keeps an in-process Map of
|
|
5
|
+
* request counts. Each Fastify instance maintains its own bucket state. On a
|
|
6
|
+
* deployment with no database (or `AILK_RATE_LIMIT_STORE=memory`), an
|
|
7
|
+
* attacker can issue up to N × perIpPerMinute requests per minute per IP,
|
|
8
|
+
* where N is the replica/isolate count.
|
|
9
|
+
*
|
|
10
|
+
* SHARED STORE (#5040): on a deployment with a database, every registration
|
|
11
|
+
* defaults to counting in `rate_limit_counters` instead (D4), so one
|
|
12
|
+
* visitor's limit holds across every concurrent isolate. See
|
|
13
|
+
* `apps/api/src/lib/rate-limit-store.ts` for the store seam and
|
|
14
|
+
* `docs/specs/5040-the-api-s-rate-limiter-spec.md` D11 for the full list of
|
|
15
|
+
* what this protects against and what it does not — in short: it stops one
|
|
16
|
+
* identity from exceeding its ceiling by spreading requests over isolates,
|
|
17
|
+
* but a caller that mints a fresh identity per request (a forged
|
|
18
|
+
* `x-forwarded-for` the edge passes through unmodified) still gets a fresh
|
|
19
|
+
* bucket every time, in either store.
|
|
11
20
|
*
|
|
12
21
|
* Cleanup strategy: evict stale buckets on every 429 emission and every 100
|
|
13
22
|
* allowed requests (bounded, O(n) over bucket count — negligible for typical
|
|
@@ -23,7 +32,9 @@
|
|
|
23
32
|
* caps total entries independent of the time-based eviction: once at
|
|
24
33
|
* capacity, a genuinely new identity evicts the single oldest-inserted entry
|
|
25
34
|
* (`Map` iteration order is insertion order) before inserting itself — O(1)
|
|
26
|
-
* amortized, no behavior change for any caller under the cap.
|
|
35
|
+
* amortized, no behavior change for any caller under the cap. The database
|
|
36
|
+
* store has no such cap: it relies entirely on the sweep (D6) to bound its
|
|
37
|
+
* row count.
|
|
27
38
|
*
|
|
28
39
|
* Trust-proxy boundary: this middleware reads req.ip, which Fastify populates
|
|
29
40
|
* from the socket address and any trusted proxy headers (X-Forwarded-For) per
|
|
@@ -51,8 +62,10 @@
|
|
|
51
62
|
* believes only alongside a hub-issued key in `x-ailk-visitor-forward-key`,
|
|
52
63
|
* and only on the `FORWARDED_VISITOR_ROUTES`: the three lead writes, and
|
|
53
64
|
* since #7633 the two schedule calls a site's same-origin relay forwards
|
|
54
|
-
* (#7644) — `GET /v1/schedule/availability` and `POST /v1/schedule/book
|
|
55
|
-
*
|
|
65
|
+
* (#7644) — `GET /v1/schedule/availability` and `POST /v1/schedule/book` — and
|
|
66
|
+
* since #7479 the three listing-draft calls a spoke makes for its applicant
|
|
67
|
+
* (`POST /v1/project-listings`, `PATCH /v1/project-listings/drafts/:id`,
|
|
68
|
+
* `POST /v1/project-listings/drafts/:id/submit`). The global limiter (`server.ts`) passes the hub's digests, so a listed call is
|
|
56
69
|
* counted per visitor there; a scope's own limiter does so only when its
|
|
57
70
|
* registration passes `trustedKeyDigests` too, as the leads and schedule
|
|
58
71
|
* scopes do. A believed visitor is
|
|
@@ -78,8 +91,17 @@ import type {
|
|
|
78
91
|
FastifyRequest,
|
|
79
92
|
FastifyReply,
|
|
80
93
|
FastifyPluginCallback,
|
|
94
|
+
FastifyBaseLogger,
|
|
81
95
|
} from "fastify";
|
|
82
96
|
|
|
97
|
+
import { prisma } from "../lib/prisma.js";
|
|
98
|
+
import {
|
|
99
|
+
createDatabaseRateLimitStore,
|
|
100
|
+
resolveRateLimitStoreKind,
|
|
101
|
+
type RateLimitBucket,
|
|
102
|
+
type RateLimitStore,
|
|
103
|
+
} from "../lib/rate-limit-store.js";
|
|
104
|
+
|
|
83
105
|
export type RateLimitOptions = {
|
|
84
106
|
/**
|
|
85
107
|
* Requests allowed per IP per window.
|
|
@@ -101,7 +123,8 @@ export type RateLimitOptions = {
|
|
|
101
123
|
/**
|
|
102
124
|
* Maximum distinct entries `buckets` may hold at once, independent of the
|
|
103
125
|
* time-based eviction. Default: `MAX_BUCKETS`. Exposed for tests only —
|
|
104
|
-
* no production caller overrides this.
|
|
126
|
+
* no production caller overrides this. Has no effect on the database
|
|
127
|
+
* store, which has no entry cap of its own (D6's sweep bounds it instead).
|
|
105
128
|
*/
|
|
106
129
|
maxBuckets?: number;
|
|
107
130
|
/**
|
|
@@ -123,11 +146,15 @@ export type RateLimitOptions = {
|
|
|
123
146
|
* visitors (#7394). Default: 300.
|
|
124
147
|
*/
|
|
125
148
|
perKeyPerMinute?: number;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Override the process-default store (#5040 D4). Test-only, like
|
|
151
|
+
* `maxBuckets` — no production caller overrides this.
|
|
152
|
+
*/
|
|
153
|
+
store?: RateLimitStore;
|
|
154
|
+
/**
|
|
155
|
+
* Override `STORE_TIMEOUT_MS` (#5040 D5). Test-only.
|
|
156
|
+
*/
|
|
157
|
+
storeTimeoutMs?: number;
|
|
131
158
|
};
|
|
132
159
|
|
|
133
160
|
const RATE_LIMITED = "rate_limited" as const;
|
|
@@ -154,15 +181,26 @@ export const MAX_BUCKETS = 10_000;
|
|
|
154
181
|
*/
|
|
155
182
|
export const DEFAULT_SCHEDULE_PER_KEY_PER_MINUTE = 60;
|
|
156
183
|
|
|
184
|
+
/**
|
|
185
|
+
* How long a database-store call may run before the middleware falls back to
|
|
186
|
+
* the memory store for that request (#5040 D5). `RateLimitOptions.storeTimeoutMs`
|
|
187
|
+
* overrides it, test-only.
|
|
188
|
+
*/
|
|
189
|
+
export const STORE_TIMEOUT_MS = 1_000;
|
|
190
|
+
|
|
191
|
+
/** How often a `rate_limit_store_fallback` warn line may be logged (D5). */
|
|
192
|
+
const FALLBACK_WARN_THROTTLE_MS = 60_000;
|
|
193
|
+
|
|
157
194
|
/**
|
|
158
195
|
* The only (method, route) pairs on which a forwarded visitor IP is believed
|
|
159
|
-
* (#7394 DD-2): the three lead writes,
|
|
196
|
+
* (#7394 DD-2): the three lead writes, the two schedule calls a site's
|
|
160
197
|
* relay forwards on its visitors' behalf (#7633, the relay's security
|
|
161
|
-
* review)
|
|
198
|
+
* review), the two consent writes (#8438) and the three listing-draft calls
|
|
199
|
+
* (#7479). Matched against `req.method` and `req.routeOptions.url`, the
|
|
162
200
|
* registered route pattern.
|
|
163
201
|
*/
|
|
164
202
|
export const FORWARDED_VISITOR_ROUTES: readonly Readonly<{
|
|
165
|
-
method: "GET" | "POST";
|
|
203
|
+
method: "GET" | "POST" | "PATCH";
|
|
166
204
|
url: string;
|
|
167
205
|
}>[] = [
|
|
168
206
|
{ method: "POST", url: "/v1/leads/submit" },
|
|
@@ -170,6 +208,16 @@ export const FORWARDED_VISITOR_ROUTES: readonly Readonly<{
|
|
|
170
208
|
{ method: "POST", url: "/v1/leads/callback" },
|
|
171
209
|
{ method: "GET", url: "/v1/schedule/availability" },
|
|
172
210
|
{ method: "POST", url: "/v1/schedule/book" },
|
|
211
|
+
// #8438 D5/D9 — the cookie banner's log and the unsubscribe landing: a
|
|
212
|
+
// site's server forwards each visitor's IP, so one spoke's visitors are
|
|
213
|
+
// counted one by one rather than in one bucket keyed on its egress IP.
|
|
214
|
+
{ method: "POST", url: "/v1/consent/cookie" },
|
|
215
|
+
{ method: "POST", url: "/v1/consent/unsubscribe" },
|
|
216
|
+
// #7479 — the listing-draft lifecycle a spoke's Server Action calls on its
|
|
217
|
+
// applicant's behalf (`start`, the draft `patch`, `submit`), in `leadsScope`.
|
|
218
|
+
{ method: "POST", url: "/v1/project-listings" },
|
|
219
|
+
{ method: "PATCH", url: "/v1/project-listings/drafts/:id" },
|
|
220
|
+
{ method: "POST", url: "/v1/project-listings/drafts/:id/submit" },
|
|
173
221
|
];
|
|
174
222
|
|
|
175
223
|
const VISITOR_IP_HEADER = "x-ailk-visitor-ip";
|
|
@@ -330,16 +378,17 @@ function resolveScopedLimit(
|
|
|
330
378
|
return fallback;
|
|
331
379
|
}
|
|
332
380
|
|
|
333
|
-
// Module-scoped bucket store
|
|
334
|
-
// (#7394). Per-forwarding-key buckets
|
|
335
|
-
|
|
381
|
+
// Module-scoped bucket store (the memory store's backing state). One entry
|
|
382
|
+
// per unique identity, per named scope (#7394). Per-forwarding-key buckets
|
|
383
|
+
// live in `keyBuckets` below.
|
|
384
|
+
const buckets = new Map<string, RateLimitBucket>();
|
|
336
385
|
|
|
337
386
|
// Per-forwarding-key buckets (#7394 DD-8), kept apart from `buckets` so the
|
|
338
387
|
// `maxBuckets` FIFO eviction can never drop one: a keyed caller churning
|
|
339
388
|
// visitor IPs would otherwise push its own key bucket out and reset its
|
|
340
389
|
// ceiling. At most one entry per configured key per named scope, so it
|
|
341
390
|
// needs no capacity cap of its own.
|
|
342
|
-
const keyBuckets = new Map<string,
|
|
391
|
+
const keyBuckets = new Map<string, RateLimitBucket>();
|
|
343
392
|
|
|
344
393
|
// Request counter used to trigger periodic cleanup.
|
|
345
394
|
let requestsSinceLastClean = 0;
|
|
@@ -356,7 +405,6 @@ function evictStaleBuckets(now: number, windowMs: number): void {
|
|
|
356
405
|
keyBuckets.delete(key);
|
|
357
406
|
}
|
|
358
407
|
}
|
|
359
|
-
requestsSinceLastClean = 0;
|
|
360
408
|
}
|
|
361
409
|
|
|
362
410
|
/**
|
|
@@ -377,6 +425,145 @@ export function __bucketCount(): number {
|
|
|
377
425
|
return buckets.size;
|
|
378
426
|
}
|
|
379
427
|
|
|
428
|
+
/**
|
|
429
|
+
* The memory store (#5040 D1/D7): the Maps and eviction logic above, wrapped
|
|
430
|
+
* behind the `RateLimitStore` seam, unchanged in behavior. `maxBuckets` is
|
|
431
|
+
* captured per call (registrations may pass their own, test-only) but every
|
|
432
|
+
* instance still operates on the SAME shared `buckets` / `keyBuckets` Maps —
|
|
433
|
+
* there has never been more than one memory store's worth of state in this
|
|
434
|
+
* process.
|
|
435
|
+
*/
|
|
436
|
+
function memoryRateLimitStore(maxBuckets: number): RateLimitStore {
|
|
437
|
+
function hit(key: string, now: number, windowMs: number): Promise<RateLimitBucket> {
|
|
438
|
+
let bucket = buckets.get(key);
|
|
439
|
+
|
|
440
|
+
if (!bucket || now - bucket.windowStartMs >= windowMs) {
|
|
441
|
+
// A genuinely new distinct identity (not a window roll on an
|
|
442
|
+
// existing bucket) at capacity evicts the single oldest-inserted
|
|
443
|
+
// entry first — independent of the time-based eviction above,
|
|
444
|
+
// which only removes EXPIRED windows and may leave `buckets` at or
|
|
445
|
+
// above `maxBuckets` when every entry is still within its window.
|
|
446
|
+
if (!buckets.has(key) && buckets.size >= maxBuckets) {
|
|
447
|
+
const oldestKey = buckets.keys().next().value;
|
|
448
|
+
if (oldestKey !== undefined) {
|
|
449
|
+
buckets.delete(oldestKey);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
bucket = { count: 0, windowStartMs: now };
|
|
453
|
+
buckets.set(key, bucket);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
bucket.count += 1;
|
|
457
|
+
return Promise.resolve(bucket);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function hitKey(key: string, now: number, windowMs: number): Promise<RateLimitBucket> {
|
|
461
|
+
let bucket = keyBuckets.get(key);
|
|
462
|
+
if (!bucket || now - bucket.windowStartMs >= windowMs) {
|
|
463
|
+
bucket = { count: 0, windowStartMs: now };
|
|
464
|
+
keyBuckets.set(key, bucket);
|
|
465
|
+
}
|
|
466
|
+
bucket.count += 1;
|
|
467
|
+
return Promise.resolve(bucket);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function hasOpenWindow(key: string, now: number, windowMs: number): Promise<boolean> {
|
|
471
|
+
const bucket = buckets.get(key);
|
|
472
|
+
return Promise.resolve(
|
|
473
|
+
bucket !== undefined && now - bucket.windowStartMs < windowMs,
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// D6 — the memory store sweeps on both triggers, exactly as
|
|
478
|
+
// evictStaleBuckets always has; `reason` only matters to the database
|
|
479
|
+
// store.
|
|
480
|
+
function sweep(now: number, windowMs: number): Promise<void> {
|
|
481
|
+
evictStaleBuckets(now, windowMs);
|
|
482
|
+
return Promise.resolve();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return { hit, hitKey, hasOpenWindow, sweep };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// The process-wide default store (#5040 D4), resolved once and memoized —
|
|
489
|
+
// NOT reset by __resetBuckets(), since only a fresh module instance
|
|
490
|
+
// (jest.isolateModules) changes which store the environment resolves to.
|
|
491
|
+
let processStoreKind: "memory" | "database" | undefined;
|
|
492
|
+
let processDatabaseStore: RateLimitStore | undefined;
|
|
493
|
+
let loggedStoreKind = false;
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* A rejection or timeout fired this long ago, within `FALLBACK_WARN_THROTTLE_MS`
|
|
497
|
+
* — do not log another `rate_limit_store_fallback` line yet.
|
|
498
|
+
*/
|
|
499
|
+
let lastFallbackWarnAt: number | undefined;
|
|
500
|
+
|
|
501
|
+
/** The process default store for a registration with no explicit `store` option. */
|
|
502
|
+
function resolveProcessDefaultStore(
|
|
503
|
+
warn: (msg: string) => void,
|
|
504
|
+
log: Pick<FastifyBaseLogger, "info">,
|
|
505
|
+
maxBuckets: number,
|
|
506
|
+
): RateLimitStore {
|
|
507
|
+
if (processStoreKind === undefined) {
|
|
508
|
+
processStoreKind = resolveRateLimitStoreKind(
|
|
509
|
+
{
|
|
510
|
+
DATABASE_URL: process.env.DATABASE_URL,
|
|
511
|
+
AILK_RATE_LIMIT_STORE: process.env.AILK_RATE_LIMIT_STORE,
|
|
512
|
+
},
|
|
513
|
+
warn,
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
if (!loggedStoreKind) {
|
|
517
|
+
loggedStoreKind = true;
|
|
518
|
+
log.info({ rateLimitStore: processStoreKind }, "rate_limit_store");
|
|
519
|
+
}
|
|
520
|
+
if (processStoreKind === "database") {
|
|
521
|
+
processDatabaseStore ??= createDatabaseRateLimitStore(prisma);
|
|
522
|
+
return processDatabaseStore;
|
|
523
|
+
}
|
|
524
|
+
return memoryRateLimitStore(maxBuckets);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
class RateLimitStoreTimeoutError extends Error {}
|
|
528
|
+
|
|
529
|
+
/** Reject with a `RateLimitStoreTimeoutError` if `promise` has not settled within `ms`. */
|
|
530
|
+
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
531
|
+
return new Promise<T>((resolve, reject) => {
|
|
532
|
+
const timer = setTimeout(() => {
|
|
533
|
+
reject(new RateLimitStoreTimeoutError("rate limit store call timed out"));
|
|
534
|
+
}, ms);
|
|
535
|
+
promise.then(
|
|
536
|
+
(value) => {
|
|
537
|
+
clearTimeout(timer);
|
|
538
|
+
resolve(value);
|
|
539
|
+
},
|
|
540
|
+
(err: unknown) => {
|
|
541
|
+
clearTimeout(timer);
|
|
542
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
543
|
+
},
|
|
544
|
+
);
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Log at most one `rate_limit_store_fallback` warn per `FALLBACK_WARN_THROTTLE_MS`
|
|
550
|
+
* per process (#5040 D5). Carries no key, IP, hash or error message.
|
|
551
|
+
*/
|
|
552
|
+
function warnFallback(
|
|
553
|
+
warn: (obj: Record<string, unknown>, msg: string) => void,
|
|
554
|
+
now: number,
|
|
555
|
+
reason: "error" | "timeout",
|
|
556
|
+
): void {
|
|
557
|
+
if (
|
|
558
|
+
lastFallbackWarnAt !== undefined &&
|
|
559
|
+
now - lastFallbackWarnAt < FALLBACK_WARN_THROTTLE_MS
|
|
560
|
+
) {
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
lastFallbackWarnAt = now;
|
|
564
|
+
warn({ event: "rate_limit_store_fallback", reason }, "rate_limit_store_fallback");
|
|
565
|
+
}
|
|
566
|
+
|
|
380
567
|
// Break Fastify's plugin encapsulation so the onRequest hook propagates to
|
|
381
568
|
// parent and sibling scopes. This is the manual equivalent of fastify-plugin's
|
|
382
569
|
// `skip-override` mechanism — documented in Fastify's plugin guide.
|
|
@@ -391,6 +578,7 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
|
|
|
391
578
|
maxBuckets = MAX_BUCKETS,
|
|
392
579
|
scope,
|
|
393
580
|
trustedKeyDigests = [],
|
|
581
|
+
storeTimeoutMs = STORE_TIMEOUT_MS,
|
|
394
582
|
} = opts;
|
|
395
583
|
|
|
396
584
|
if (scope !== undefined && !SCOPE_RE.test(scope)) {
|
|
@@ -430,45 +618,57 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
|
|
|
430
618
|
const bucketKey = (identity: string) =>
|
|
431
619
|
scope === undefined ? identity : `${scope}|${identity}`;
|
|
432
620
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
621
|
+
const fallbackStore = memoryRateLimitStore(maxBuckets);
|
|
622
|
+
const primaryStore: RateLimitStore =
|
|
623
|
+
opts.store ?? resolveProcessDefaultStore(warn, fastify.log, maxBuckets);
|
|
436
624
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
625
|
+
/**
|
|
626
|
+
* Run the whole request's counting against `activeStore` (D7's unchanged
|
|
627
|
+
* control flow: hitKey → keyOver → R-1 check (hasOpenWindow) → hit →
|
|
628
|
+
* tripped), plus the periodic/on-429 sweep (D6).
|
|
629
|
+
*/
|
|
630
|
+
async function countRequest(
|
|
631
|
+
activeStore: RateLimitStore,
|
|
632
|
+
ip: string,
|
|
633
|
+
keyIndex: number | null,
|
|
634
|
+
now: number,
|
|
635
|
+
sweepOnInterval: boolean,
|
|
636
|
+
): Promise<{
|
|
637
|
+
tripped: RateLimitBucket | undefined;
|
|
638
|
+
visitorOver: boolean;
|
|
639
|
+
keyOver: boolean;
|
|
640
|
+
}> {
|
|
641
|
+
if (sweepOnInterval) {
|
|
642
|
+
await activeStore.sweep(now, windowMs, "interval");
|
|
451
643
|
}
|
|
452
644
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
645
|
+
// #7394 DD-8: a keyed request also counts against its key's own
|
|
646
|
+
// bucket, even when the visitor bucket is already over its limit.
|
|
647
|
+
const keyBucket =
|
|
648
|
+
keyIndex === null
|
|
649
|
+
? undefined
|
|
650
|
+
: await activeStore.hitKey(`${scope}|key,${keyIndex}`, now, windowMs);
|
|
651
|
+
const keyOver =
|
|
652
|
+
keyBucket !== undefined && keyBucket.count > perKeyPerMinute;
|
|
653
|
+
// A key over its ceiling opens no NEW visitor bucket (#7394 security
|
|
654
|
+
// review R-1). Each new entry can evict the oldest one in `buckets`,
|
|
655
|
+
// another scope's counter included, so without this a key holder
|
|
656
|
+
// rotating visitor IPs could wipe every strict limiter at will. A
|
|
657
|
+
// visitor whose window is still open is counted as usual.
|
|
658
|
+
const visitorKey = bucketKey(ip);
|
|
659
|
+
const bucket =
|
|
660
|
+
keyOver && !(await activeStore.hasOpenWindow(visitorKey, now, windowMs))
|
|
661
|
+
? undefined
|
|
662
|
+
: await activeStore.hit(visitorKey, now, windowMs);
|
|
663
|
+
const visitorOver = bucket !== undefined && bucket.count > perIpPerMinute;
|
|
664
|
+
const tripped = visitorOver ? bucket : keyOver ? keyBucket : undefined;
|
|
665
|
+
|
|
666
|
+
if (tripped) {
|
|
667
|
+
// Evict stale buckets on every 429 to cap memory under abuse.
|
|
668
|
+
await activeStore.sweep(now, windowMs, "limited");
|
|
469
669
|
}
|
|
470
|
-
|
|
471
|
-
return
|
|
670
|
+
|
|
671
|
+
return { tripped, visitorOver, keyOver };
|
|
472
672
|
}
|
|
473
673
|
|
|
474
674
|
fastify.addHook(
|
|
@@ -478,37 +678,50 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
|
|
|
478
678
|
const { identity: ip, keyIndex } = identityOf(req);
|
|
479
679
|
|
|
480
680
|
requestsSinceLastClean += 1;
|
|
481
|
-
|
|
482
|
-
|
|
681
|
+
const sweepOnInterval = requestsSinceLastClean >= CLEAN_INTERVAL_REQUESTS;
|
|
682
|
+
if (sweepOnInterval) requestsSinceLastClean = 0;
|
|
683
|
+
|
|
684
|
+
// #5040 D5 — fail open to the memory store, never to "no limit": a
|
|
685
|
+
// rejection or a timeout on ANY call of the primary store redoes the
|
|
686
|
+
// WHOLE request's counting against the memory store, so one request
|
|
687
|
+
// never mixes stores. withTimeout does not cancel the primary store's
|
|
688
|
+
// in-flight call, so a write that already landed before the failure
|
|
689
|
+
// stays counted, and a timed-out query may still land later — the
|
|
690
|
+
// request can be counted in BOTH stores, over-counting by at most
|
|
691
|
+
// one. That is the safe direction (D5): never under-counts, never
|
|
692
|
+
// lets a request through uncounted.
|
|
693
|
+
let outcome: {
|
|
694
|
+
tripped: RateLimitBucket | undefined;
|
|
695
|
+
visitorOver: boolean;
|
|
696
|
+
keyOver: boolean;
|
|
697
|
+
};
|
|
698
|
+
try {
|
|
699
|
+
outcome = await withTimeout(
|
|
700
|
+
countRequest(primaryStore, ip, keyIndex, now, sweepOnInterval),
|
|
701
|
+
storeTimeoutMs,
|
|
702
|
+
);
|
|
703
|
+
} catch (err) {
|
|
704
|
+
warnFallback(
|
|
705
|
+
(obj, msg) => fastify.log.warn(obj, msg),
|
|
706
|
+
now,
|
|
707
|
+
err instanceof RateLimitStoreTimeoutError ? "timeout" : "error",
|
|
708
|
+
);
|
|
709
|
+
outcome = await countRequest(
|
|
710
|
+
fallbackStore,
|
|
711
|
+
ip,
|
|
712
|
+
keyIndex,
|
|
713
|
+
now,
|
|
714
|
+
sweepOnInterval,
|
|
715
|
+
);
|
|
483
716
|
}
|
|
484
717
|
|
|
485
|
-
|
|
486
|
-
// bucket, even when the visitor bucket is already over its limit.
|
|
487
|
-
const keyBucket =
|
|
488
|
-
keyIndex === null ? undefined : hitKey(`${scope}|key,${keyIndex}`, now);
|
|
489
|
-
const keyOver =
|
|
490
|
-
keyBucket !== undefined && keyBucket.count > perKeyPerMinute;
|
|
491
|
-
// A key over its ceiling opens no NEW visitor bucket (#7394 security
|
|
492
|
-
// review R-1). Each new entry can evict the oldest one in `buckets`,
|
|
493
|
-
// another scope's counter included, so without this a key holder
|
|
494
|
-
// rotating visitor IPs could wipe every strict limiter at will. A
|
|
495
|
-
// visitor whose window is still open is counted as usual.
|
|
496
|
-
const visitorKey = bucketKey(ip);
|
|
497
|
-
const bucket =
|
|
498
|
-
keyOver && !hasOpenWindow(visitorKey, now)
|
|
499
|
-
? undefined
|
|
500
|
-
: hit(visitorKey, now);
|
|
501
|
-
const visitorOver = bucket !== undefined && bucket.count > perIpPerMinute;
|
|
502
|
-
const tripped = visitorOver ? bucket : keyOver ? keyBucket : undefined;
|
|
718
|
+
const { tripped, visitorOver } = outcome;
|
|
503
719
|
|
|
504
720
|
if (tripped) {
|
|
505
721
|
const retryAfterSeconds = Math.ceil(
|
|
506
722
|
(tripped.windowStartMs + windowMs - now) / 1_000,
|
|
507
723
|
);
|
|
508
724
|
|
|
509
|
-
// Evict stale buckets on every 429 to cap memory under abuse.
|
|
510
|
-
evictStaleBuckets(now, windowMs);
|
|
511
|
-
|
|
512
725
|
// The path WITHOUT its query string (#5970 security review, finding 1): a
|
|
513
726
|
// route may carry its credential there (`/v1/requests/:id/confirm?token=…`
|
|
514
727
|
// does), and a rate-limit warning is a line an attacker can provoke at
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file live-key.test.ts
|
|
3
|
+
*
|
|
4
|
+
* The hub checkout with a live key (#8450 AC 1, AC 2): `POST
|
|
5
|
+
* /v1/checkout/sessions` and the return page's `GET
|
|
6
|
+
* /v1/checkout/sessions/:id`. Unlike `sessions.test.ts` and
|
|
7
|
+
* `session.test.ts`, BOTH guards here are real: `lib/stripe.ts` runs as
|
|
8
|
+
* itself (only the two Stripe calls are mocked, so nothing leaves the
|
|
9
|
+
* process), and `@working-theory/stripe-client` runs as itself with
|
|
10
|
+
* `assertSandboxKey` wrapped in a spy, so the test can see whether a route
|
|
11
|
+
* still calls the sandbox-only guard.
|
|
12
|
+
*
|
|
13
|
+
* The process admits a restricted live key once, in `beforeAll`, exactly as
|
|
14
|
+
* `build()` does at boot. Every live-shaped key is composed at runtime from
|
|
15
|
+
* fragments — never a literal (architecture.yaml's live-key pattern).
|
|
16
|
+
*/
|
|
17
|
+
import type { FastifyInstance } from "fastify";
|
|
18
|
+
|
|
19
|
+
const mockCreateCheckoutSession = jest.fn();
|
|
20
|
+
const mockRetrieveCheckoutSession = jest.fn();
|
|
21
|
+
|
|
22
|
+
jest.mock("@working-theory/stripe-client", () => {
|
|
23
|
+
const actual = jest.requireActual("@working-theory/stripe-client");
|
|
24
|
+
return { ...actual, assertSandboxKey: jest.fn(actual.assertSandboxKey) };
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
|
|
28
|
+
jest.mock("../../../lib/stripe.js", () => ({
|
|
29
|
+
...jest.requireActual("../../../lib/stripe.js"),
|
|
30
|
+
createCheckoutSession: (...args: unknown[]) => mockCreateCheckoutSession(...args),
|
|
31
|
+
retrieveCheckoutSession: (...args: unknown[]) => mockRetrieveCheckoutSession(...args),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted; the DB boundary, mirrors sessions.test.ts
|
|
35
|
+
jest.mock("../../../lib/prisma.js", () => ({
|
|
36
|
+
prisma: {
|
|
37
|
+
session: { findUnique: jest.fn() },
|
|
38
|
+
user: { findUnique: jest.fn() },
|
|
39
|
+
apiToken: { findUnique: jest.fn(), update: jest.fn() },
|
|
40
|
+
},
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
import { assertSandboxKey } from "@working-theory/stripe-client";
|
|
44
|
+
|
|
45
|
+
import { config } from "../../../config/index.js";
|
|
46
|
+
import { admitApiStripeKey, stripeKeyMode } from "../../../lib/stripe.js";
|
|
47
|
+
import { __resetBuckets } from "../../../middleware/rate-limit.js";
|
|
48
|
+
import { checkoutRoutes } from "../index";
|
|
49
|
+
|
|
50
|
+
config.FRONTEND_URL = "https://example.com";
|
|
51
|
+
config.TRUSTED_ORIGINS = ["https://example.com"];
|
|
52
|
+
|
|
53
|
+
const TAIL = "FixtureOnlyCheckout";
|
|
54
|
+
const restrictedLiveKey = ["rk", "live", ""].join("_") + TAIL;
|
|
55
|
+
const secretLiveKey = ["sk", "live", ""].join("_") + TAIL;
|
|
56
|
+
const publishableLiveKey = ["pk", "live", ""].join("_") + TAIL;
|
|
57
|
+
|
|
58
|
+
const sandboxSpy = assertSandboxKey as jest.MockedFunction<typeof assertSandboxKey>;
|
|
59
|
+
|
|
60
|
+
const ENV_KEYS = ["STRIPE_SECRET_KEY", "STRIPE_MODE", "VERCEL_ENV", "OSS_API_INTERNAL_SECRET"] as const;
|
|
61
|
+
const savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
|
|
62
|
+
|
|
63
|
+
const VALID_BODY = {
|
|
64
|
+
productCode: "ailk-pro",
|
|
65
|
+
successUrl: "https://example.com/success",
|
|
66
|
+
cancelUrl: "https://example.com/cancel",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const SESSION_ID = "cs_live_fixture12345";
|
|
70
|
+
|
|
71
|
+
function buildApp(): FastifyInstance {
|
|
72
|
+
const fastify = require("fastify")({ logger: false });
|
|
73
|
+
fastify.register(checkoutRoutes);
|
|
74
|
+
return fastify;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("the hub checkout — an admitted live key (#8450)", () => {
|
|
78
|
+
let app: FastifyInstance;
|
|
79
|
+
|
|
80
|
+
beforeAll(() => {
|
|
81
|
+
delete process.env["OSS_API_INTERNAL_SECRET"];
|
|
82
|
+
process.env["STRIPE_SECRET_KEY"] = restrictedLiveKey;
|
|
83
|
+
process.env["STRIPE_MODE"] = "live";
|
|
84
|
+
process.env["VERCEL_ENV"] = "production";
|
|
85
|
+
expect(admitApiStripeKey()).toBe("live");
|
|
86
|
+
expect(stripeKeyMode()).toBe("live");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterAll(() => {
|
|
90
|
+
for (const k of ENV_KEYS) {
|
|
91
|
+
if (savedEnv[k] === undefined) delete process.env[k];
|
|
92
|
+
else process.env[k] = savedEnv[k];
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
beforeEach(async () => {
|
|
97
|
+
jest.clearAllMocks();
|
|
98
|
+
__resetBuckets();
|
|
99
|
+
process.env["STRIPE_SECRET_KEY"] = restrictedLiveKey;
|
|
100
|
+
mockCreateCheckoutSession.mockResolvedValue({
|
|
101
|
+
sessionId: "cs_live_fixture12345",
|
|
102
|
+
url: "https://checkout.stripe.com/c/pay/fixture",
|
|
103
|
+
});
|
|
104
|
+
mockRetrieveCheckoutSession.mockResolvedValue({
|
|
105
|
+
id: SESSION_ID,
|
|
106
|
+
status: "complete",
|
|
107
|
+
paymentStatus: "paid",
|
|
108
|
+
currency: "usd",
|
|
109
|
+
amountTotal: 4800,
|
|
110
|
+
lineItems: [],
|
|
111
|
+
});
|
|
112
|
+
app = buildApp();
|
|
113
|
+
await app.ready();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
afterEach(async () => {
|
|
117
|
+
await app.close();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("POST /v1/checkout/sessions", () => {
|
|
121
|
+
it("gets past the key check on the restricted live key, without the sandbox-only guard", async () => {
|
|
122
|
+
const res = await app.inject({ method: "POST", url: "/v1/checkout/sessions", payload: VALID_BODY });
|
|
123
|
+
|
|
124
|
+
expect(res.statusCode).toBe(201);
|
|
125
|
+
expect(mockCreateCheckoutSession).toHaveBeenCalledTimes(1);
|
|
126
|
+
expect(sandboxSpy).not.toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it.each([
|
|
130
|
+
["an unrestricted live key", secretLiveKey],
|
|
131
|
+
["a publishable live key", publishableLiveKey],
|
|
132
|
+
])("still refuses %s with 500 live-mode-forbidden and no Stripe call", async (_label, key) => {
|
|
133
|
+
process.env["STRIPE_SECRET_KEY"] = key;
|
|
134
|
+
|
|
135
|
+
const res = await app.inject({ method: "POST", url: "/v1/checkout/sessions", payload: VALID_BODY });
|
|
136
|
+
|
|
137
|
+
expect(res.statusCode).toBe(500);
|
|
138
|
+
expect(res.json()).toEqual({ error: "live-mode-forbidden" });
|
|
139
|
+
expect(res.body).not.toContain(TAIL);
|
|
140
|
+
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("GET /v1/checkout/sessions/:id", () => {
|
|
145
|
+
it("gets past the key check on the restricted live key, without the sandbox-only guard", async () => {
|
|
146
|
+
const res = await app.inject({ method: "GET", url: `/v1/checkout/sessions/${SESSION_ID}` });
|
|
147
|
+
|
|
148
|
+
expect(res.statusCode).toBe(200);
|
|
149
|
+
expect(mockRetrieveCheckoutSession).toHaveBeenCalledTimes(1);
|
|
150
|
+
expect(sandboxSpy).not.toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it.each([
|
|
154
|
+
["an unrestricted live key", secretLiveKey],
|
|
155
|
+
["a publishable live key", publishableLiveKey],
|
|
156
|
+
])("still refuses %s with 500 live-mode-forbidden and no Stripe call", async (_label, key) => {
|
|
157
|
+
process.env["STRIPE_SECRET_KEY"] = key;
|
|
158
|
+
|
|
159
|
+
const res = await app.inject({ method: "GET", url: `/v1/checkout/sessions/${SESSION_ID}` });
|
|
160
|
+
|
|
161
|
+
expect(res.statusCode).toBe(500);
|
|
162
|
+
expect(res.json()).toEqual({ error: "live-mode-forbidden" });
|
|
163
|
+
expect(res.body).not.toContain(TAIL);
|
|
164
|
+
expect(mockRetrieveCheckoutSession).not.toHaveBeenCalled();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|