create-ailk 0.18.0 → 0.19.1
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/LICENSE +6 -5
- package/NOTICE +2 -0
- package/dist/add-module.js +11 -3
- package/dist/category-profile.d.ts +10 -0
- package/dist/category-profile.js +21 -0
- package/dist/derive-architecture.d.ts +5 -1
- package/dist/derive-architecture.js +8 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/license-key-files.d.ts +6 -3
- package/dist/lib/license-key-files.js +8 -2
- package/dist/lib/scaffold-render-sweep.d.ts +32 -0
- package/dist/lib/scaffold-render-sweep.js +76 -3
- package/dist/license-set.d.ts +1 -1
- package/dist/license-set.js +13 -8
- package/dist/module-architecture.js +128 -3
- package/dist/programmatic.js +11 -1
- package/dist/site-roster.js +21 -4
- package/dist/spoke-checkout.d.ts +11 -3
- package/dist/spoke-checkout.js +62 -12
- package/dist/starter-grid.d.ts +1 -1
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +170 -1
- package/dist/template-manifest.d.ts +4 -3
- package/dist/topology.d.ts +7 -2
- package/dist/topology.js +88 -3
- package/package.json +5 -4
- package/templates/.env.example +3 -3
- package/templates/.github/workflows/ci.yml +5 -0
- package/templates/apps/api/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +31 -7
- package/templates/apps/api/LICENSE +6 -5
- package/templates/apps/api/NOTICE +2 -0
- package/templates/apps/api/jest.config.cjs +4 -29
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +17 -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__/deliverable-asset-store.test.ts +38 -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/deliverable-asset-store.ts +5 -4
- package/templates/apps/api/src/lib/stripe.ts +176 -3
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +272 -39
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
- package/templates/apps/api/src/middleware/auth.ts +214 -46
- package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
- 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 +19 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
- 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-rate-limit.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
- package/templates/apps/api/src/server.ts +218 -16
- 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 +173 -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/consent.ts +488 -3
- package/templates/apps/api/src/services/lead-routing.ts +135 -14
- 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/request-confirm.ts +52 -0
- package/templates/apps/api/src/services/revalidation.ts +11 -1
- package/templates/apps/api/vercel.json +2 -2
- package/templates/apps/mcp/.env.example +10 -0
- package/templates/apps/mcp/CLAUDE.md +9 -4
- package/templates/apps/mcp/LICENSE +6 -5
- package/templates/apps/mcp/NOTICE +2 -0
- package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
- package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
- package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
- package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
- package/templates/apps/mcp/jest.config.cjs +3 -44
- package/templates/apps/mcp/src/abuse/index.ts +117 -21
- package/templates/apps/mcp/src/boot.ts +5 -23
- package/templates/apps/mcp/src/http-transport.ts +93 -73
- package/templates/apps/mcp/src/index.ts +2 -0
- package/templates/apps/mcp/src/request-context.ts +52 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
- package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
- package/templates/apps/web/LICENSE +6 -5
- package/templates/apps/web/NOTICE +2 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
- 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__/billing.test.tsx +302 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +724 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +346 -41
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
- 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 +232 -29
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +140 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +409 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +65 -380
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +188 -54
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +174 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +30 -4
- package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
- package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +132 -0
- 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/page.tsx +13 -354
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
- package/templates/apps/web/app/[locale]/layout.tsx +23 -4
- package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
- package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
- package/templates/apps/web/app/robots.txt/route.ts +10 -4
- package/templates/apps/web/auth.ts +25 -2
- package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
- package/templates/apps/web/lib/account-config.ts +188 -52
- package/templates/apps/web/lib/auth-email.ts +45 -10
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
- package/templates/apps/web/lib/hub-session/index.ts +1 -0
- package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
- package/templates/apps/web/lib/hub-session/seal.ts +13 -9
- package/templates/apps/web/lib/topology.generated.ts +33 -0
- package/templates/apps/web/messages/en.json +194 -33
- package/templates/database/CHANGELOG.md +37 -0
- package/templates/database/LICENSE +6 -5
- package/templates/database/NOTICE +2 -0
- package/templates/database/README.md +1 -1
- 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__/member-area.db.test.ts +15 -3
- package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
- package/templates/database/__tests__/member-contract.db.test.ts +413 -0
- package/templates/database/__tests__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- package/templates/database/__tests__/seed.test.ts +54 -2
- package/templates/database/inbox/schema.prisma +166 -25
- package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
- package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
- 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/package.json +3 -2
- package/templates/database/seed.ts +34 -2
- package/versions.json +36 -36
- package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
- package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
- package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
- package/templates/apps/api/src/__tests__/server.test.ts +0 -591
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
- package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
- package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
- package/templates/database/__tests__/build.test.ts +0 -131
|
@@ -8,7 +8,12 @@
|
|
|
8
8
|
* '@working-theory/stripe-client' — single chokepoint for AILK-internal ergonomics
|
|
9
9
|
* (logger context, request-id propagation) without touching every consumer.
|
|
10
10
|
*/
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
admitLiveRestrictedKey,
|
|
13
|
+
assertSandboxKey,
|
|
14
|
+
getStripeClient,
|
|
15
|
+
StripeConfigError,
|
|
16
|
+
} from "@working-theory/stripe-client";
|
|
12
17
|
|
|
13
18
|
export {
|
|
14
19
|
// PRECONDITION (#6113): do not narrow or bypass this sandbox-only guard for a
|
|
@@ -18,6 +23,8 @@ export {
|
|
|
18
23
|
// Widened by #6852 (the #6113 follow-up): the same function must bind only
|
|
19
24
|
// on a server-stamped metadata.ailk_user_id — an unverified Checkout email
|
|
20
25
|
// never makes a FIRST binding either.
|
|
26
|
+
// Both are on main (#6850, #6862); #8450 is the narrowing this guarded —
|
|
27
|
+
// `assertAdmittedStripeKey` below. This export itself stays sandbox-only.
|
|
21
28
|
assertSandboxKey,
|
|
22
29
|
createAdHocPrice,
|
|
23
30
|
createBillingPortalSession,
|
|
@@ -60,6 +67,89 @@ export type {
|
|
|
60
67
|
StripeWebhookEvent,
|
|
61
68
|
} from "@working-theory/stripe-client";
|
|
62
69
|
|
|
70
|
+
/**
|
|
71
|
+
* apps/api's live-key admission (#8450 D2 — D-68's apps/api leg, the half
|
|
72
|
+
* #6881 left unbuilt). Composed at runtime so this file's own text never
|
|
73
|
+
* trips the repo-wide live-key lint.
|
|
74
|
+
*/
|
|
75
|
+
const LIVE_RESTRICTED_KEY_PREFIX = ["rk", "live", ""].join("_");
|
|
76
|
+
|
|
77
|
+
let liveKeyAdmitted = false;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `VERCEL_ENV` when set, else `NODE_ENV` — packages/spoke-checkout's own
|
|
81
|
+
* rule, with one fail-closed delta (PR #8470 security review R-1): on Vercel
|
|
82
|
+
* (`VERCEL` set) a missing `VERCEL_ENV` is not production, because
|
|
83
|
+
* `vercel.json` sets `NODE_ENV=production` in every environment, Preview
|
|
84
|
+
* included.
|
|
85
|
+
*/
|
|
86
|
+
function isProductionBuild(): boolean {
|
|
87
|
+
if (process.env.VERCEL_ENV !== undefined) {
|
|
88
|
+
return process.env.VERCEL_ENV === "production";
|
|
89
|
+
}
|
|
90
|
+
if (process.env.VERCEL !== undefined) return false;
|
|
91
|
+
return process.env.NODE_ENV === "production";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Admit this process's Stripe key at server build, or throw
|
|
96
|
+
* `StripeConfigError` (#8450 D2). Called once, by `server.ts`'s `build()`,
|
|
97
|
+
* and nowhere else.
|
|
98
|
+
*
|
|
99
|
+
* Only a restricted live key, beside `STRIPE_MODE=live`, on a production
|
|
100
|
+
* build is admitted: the package's one-way flag is set and `"live"` comes
|
|
101
|
+
* back. `STRIPE_MODE` unset or `test` is today's boot — `assertSandboxKey`,
|
|
102
|
+
* its message unchanged — and returns `"test"`. Every other combination
|
|
103
|
+
* throws. It takes no parameter and reads `process.env` only, so it vouches
|
|
104
|
+
* for the same key `getStripeClient()` later reads. No message names any
|
|
105
|
+
* part of the key.
|
|
106
|
+
*/
|
|
107
|
+
export function admitApiStripeKey(): "test" | "live" {
|
|
108
|
+
const mode = process.env.STRIPE_MODE;
|
|
109
|
+
const key = process.env.STRIPE_SECRET_KEY ?? "";
|
|
110
|
+
|
|
111
|
+
if (mode !== undefined && mode !== "test" && mode !== "live") {
|
|
112
|
+
throw new StripeConfigError('STRIPE_MODE must be "test" or "live".');
|
|
113
|
+
}
|
|
114
|
+
if (mode === "live") {
|
|
115
|
+
if (!key.startsWith(LIVE_RESTRICTED_KEY_PREFIX)) {
|
|
116
|
+
throw new StripeConfigError(
|
|
117
|
+
"STRIPE_MODE=live needs a restricted live Stripe key. " +
|
|
118
|
+
"Unrestricted, publishable and test-mode keys are refused.",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (!isProductionBuild()) {
|
|
122
|
+
throw new StripeConfigError(
|
|
123
|
+
"A restricted live Stripe key is admitted only with STRIPE_MODE=live on a production build " +
|
|
124
|
+
"(VERCEL_ENV=production, or NODE_ENV=production off Vercel).",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
admitLiveRestrictedKey();
|
|
128
|
+
liveKeyAdmitted = true;
|
|
129
|
+
return "live";
|
|
130
|
+
}
|
|
131
|
+
assertSandboxKey(key);
|
|
132
|
+
return "test";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The request-time key check for the routes that may run live (#8450 D3):
|
|
137
|
+
* the member portal session and the checkout start and read. It returns
|
|
138
|
+
* before the sandbox-only guard for the restricted live class alone, and
|
|
139
|
+
* only once `admitApiStripeKey()` admitted it; every other key meets
|
|
140
|
+
* `assertSandboxKey`'s allow-list, so an unrestricted or publishable live
|
|
141
|
+
* key is refused whether or not the process admitted.
|
|
142
|
+
*/
|
|
143
|
+
export function assertAdmittedStripeKey(key: string): void {
|
|
144
|
+
if (liveKeyAdmitted && key.startsWith(LIVE_RESTRICTED_KEY_PREFIX)) return;
|
|
145
|
+
assertSandboxKey(key);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The key class this process admitted at boot: `"live"` only after `admitApiStripeKey()` did. */
|
|
149
|
+
export function stripeKeyMode(): "test" | "live" {
|
|
150
|
+
return liveKeyAdmitted ? "live" : "test";
|
|
151
|
+
}
|
|
152
|
+
|
|
63
153
|
/**
|
|
64
154
|
* Expire a Checkout Session (#4912 FR7/D7).
|
|
65
155
|
*
|
|
@@ -67,9 +157,92 @@ export type {
|
|
|
67
157
|
* session already exists, its id never reached the `FlowCheckout` row, and
|
|
68
158
|
* nothing will ever join the two — so the session is closed rather than left
|
|
69
159
|
* live and payable (#4863). Built on the already-re-exported
|
|
70
|
-
* `getStripeClient`, whose construction asserts
|
|
71
|
-
*
|
|
160
|
+
* `getStripeClient`, whose construction asserts its key allow-list, so this
|
|
161
|
+
* call inherits the same gate as every other Stripe call here (test-mode, or
|
|
162
|
+
* the restricted live key once `admitApiStripeKey()` admitted it, #8450).
|
|
72
163
|
*/
|
|
73
164
|
export async function expireCheckoutSession(stripeSessionId: string): Promise<void> {
|
|
74
165
|
await getStripeClient().checkout.sessions.expire(stripeSessionId);
|
|
75
166
|
}
|
|
167
|
+
|
|
168
|
+
/** #8171 D7 — what the Billing page shows: the plan's name and the card on file. */
|
|
169
|
+
export type CustomerBillingSummary = {
|
|
170
|
+
planName: string | null;
|
|
171
|
+
paymentMethod: { brand: string; last4: string; expMonth: number; expYear: number } | null;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/** A subscription status that still names the customer's plan (#8171 C10). */
|
|
175
|
+
const CURRENT_SUBSCRIPTION_STATUSES = new Set(["active", "trialing", "past_due", "unpaid"]);
|
|
176
|
+
const PLAN_NAME_MAX = 200;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Read one customer's plan name and card summary (#8171 D7, C10): the newest
|
|
180
|
+
* subscription that is `active`, `trialing`, `past_due` or `unpaid`; its
|
|
181
|
+
* first item's product name (else the price nickname); and its default
|
|
182
|
+
* payment method (else the customer's invoice default). At most three reads,
|
|
183
|
+
* no expand path deeper than three levels (SEC-8171-3). Built on
|
|
184
|
+
* `getStripeClient`, so it inherits its allow-list: a missing key, or a live
|
|
185
|
+
* key this process did not admit (#8450), throws `StripeConfigError`. It logs
|
|
186
|
+
* nothing and returns no id.
|
|
187
|
+
*/
|
|
188
|
+
export async function readCustomerBillingSummary(
|
|
189
|
+
customerId: string,
|
|
190
|
+
): Promise<CustomerBillingSummary> {
|
|
191
|
+
const stripe = getStripeClient();
|
|
192
|
+
const subscriptions = await stripe.subscriptions.list({
|
|
193
|
+
customer: customerId,
|
|
194
|
+
status: "all",
|
|
195
|
+
limit: 10,
|
|
196
|
+
expand: ["data.default_payment_method"],
|
|
197
|
+
});
|
|
198
|
+
const current = subscriptions.data
|
|
199
|
+
.filter((sub) => CURRENT_SUBSCRIPTION_STATUSES.has(sub.status))
|
|
200
|
+
.sort((a, b) => b.created - a.created)[0];
|
|
201
|
+
|
|
202
|
+
let planName: string | null = null;
|
|
203
|
+
const price = current?.items.data[0]?.price;
|
|
204
|
+
if (price) {
|
|
205
|
+
let name: string | null = null;
|
|
206
|
+
if (typeof price.product === "string") {
|
|
207
|
+
const product = await stripe.products.retrieve(price.product);
|
|
208
|
+
name = product.name;
|
|
209
|
+
} else if (price.product && !("deleted" in price.product && price.product.deleted)) {
|
|
210
|
+
name = (price.product as { name?: string }).name ?? null;
|
|
211
|
+
}
|
|
212
|
+
const trimmed = (name ?? price.nickname ?? "").trim().slice(0, PLAN_NAME_MAX);
|
|
213
|
+
planName = trimmed === "" ? null : trimmed;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let method: unknown = current?.default_payment_method ?? null;
|
|
217
|
+
if (cardOf(method) === null) {
|
|
218
|
+
const customer = await stripe.customers.retrieve(customerId, {
|
|
219
|
+
expand: ["invoice_settings.default_payment_method"],
|
|
220
|
+
});
|
|
221
|
+
method = "deleted" in customer && customer.deleted
|
|
222
|
+
? null
|
|
223
|
+
: (customer as { invoice_settings?: { default_payment_method?: unknown } }).invoice_settings
|
|
224
|
+
?.default_payment_method ?? null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return { planName, paymentMethod: cardOf(method) };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** A `card` payment method's presentation fields, or `null` for anything else. */
|
|
231
|
+
function cardOf(method: unknown): CustomerBillingSummary["paymentMethod"] {
|
|
232
|
+
if (typeof method !== "object" || method === null) return null;
|
|
233
|
+
const { type, card } = method as {
|
|
234
|
+
type?: unknown;
|
|
235
|
+
card?: { brand?: unknown; last4?: unknown; exp_month?: unknown; exp_year?: unknown };
|
|
236
|
+
};
|
|
237
|
+
if (type !== "card" || !card) return null;
|
|
238
|
+
const { brand, last4, exp_month: expMonth, exp_year: expYear } = card;
|
|
239
|
+
if (
|
|
240
|
+
typeof brand !== "string" ||
|
|
241
|
+
typeof last4 !== "string" ||
|
|
242
|
+
typeof expMonth !== "number" ||
|
|
243
|
+
typeof expYear !== "number"
|
|
244
|
+
) {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
return { brand, last4, expMonth, expYear };
|
|
248
|
+
}
|
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
* docs/specs/7894-the-hub-signs-in-its-spec.md D6; AC-4, AC-5, AC-7).
|
|
6
6
|
*
|
|
7
7
|
* A bearer starting `ailk_hst_` is admitted only beside the SAME spoke's key,
|
|
8
|
-
* only while its grant is live and its person verified, and only on the
|
|
9
|
-
* routes of `HUB_SESSION_ROUTES
|
|
10
|
-
* run (the status `lib/account-config.ts`'s `read()` already
|
|
11
|
-
* for this caller"), never 403.
|
|
8
|
+
* only while its grant is live and its person verified, and only on the 20
|
|
9
|
+
* routes of `HUB_SESSION_ROUTES` (#8220 D1, AC-1). Any other route answers 404
|
|
10
|
+
* with no handler run (the status `lib/account-config.ts`'s `read()` already
|
|
11
|
+
* loads as "nothing for this caller"), never 403. Each (person, spoke) is
|
|
12
|
+
* limited to 60 requests and 10 writes a minute and 30 writes an hour,
|
|
13
|
+
* charged only after admission (#8220 D2, AC-7).
|
|
12
14
|
*
|
|
13
|
-
*
|
|
15
|
+
* Recording stubs stand in for every admitted and every off-list route; each
|
|
14
16
|
* one records that its handler ran, so "404 with no handler run" is asserted,
|
|
15
17
|
* not assumed.
|
|
16
18
|
*
|
|
@@ -34,7 +36,11 @@ import {
|
|
|
34
36
|
} from "../../__tests__/support/hub-sign-in-fake-db.js";
|
|
35
37
|
import { mintCredential, sha256Hex } from "../../lib/hub-sign-in-store.js";
|
|
36
38
|
import { prisma } from "../../lib/prisma.js";
|
|
37
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
__resetHubSessionRateBuckets,
|
|
41
|
+
authPreHandlerPlugin,
|
|
42
|
+
HUB_SESSION_ROUTES,
|
|
43
|
+
} from "../auth.js";
|
|
38
44
|
|
|
39
45
|
const db = jest.mocked(prisma);
|
|
40
46
|
|
|
@@ -47,6 +53,61 @@ let spokeA: SeededSpoke;
|
|
|
47
53
|
let spokeB: SeededSpoke;
|
|
48
54
|
let handlerRuns: string[];
|
|
49
55
|
|
|
56
|
+
/** D1's 18 strings plus #8435's two contact routes and #8438's two consents routes (22), in the list's order. */
|
|
57
|
+
const ADMITTED = [
|
|
58
|
+
"GET /v1/hub-sign-in/me",
|
|
59
|
+
"GET /v1/member/pieces",
|
|
60
|
+
"GET /v1/member/messages",
|
|
61
|
+
"GET /v1/member/messages/:id/file",
|
|
62
|
+
"GET /v1/member/purchases",
|
|
63
|
+
"GET /v1/member/appointments",
|
|
64
|
+
"GET /v1/member/artifacts",
|
|
65
|
+
"GET /v1/member/artifacts/:pieceId/versions/:version/file",
|
|
66
|
+
"GET /v1/member/meeting-types",
|
|
67
|
+
"GET /v1/member/availability",
|
|
68
|
+
"GET /v1/member/request-form",
|
|
69
|
+
"GET /v1/member/billing",
|
|
70
|
+
"POST /v1/member/artifacts/:pieceId/review",
|
|
71
|
+
"POST /v1/member/bookings",
|
|
72
|
+
"POST /v1/member/requests",
|
|
73
|
+
"POST /v1/member/messages",
|
|
74
|
+
"PATCH /v1/member/profile",
|
|
75
|
+
"GET /v1/member/profile/contact",
|
|
76
|
+
"PATCH /v1/member/profile/contact",
|
|
77
|
+
"POST /v1/member/deletion-request",
|
|
78
|
+
"GET /v1/member/consents",
|
|
79
|
+
"POST /v1/member/consents",
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
/** Every route the credential must NOT reach, each a recording stub. */
|
|
83
|
+
const OFF_LIST = [
|
|
84
|
+
"GET /v1/consent/platform",
|
|
85
|
+
"POST /v1/consent/platform",
|
|
86
|
+
"GET /v1/member/other",
|
|
87
|
+
"GET /v1/member/messages/:id",
|
|
88
|
+
"GET /v1/waitlist-signups/me",
|
|
89
|
+
"GET /v1/flow-checkouts/me",
|
|
90
|
+
"GET /v1/subscription/me",
|
|
91
|
+
"POST /v1/subscription/portal-session",
|
|
92
|
+
"GET /v1/auth/tokens",
|
|
93
|
+
"PATCH /v1/user/profile",
|
|
94
|
+
"POST /v1/hub-sign-in/codes",
|
|
95
|
+
// D7's near misses — one method or one segment from an admitted entry.
|
|
96
|
+
"GET /v1/member/requests",
|
|
97
|
+
"GET /v1/member/profile",
|
|
98
|
+
"POST /v1/member/purchases",
|
|
99
|
+
"DELETE /v1/member/profile",
|
|
100
|
+
"GET /v1/member/artifacts/:pieceId",
|
|
101
|
+
"PUT /v1/member/messages",
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
/** A registered URL with each `:param` filled in. */
|
|
105
|
+
const concrete = (url: string) =>
|
|
106
|
+
url
|
|
107
|
+
.replace(":pieceId", "piece_1")
|
|
108
|
+
.replace(":version", "1")
|
|
109
|
+
.replace(":id", "lead_1");
|
|
110
|
+
|
|
50
111
|
/** A live credential for (user, spoke), stored the way the token route stores it. */
|
|
51
112
|
function grant(
|
|
52
113
|
s: SeededSpoke,
|
|
@@ -89,22 +150,10 @@ function buildApp(): FastifyInstance {
|
|
|
89
150
|
};
|
|
90
151
|
};
|
|
91
152
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
server.get("/v1/member/messages/:id/file", echo("file"));
|
|
97
|
-
|
|
98
|
-
// Everything else the credential must NOT reach.
|
|
99
|
-
server.get("/v1/member/other", echo("other"));
|
|
100
|
-
server.get("/v1/member/messages/:id", echo("message-item"));
|
|
101
|
-
server.get("/v1/waitlist-signups/me", echo("waitlist-signups"));
|
|
102
|
-
server.get("/v1/flow-checkouts/me", echo("flow-checkouts"));
|
|
103
|
-
server.get("/v1/subscription/me", echo("subscription"));
|
|
104
|
-
server.post("/v1/subscription/portal-session", echo("portal"));
|
|
105
|
-
server.get("/v1/auth/tokens", echo("tokens"));
|
|
106
|
-
server.patch("/v1/user/profile", echo("profile"));
|
|
107
|
-
server.post("/v1/hub-sign-in/codes", echo("codes"));
|
|
153
|
+
for (const route of [...ADMITTED, ...OFF_LIST]) {
|
|
154
|
+
const [method, url] = route.split(" ") as [string, string];
|
|
155
|
+
server.route({ method: method as "GET", url, handler: echo(route) });
|
|
156
|
+
}
|
|
108
157
|
return server;
|
|
109
158
|
}
|
|
110
159
|
|
|
@@ -112,6 +161,7 @@ beforeEach(async () => {
|
|
|
112
161
|
jest.resetAllMocks();
|
|
113
162
|
state = installHubSignInFakeDb(db);
|
|
114
163
|
handlerRuns = [];
|
|
164
|
+
__resetHubSessionRateBuckets();
|
|
115
165
|
user = seedUser(state);
|
|
116
166
|
spokeA = seedSpoke(state, "a.example.com");
|
|
117
167
|
spokeB = seedSpoke(state, "b.example.com");
|
|
@@ -124,35 +174,36 @@ afterEach(async () => {
|
|
|
124
174
|
});
|
|
125
175
|
|
|
126
176
|
describe("HUB_SESSION_ROUTES", () => {
|
|
127
|
-
it("is exactly the
|
|
128
|
-
expect([...HUB_SESSION_ROUTES]).toEqual(
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
177
|
+
it("is exactly the 20 entries, by method and registered URL — never a prefix (AC-1)", () => {
|
|
178
|
+
expect([...HUB_SESSION_ROUTES]).toEqual(ADMITTED);
|
|
179
|
+
expect(HUB_SESSION_ROUTES.length).toBe(22);
|
|
180
|
+
for (const route of HUB_SESSION_ROUTES) {
|
|
181
|
+
expect({
|
|
182
|
+
route,
|
|
183
|
+
open: route.endsWith("/") || route.includes("*"),
|
|
184
|
+
}).toEqual({
|
|
185
|
+
route,
|
|
186
|
+
open: false,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
134
189
|
});
|
|
135
190
|
});
|
|
136
191
|
|
|
137
192
|
describe("hub credential — admitted routes", () => {
|
|
138
|
-
it.each(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
["GET", "/v1/member/messages/lead_1/file", "file"],
|
|
143
|
-
])(
|
|
144
|
-
"%s %s is admitted as the credential's person, bound to the spoke",
|
|
145
|
-
async (method, url, name) => {
|
|
193
|
+
it.each(ADMITTED)(
|
|
194
|
+
"%s is admitted as the credential's person, bound to the spoke",
|
|
195
|
+
async (route) => {
|
|
196
|
+
const [method, url] = route.split(" ") as [string, string];
|
|
146
197
|
const credential = grant(spokeA);
|
|
147
198
|
|
|
148
199
|
const res = await app.inject({
|
|
149
200
|
method: method as "GET",
|
|
150
|
-
url,
|
|
201
|
+
url: concrete(url),
|
|
151
202
|
headers: headers(credential),
|
|
152
203
|
});
|
|
153
204
|
|
|
154
205
|
expect(res.statusCode).toBe(200);
|
|
155
|
-
expect(handlerRuns).toEqual([
|
|
206
|
+
expect(handlerRuns).toEqual([route]);
|
|
156
207
|
const body = res.json();
|
|
157
208
|
expect(body.userId).toBe(user.id);
|
|
158
209
|
expect(body.hubSession).toEqual({
|
|
@@ -194,6 +245,28 @@ describe("hub credential — every other route is 404 with no handler run (AC-7)
|
|
|
194
245
|
expect(handlerRuns).toEqual([]);
|
|
195
246
|
});
|
|
196
247
|
|
|
248
|
+
it.each(
|
|
249
|
+
OFF_LIST.slice(-6).map((route) => route.split(" ") as [string, string]),
|
|
250
|
+
)(
|
|
251
|
+
"the registered near miss %s %s is still the preHandler's 404 (#8220 D7)",
|
|
252
|
+
async (method, url) => {
|
|
253
|
+
const credential = grant(spokeA);
|
|
254
|
+
|
|
255
|
+
const res = await app.inject({
|
|
256
|
+
method: method as "GET",
|
|
257
|
+
url: concrete(url),
|
|
258
|
+
headers: headers(credential),
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
expect(res.statusCode).toBe(404);
|
|
262
|
+
expect(res.json()).toEqual({
|
|
263
|
+
ok: false,
|
|
264
|
+
error: { code: "not_found", message: "Not found" },
|
|
265
|
+
});
|
|
266
|
+
expect(handlerRuns).toEqual([]);
|
|
267
|
+
},
|
|
268
|
+
);
|
|
269
|
+
|
|
197
270
|
it("the list is method-exact: HEAD on an admitted URL is not admitted", async () => {
|
|
198
271
|
const credential = grant(spokeA);
|
|
199
272
|
const res = await app.inject({
|
|
@@ -308,3 +381,163 @@ describe("hub credential — it is a bearer and nothing else", () => {
|
|
|
308
381
|
expect(res.body).not.toContain(credential);
|
|
309
382
|
});
|
|
310
383
|
});
|
|
384
|
+
|
|
385
|
+
// ─── #8220 D2 — the per-(person, spoke) budget (AC-7) ───────────────────────
|
|
386
|
+
|
|
387
|
+
describe("hub credential — the per-person budget at each spoke (#8220 D2, AC-7)", () => {
|
|
388
|
+
const T0 = Date.UTC(2026, 9, 11, 12, 0, 0);
|
|
389
|
+
let now: number;
|
|
390
|
+
let clock: jest.SpyInstance<number, []>;
|
|
391
|
+
|
|
392
|
+
beforeEach(() => {
|
|
393
|
+
now = T0;
|
|
394
|
+
clock = jest.spyOn(Date, "now").mockImplementation(() => now);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
afterEach(() => {
|
|
398
|
+
clock.mockRestore();
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
const read = (credential: string, s: SeededSpoke = spokeA) =>
|
|
402
|
+
app.inject({
|
|
403
|
+
method: "GET",
|
|
404
|
+
url: "/v1/member/pieces",
|
|
405
|
+
headers: headers(credential, s),
|
|
406
|
+
});
|
|
407
|
+
const write = (credential: string, s: SeededSpoke = spokeA) =>
|
|
408
|
+
app.inject({
|
|
409
|
+
method: "POST",
|
|
410
|
+
url: "/v1/member/messages",
|
|
411
|
+
headers: headers(credential, s),
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
async function times(
|
|
415
|
+
n: number,
|
|
416
|
+
send: () => ReturnType<typeof read>,
|
|
417
|
+
): Promise<number[]> {
|
|
418
|
+
const statuses: number[] = [];
|
|
419
|
+
for (let i = 0; i < n; i += 1) statuses.push((await send()).statusCode);
|
|
420
|
+
return statuses;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
it("a person's 61st admitted request at one spoke in one minute is 429 with Retry-After and the rate_limited body, no handler run", async () => {
|
|
424
|
+
const credential = grant(spokeA);
|
|
425
|
+
expect(new Set(await times(60, () => read(credential)))).toEqual(
|
|
426
|
+
new Set([200]),
|
|
427
|
+
);
|
|
428
|
+
handlerRuns = [];
|
|
429
|
+
now += 15_000;
|
|
430
|
+
|
|
431
|
+
const res = await read(credential);
|
|
432
|
+
|
|
433
|
+
expect(res.statusCode).toBe(429);
|
|
434
|
+
expect(res.headers["retry-after"]).toBe("45");
|
|
435
|
+
expect(res.json()).toEqual({
|
|
436
|
+
ok: false,
|
|
437
|
+
error: { code: "rate_limited", message: "Too many requests" },
|
|
438
|
+
});
|
|
439
|
+
expect(handlerRuns).toEqual([]);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("another person at the same spoke, and the same person at another spoke, are admitted in that minute (SEC-8220-4)", async () => {
|
|
443
|
+
const credential = grant(spokeA);
|
|
444
|
+
await times(60, () => read(credential));
|
|
445
|
+
expect((await read(credential)).statusCode).toBe(429);
|
|
446
|
+
|
|
447
|
+
const other = seedUser(state);
|
|
448
|
+
expect((await read(grant(spokeA, { userId: other.id }))).statusCode).toBe(
|
|
449
|
+
200,
|
|
450
|
+
);
|
|
451
|
+
expect((await read(grant(spokeB), spokeB)).statusCode).toBe(200);
|
|
452
|
+
// The spent bucket opens again when its minute ends.
|
|
453
|
+
now += 60_000;
|
|
454
|
+
expect((await read(credential)).statusCode).toBe(200);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("the 11th write in a minute is 429; after it a read is admitted and a 12th write is 429", async () => {
|
|
458
|
+
const credential = grant(spokeA);
|
|
459
|
+
expect(new Set(await times(10, () => write(credential)))).toEqual(
|
|
460
|
+
new Set([200]),
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
const eleventh = await write(credential);
|
|
464
|
+
expect(eleventh.statusCode).toBe(429);
|
|
465
|
+
expect(Number(eleventh.headers["retry-after"])).toBeGreaterThanOrEqual(1);
|
|
466
|
+
expect((await read(credential)).statusCode).toBe(200);
|
|
467
|
+
expect((await write(credential)).statusCode).toBe(429);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("a refused request is not counted: after 10 admitted and 50 refused writes, a read is admitted", async () => {
|
|
471
|
+
const credential = grant(spokeA);
|
|
472
|
+
await times(10, () => write(credential));
|
|
473
|
+
expect(new Set(await times(50, () => write(credential)))).toEqual(
|
|
474
|
+
new Set([429]),
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
// A counted refusal would make this the 61st request of the minute.
|
|
478
|
+
expect((await read(credential)).statusCode).toBe(200);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("writes refused by the minute ceiling do not advance the hourly count; the 31st write in an hour is 429 and a read after it is admitted", async () => {
|
|
482
|
+
const credential = grant(spokeA, {
|
|
483
|
+
expiresAt: new Date(T0 + 24 * 3_600_000),
|
|
484
|
+
});
|
|
485
|
+
// Minute 0: 10 admitted, 20 refused by the minute ceiling.
|
|
486
|
+
expect(await times(30, () => write(credential))).toEqual([
|
|
487
|
+
...Array<number>(10).fill(200),
|
|
488
|
+
...Array<number>(20).fill(429),
|
|
489
|
+
]);
|
|
490
|
+
// Minutes 1 and 2: 10 admitted each — 30 admitted in the hour.
|
|
491
|
+
now += 60_000;
|
|
492
|
+
expect(new Set(await times(10, () => write(credential)))).toEqual(
|
|
493
|
+
new Set([200]),
|
|
494
|
+
);
|
|
495
|
+
now += 60_000;
|
|
496
|
+
expect(new Set(await times(10, () => write(credential)))).toEqual(
|
|
497
|
+
new Set([200]),
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
// Minute 3: the 31st admitted write would pass the minute's ceiling, but
|
|
501
|
+
// not the hour's — Retry-After is the hour's end, 57 minutes away.
|
|
502
|
+
now += 60_000;
|
|
503
|
+
const thirtyFirst = await write(credential);
|
|
504
|
+
expect(thirtyFirst.statusCode).toBe(429);
|
|
505
|
+
expect(thirtyFirst.headers["retry-after"]).toBe(String(57 * 60));
|
|
506
|
+
expect((await read(credential)).statusCode).toBe(200);
|
|
507
|
+
|
|
508
|
+
// The hour ends: writes are admitted again.
|
|
509
|
+
now = T0 + 60 * 60_000;
|
|
510
|
+
expect((await write(credential)).statusCode).toBe(200);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("100 off-list or refused-credential requests leave the next admitted request admitted — no bucket before admission", async () => {
|
|
514
|
+
const credential = grant(spokeA);
|
|
515
|
+
// 70 off-list requests with the person's own live credential: charged
|
|
516
|
+
// before the route check, they alone would spend the minute's 60.
|
|
517
|
+
for (let i = 0; i < 70; i += 1) {
|
|
518
|
+
const res = await app.inject({
|
|
519
|
+
method: "GET",
|
|
520
|
+
url: "/v1/subscription/me",
|
|
521
|
+
headers: headers(credential),
|
|
522
|
+
});
|
|
523
|
+
expect(res.statusCode).toBe(404);
|
|
524
|
+
}
|
|
525
|
+
for (let i = 0; i < 30; i += 1) {
|
|
526
|
+
expect((await read(mintCredential())).statusCode).toBe(401);
|
|
527
|
+
}
|
|
528
|
+
expect(handlerRuns).toEqual([]);
|
|
529
|
+
|
|
530
|
+
expect((await read(credential)).statusCode).toBe(200);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it("GET /v1/hub-sign-in/me is an admitted route and is charged like the rest", async () => {
|
|
534
|
+
const credential = grant(spokeA);
|
|
535
|
+
await times(60, () => read(credential));
|
|
536
|
+
const res = await app.inject({
|
|
537
|
+
method: "GET",
|
|
538
|
+
url: "/v1/hub-sign-in/me",
|
|
539
|
+
headers: headers(credential),
|
|
540
|
+
});
|
|
541
|
+
expect(res.statusCode).toBe(429);
|
|
542
|
+
});
|
|
543
|
+
});
|
|
@@ -836,10 +836,61 @@ describe("per-visitor forwarding, scope namespace and per-scope env limit (#7394
|
|
|
836
836
|
{ method: "POST", url: "/v1/leads/callback" },
|
|
837
837
|
{ method: "GET", url: AVAILABILITY },
|
|
838
838
|
{ method: "POST", url: BOOK },
|
|
839
|
+
{ method: "POST", url: "/v1/consent/cookie" },
|
|
840
|
+
{ method: "POST", url: "/v1/consent/unsubscribe" },
|
|
839
841
|
]);
|
|
840
842
|
});
|
|
841
843
|
});
|
|
842
844
|
|
|
845
|
+
// #8438 D5/D9 — the cookie banner's log and the unsubscribe landing. Each
|
|
846
|
+
// is registered in its OWN scope (`consent`, `unsubscribe`), exactly as
|
|
847
|
+
// server.ts does, and both are on the forwarded-visitor list.
|
|
848
|
+
describe("the consent routes count each keyed visitor separately, in scopes of their own (#8438)", () => {
|
|
849
|
+
const COOKIE = "/v1/consent/cookie";
|
|
850
|
+
const UNSUBSCRIBE = "/v1/consent/unsubscribe";
|
|
851
|
+
|
|
852
|
+
function consentApp(): FastifyInstance {
|
|
853
|
+
const instance = Fastify({ trustProxy: true });
|
|
854
|
+
for (const [scope, url] of [
|
|
855
|
+
["consent", COOKIE],
|
|
856
|
+
["unsubscribe", UNSUBSCRIBE],
|
|
857
|
+
] as const) {
|
|
858
|
+
instance.register(async (scoped) => {
|
|
859
|
+
scoped.register(rateLimitMiddleware, {
|
|
860
|
+
scope,
|
|
861
|
+
perIpPerMinute: 10,
|
|
862
|
+
trustedKeyDigests: digests(),
|
|
863
|
+
});
|
|
864
|
+
scoped.post(url, async () => ({ ok: true }));
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
return instance;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
async function post(url: string, headers: Record<string, string>): Promise<number> {
|
|
871
|
+
if (!app) throw new Error("no app");
|
|
872
|
+
return (await app.inject({ method: "POST", url, headers })).statusCode;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
it.each([COOKIE, UNSUBSCRIBE])("%s: two visitors behind one site key are counted separately", async (url) => {
|
|
876
|
+
app = consentApp();
|
|
877
|
+
await app.ready();
|
|
878
|
+
for (let i = 0; i < 10; i++) {
|
|
879
|
+
expect(await post(url, keyed("203.0.113.1"))).toBe(200);
|
|
880
|
+
}
|
|
881
|
+
expect(await post(url, keyed("203.0.113.1"))).toBe(429);
|
|
882
|
+
expect(await post(url, keyed("203.0.113.2"))).toBe(200);
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
it("an exhausted consent bucket leaves unsubscribe answering", async () => {
|
|
886
|
+
app = consentApp();
|
|
887
|
+
await app.ready();
|
|
888
|
+
for (let i = 0; i < 10; i++) await post(COOKIE, keyed("203.0.113.1"));
|
|
889
|
+
expect(await post(COOKIE, keyed("203.0.113.1"))).toBe(429);
|
|
890
|
+
expect(await post(UNSUBSCRIBE, keyed("203.0.113.1"))).toBe(200);
|
|
891
|
+
});
|
|
892
|
+
});
|
|
893
|
+
|
|
843
894
|
describe("AC-5 — visitor IP validation and normalisation", () => {
|
|
844
895
|
function identityApp(): FastifyInstance {
|
|
845
896
|
const instance = Fastify({ trustProxy: true });
|