@volter/twin-stripe 0.1.0 → 0.1.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/README.md +39 -5
- package/package.json +2 -2
- package/src/cli.ts +10 -3
- package/src/index.ts +44 -2
- package/src/stripe-budget.ts +181 -0
- package/src/stripe-capabilities.ts +338 -20
- package/src/stripe-conformance.ts +2 -0
- package/src/stripe-connector.ts +65 -4
- package/src/stripe-emit.ts +150 -0
- package/src/stripe-events.ts +176 -6
- package/src/stripe-mirror-ui.ts +25 -10
- package/src/stripe-server.ts +67 -32
- package/src/stripe-twin.ts +1046 -71
- package/test-fixtures/stripe-known-deviations.json +25 -0
- package/test-fixtures/stripe-openapi-operations.json +6384 -0
- package/test-fixtures/stripe-schemas.json +376 -0
package/README.md
CHANGED
|
@@ -40,14 +40,14 @@ world-stripe conformance [--fields FILE] [--root DIR]
|
|
|
40
40
|
`serve` defaults to `simulator` (the stack uses the twin as a writable Stripe).
|
|
41
41
|
Point the real `stripe` SDK at it with `{ host, port, protocol: 'http' }`.
|
|
42
42
|
|
|
43
|
-
##
|
|
43
|
+
## Interaction surfaces
|
|
44
44
|
|
|
45
|
-
1. **
|
|
45
|
+
1. **SDK/API** — *zero edits (preferred):* `STRIPE_TWIN_URL=http://127.0.0.1:PORT node --require @volter/twin/inject your-app` redirects the real `stripe` SDK from `api.stripe.com` to the twin (`cookbook/zero-edit-inject`). For the browser too: `volter-twin proxy --target <app> --map stripe=http://127.0.0.1:PORT` (browser + backend share one twin). *Or* override directly: `new Stripe(key, { host: '127.0.0.1', port: PORT, protocol: 'http' })`.
|
|
46
46
|
2. **API + CLI** — `world-stripe serve` (writable) + drive with `volter-twin status|plan|refs stripe`, then push.
|
|
47
47
|
3. **Read-only** — `world-stripe serve --read-only`: unlimited local reads, no rate limits; writes refuse like Stripe (4xx).
|
|
48
48
|
4. **UI mirror** — `world-stripe mirror` renders a Stripe-dashboard-style view of the twin's state.
|
|
49
49
|
|
|
50
|
-
(See
|
|
50
|
+
(See Getting Started → "Twin interaction surfaces".)
|
|
51
51
|
|
|
52
52
|
Stable on the twin rubric: fidelity, read/write/fork, sync, observability, event emission, and conformance are tracked with explicit coverage gaps.
|
|
53
53
|
|
|
@@ -92,8 +92,22 @@ open lifecycle gates, **customer + subscription discount delete**, **promotion-c
|
|
|
92
92
|
family — **Reviews** (`/approve`), **Value Lists** (+ items), **Rules** — plus a **Radar
|
|
93
93
|
dashboard screen** in the UI mirror.
|
|
94
94
|
|
|
95
|
-
**
|
|
96
|
-
|
|
95
|
+
**Issuing** (modeled) — cardholders/cards/authorizations/transactions/disputes + test helpers
|
|
96
|
+
(present authorization, capture, force capture, fund_balance), including the **real-time
|
|
97
|
+
authorization leg**: an endpoint enrolled for `issuing_authorization.request` receives the
|
|
98
|
+
signed request event **synchronously** at present time and its JSON response
|
|
99
|
+
(`{approved, amount?}`) decides the authorization within Stripe's 2-second window (timeout →
|
|
100
|
+
declined `webhook_timeout`, invalid response → `webhook_error`); **spending_controls**
|
|
101
|
+
(category/country allow+block lists, per_authorization/all_time/calendar-window spending
|
|
102
|
+
limits) are enforced ahead of the webhook with the vendor's `spending_controls` decline
|
|
103
|
+
reason; and **captures debit the issuing balance** (`balance_transaction` type
|
|
104
|
+
`issuing_transaction`, `balance_type: 'issuing'`, served in `GET /v1/balance`'s `issuing`
|
|
105
|
+
section). Remaining issuing gaps (authorization holds, expire/increment/reverse test
|
|
106
|
+
helpers) are tracked todos in `stripe-capabilities.ts`. **Terminal**
|
|
107
|
+
(locations/readers/connection tokens + process_payment_intent) is likewise modeled — the old
|
|
108
|
+
"Planned" listing for both families was stale.
|
|
109
|
+
|
|
110
|
+
**Planned** (known-missing, will do) — **Treasury**
|
|
97
111
|
(financial accounts), **Climate**, **Financial Connections**, **Entitlements**, **Billing**
|
|
98
112
|
credit grants + usage alerts, **Connect** remaining surfaces (account sessions, connected-account
|
|
99
113
|
payouts, top-ups, payout reverse), **Reporting/Sigma**, the legacy **Sources** API, and
|
|
@@ -108,3 +122,23 @@ todo list — every entry not explicitly out-of-scope is a tracked gap.)
|
|
|
108
122
|
- Real **money settlement / bank movement** (proposed — pending owner approval): actually moving
|
|
109
123
|
funds is real-world infra, not the API — the payout/balance_transaction/balance *objects* and
|
|
110
124
|
their lifecycle are modeled.
|
|
125
|
+
|
|
126
|
+
## Rate budget — the fail-closed backstop on live calls
|
|
127
|
+
|
|
128
|
+
`liveStripeExecute` is the **one place** this pack issues a live request, so every call it makes is charged
|
|
129
|
+
against a persistent, fail-closed spend ledger **before** the request goes out. Past the ceiling, or
|
|
130
|
+
while a `Retry-After`/429 cooldown is armed, it **throws instead of calling**. The ledger is keyed by
|
|
131
|
+
vendor and a hash of the credential (limits are per credential, so it is deliberately *not*
|
|
132
|
+
cwd-scoped) and persists across processes, so a fresh process does not get a fresh allowance; a
|
|
133
|
+
corrupt ledger counts as a **full** window rather than zero spend. There is no option to disable it,
|
|
134
|
+
and no value you can pass for `budget` that yields an unguarded client — an injected budget is
|
|
135
|
+
validated by *method identity*, so a subclass or a `Proxy` that replaces `checkBudget` is refused.
|
|
136
|
+
|
|
137
|
+
The declared numbers: **120 weighted units / 60s** = 2 requests/second — 2% of Stripe's documented 100/s live mode and 8% of the 25/s a sandbox key or any single endpoint gets. `POST`/`DELETE` cost 2 (not a published ratio — a judgement call about blast radius: a write creates a charge, refund or receipt e-mail and cannot be taken back) and `POST /v1/payouts` costs 5 (documented at 15 creates/second). Stripe's own window is a **second** while this one is a minute, so an intra-second burst reaches Stripe's limiter first; the 429 cooldown is the backstop for that shape.
|
|
138
|
+
|
|
139
|
+
The mechanism is **shared and vendor-agnostic** — it lives in the kernel (`@volter/twin` →
|
|
140
|
+
`control-plane/src/rateBudget.ts`); what lives here in [`src/stripe-budget.ts`](src/stripe-budget.ts) is this vendor's
|
|
141
|
+
**declaration** (window, ceiling, per-endpoint weights, and a `reason` citing the limits above) plus
|
|
142
|
+
the vendor-bound `StripeBudget`. The rule is ratified as
|
|
143
|
+
[ARCHITECTURE.md](../../../ARCHITECTURE.md) **D8**, and the kernel module's header documents what the
|
|
144
|
+
guard does *not* guarantee — read that before trusting it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/twin-stripe",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Local Stripe twin \u2014 a faithful, stateful local Stripe API your real `stripe` SDK talks to unmodified. Mirror, simulate, and fork. Built on @volter/twin.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"react-dom": "^19.2.7"
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
|
-
"@volter/twin": "0.1.
|
|
56
|
+
"@volter/twin": "0.1.1"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@volter/twin": "0.1.0",
|
package/src/cli.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { keepProcessAlive } from '@volter/twin/lifecycle';
|
|
2
3
|
// world-stripe CLI: serve the Stripe API twin, the mirror UI, or run conformance.
|
|
3
4
|
import { hasFlag, optionValue } from '@volter/twin/args';
|
|
4
5
|
import { createStripeTwinServer } from './stripe-server.ts';
|
|
@@ -12,11 +13,17 @@ const readOnly = hasFlag(rest, '--read-only'); // a twin accepts writes unless s
|
|
|
12
13
|
if (cmd === 'serve') {
|
|
13
14
|
const s = createStripeTwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}) });
|
|
14
15
|
process.stdout.write(`stripe twin (spec-correct API)${readOnly ? ' [read-only]' : ''} at http://127.0.0.1:${s.port}\n`);
|
|
15
|
-
await
|
|
16
|
+
await keepProcessAlive();
|
|
16
17
|
} else if (cmd === 'mirror') {
|
|
17
18
|
const s = createStripeMirrorServer({ ...(root ? { root } : {}), ...(port ? { port } : {}) });
|
|
18
19
|
process.stdout.write(`stripe mirror UI (dashboard) at http://127.0.0.1:${s.port}\n`);
|
|
19
|
-
await
|
|
20
|
+
await keepProcessAlive();
|
|
21
|
+
} else if (cmd === 'emit') {
|
|
22
|
+
// DELIVER: fire a signed, vendor-shaped event synthesized from twin state at the app's
|
|
23
|
+
// registered webhook endpoint(s). `emit --list` shows what's emittable right now.
|
|
24
|
+
const { runEmitCli } = await import('@volter/twin');
|
|
25
|
+
const { stripeEmitter } = await import('./stripe-emit.ts');
|
|
26
|
+
process.exit(await runEmitCli(stripeEmitter, rest));
|
|
20
27
|
} else if (cmd === 'conformance') {
|
|
21
28
|
// dev-only; lazy so the bin runs without @volter/twin-tooling
|
|
22
29
|
const { checkStripeConformance, loadStripeKnownDeviations, loadStripeSchemas, stripeCoverage } = await import('./stripe-conformance.ts');
|
|
@@ -25,5 +32,5 @@ if (cmd === 'serve') {
|
|
|
25
32
|
process.stdout.write(`${JSON.stringify({ ...report, coverage: stripeCoverage(schemas, { ...(root ? { root } : {}) }) }, null, 2)}\n`);
|
|
26
33
|
if (!report.ok) process.exitCode = 1;
|
|
27
34
|
} else {
|
|
28
|
-
process.stdout.write('Usage: world-stripe serve|mirror|conformance [--port N] [--root DIR] [--read-only]\n');
|
|
35
|
+
process.stdout.write('Usage: world-stripe serve|mirror|emit|conformance [--port N] [--root DIR] [--read-only]\n world-stripe emit --list [--root DIR] | emit <event.type> <subject-id> [--root DIR] [--endpoint URL|we_id] [--json]\n');
|
|
29
36
|
}
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
// @volter/twin-tooling, a dev dependency — not shipped in the runtime API.)
|
|
5
5
|
export { handleStripeTwinRequest } from './stripe-twin.ts';
|
|
6
6
|
export type { StripeRequest, StripeResponse } from './stripe-twin.ts';
|
|
7
|
-
|
|
7
|
+
// The serverless-ready surface: the whole serve path as a plain fetch, plus the one-line
|
|
8
|
+
// Bun.serve wrapper the CLI/standalone lane uses (jira/slack are the reference packs).
|
|
9
|
+
export { createStripeTwinFetch, createStripeTwinServer } from './stripe-server.ts';
|
|
10
|
+
export type { StripeTwinOptions } from './stripe-server.ts';
|
|
8
11
|
export { parseStripeForm } from './stripe-form.ts';
|
|
9
12
|
export {
|
|
10
13
|
clearStripeWebhooks,
|
|
@@ -18,6 +21,8 @@ export {
|
|
|
18
21
|
StripeSignatureVerificationError,
|
|
19
22
|
} from './stripe-events.ts';
|
|
20
23
|
export type { StripeEvent, StripeEventDelivery } from './stripe-events.ts';
|
|
24
|
+
// The DELIVER verb's pack side (kernel seam: @volter/twin emit.ts) — `world-stripe emit`.
|
|
25
|
+
export { stripeEmitter } from './stripe-emit.ts';
|
|
21
26
|
export {
|
|
22
27
|
fullSyncStripe,
|
|
23
28
|
liveStripeExecute,
|
|
@@ -33,15 +38,38 @@ export {
|
|
|
33
38
|
stripeRequestForAction,
|
|
34
39
|
syncStripeFromReal,
|
|
35
40
|
} from './stripe-connector.ts';
|
|
36
|
-
export type { StripeExecute } from './stripe-connector.ts';
|
|
41
|
+
export type { StripeExecute, LiveStripeOptions } from './stripe-connector.ts';
|
|
42
|
+
// The client-side rate budget — the fail-closed backstop `liveStripeExecute` routes every live
|
|
43
|
+
// request through. The MECHANISM is the kernel's shared, vendor-agnostic `RateBudget`; what lives
|
|
44
|
+
// here is Stripe's DECLARATION (window/ceiling/per-endpoint weights) plus the vendor-bound
|
|
45
|
+
// bindings. Exported so an operator can inspect spend (`snapshot`) and so a caller can catch
|
|
46
|
+
// `StripeBudgetError` by type; there is deliberately no export that disables the guard.
|
|
47
|
+
export {
|
|
48
|
+
STRIPE_BUDGET_CEILING,
|
|
49
|
+
STRIPE_BUDGET_MAX_RETRY_AFTER_S,
|
|
50
|
+
STRIPE_BUDGET_WINDOW_MS,
|
|
51
|
+
STRIPE_CALL_WEIGHTS,
|
|
52
|
+
STRIPE_RATE_BUDGET,
|
|
53
|
+
StripeBudget,
|
|
54
|
+
StripeBudgetError,
|
|
55
|
+
stripeBudgetPath,
|
|
56
|
+
stripeCallWeight,
|
|
57
|
+
} from './stripe-budget.ts';
|
|
58
|
+
export type { StripeBudgetErrorKind, StripeBudgetOptions, StripeBudgetReservation, StripeBudgetSnapshot } from './stripe-budget.ts';
|
|
37
59
|
export { buildStripeMirrorClient, createStripeMirrorServer, stripeMirrorHtml } from './stripe-mirror-ui.ts';
|
|
38
60
|
|
|
39
61
|
// Registry descriptor (#1): the pack self-describes so tooling can discover it.
|
|
40
62
|
// A consumer does `registerPack(pack)` after importing this package.
|
|
41
63
|
import type { TwinPack } from '@volter/twin';
|
|
64
|
+
import { STRIPE_RATE_BUDGET as RATE_BUDGET } from './stripe-budget.ts';
|
|
65
|
+
import { stripeEmitter } from './stripe-emit.ts';
|
|
42
66
|
export const pack: TwinPack = {
|
|
43
67
|
vendor: 'stripe',
|
|
68
|
+
// The SAME object stripe-budget.ts declares at module load — one source of truth, so registering
|
|
69
|
+
// the pack and importing the connector can never arm two different ceilings.
|
|
70
|
+
rateBudget: RATE_BUDGET,
|
|
44
71
|
transport: 'rest',
|
|
72
|
+
archetype: 'crud',
|
|
45
73
|
bin: 'world-stripe',
|
|
46
74
|
resources: ['charge', 'customer', 'payment_intent', 'setup_intent', 'payment_method', 'subscription', 'price', 'product', 'invoice', 'invoiceitem', 'refund'],
|
|
47
75
|
specSource: 'test-fixtures/stripe-schemas.json (per-object JSON Schemas from Stripe OpenAPI)',
|
|
@@ -49,4 +77,18 @@ export const pack: TwinPack = {
|
|
|
49
77
|
// Stripe.js calls the same-origin '/v1/…' and loads from api.stripe.com — the dev proxy
|
|
50
78
|
// forwards '/v1/' to the twin and strips the absolute host so calls come back same-origin.
|
|
51
79
|
browserRouting: { apiPathPrefix: '/v1/', loaderHost: 'https://api.stripe.com' },
|
|
80
|
+
// Adoption + interception: the official server SDK, the STRIPE_* credential stem, and the
|
|
81
|
+
// one live API host — declared HERE, not in the central maps (TWIN-PACK-CONTRACT exemplar;
|
|
82
|
+
// the pack-facts artifact carries them to covers/inspect-project and inject.cjs).
|
|
83
|
+
// NEXT_PRIVATE_STRIPE_* joined the stem list in the adoption-facts sweep 2026-08-31.
|
|
84
|
+
// `stripeconnect` is deliberately NOT claimed: the Connect area is unmodeled, so a repo
|
|
85
|
+
// holding only that credential is build-surface, not coverage this twin can honor.
|
|
86
|
+
adoption: {
|
|
87
|
+
// Stripe's official Python bindings.
|
|
88
|
+
pypi: ['stripe'],
|
|
89
|
+
sdks: ['stripe'], envStems: ['STRIPE', 'NEXTPRIVATESTRIPE'],
|
|
90
|
+
},
|
|
91
|
+
hosts: [{ host: 'api.stripe.com' }],
|
|
92
|
+
// DELIVER support: signed event synthesis from twin state (`world-stripe emit`).
|
|
93
|
+
emitter: stripeEmitter,
|
|
52
94
|
};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Stripe's CLIENT-SIDE RATE BUDGET — the pack's DECLARATION (the numbers) plus the thin typed
|
|
2
|
+
// bindings `liveStripeExecute` uses. The MECHANISM — the durable token-keyed ledger, the rolling
|
|
3
|
+
// window, reserve-under-lock, the `Retry-After`/429 cooldown, fail-CLOSED on a corrupt ledger —
|
|
4
|
+
// lives ONCE in the vendor-agnostic kernel (`@volter/twin` → `rateBudget.ts`). Read that module's
|
|
5
|
+
// header for the full rationale AND for the honest list of what the guard does not guarantee (an
|
|
6
|
+
// injected clock or ledger path still defeats it — it guards carelessness, not malice).
|
|
7
|
+
//
|
|
8
|
+
// ── WHY THIS EXISTS ─────────────────────────────────────────────────────────────────────────
|
|
9
|
+
// A real ~4.5-DAY vendor lockout (Figma, 2026-07-25) happened because raw API calls were made
|
|
10
|
+
// outside the pack's connector — no cache, no batching, no ceiling. Discipline only binds the code
|
|
11
|
+
// that follows it; a BUDGET binds the code that does not. Stripe is the pack where a runaway loop
|
|
12
|
+
// is not merely rude: its writes MOVE MONEY.
|
|
13
|
+
//
|
|
14
|
+
// ── HOW THE CEILING WAS CHOSEN ──────────────────────────────────────────────────────────────
|
|
15
|
+
// Stripe DOES publish scalar limits, so this models the real thing rather than guessing. From
|
|
16
|
+
// https://docs.stripe.com/rate-limits (read 2026-07-26):
|
|
17
|
+
// • live mode: 100 requests/second
|
|
18
|
+
// • sandbox (test mode): 25 requests/second
|
|
19
|
+
// • an individual endpoint: 25 requests/second unless noted otherwise
|
|
20
|
+
// • Payouts, create: 15 requests/second
|
|
21
|
+
// • Subscriptions: 10 new invoices per subscription per minute
|
|
22
|
+
// • Payment Intents: 1,000 update requests per PaymentIntent per hour
|
|
23
|
+
// Over the limit: 429, with a `Stripe-Rate-Limited-Reason` header naming which limit was hit.
|
|
24
|
+
//
|
|
25
|
+
// The ceiling is 120 weighted units per 60s — 120 calls a minute, i.e. 2 requests/second at the
|
|
26
|
+
// default weight. That is 2% of the live-mode 100/s and 8% of the tightest general limit (25/s,
|
|
27
|
+
// which is what a sandbox key and any single endpoint get). It is more permissive than the kernel's
|
|
28
|
+
// undeclared fallback (30 calls/min) precisely because those limits are documented and are one to
|
|
29
|
+
// three ORDERS of magnitude higher; without them it would not be.
|
|
30
|
+
//
|
|
31
|
+
// It bounds the 60-second AVERAGE; it does NOT pace (the kernel refuses, it never sleeps — see its
|
|
32
|
+
// header). Stripe's own window is a SECOND, so a tight loop can legitimately fire all 120 inside
|
|
33
|
+
// one second here — 120 req/s, above the sandbox 25/s — and in that shape Stripe's 429 arrives
|
|
34
|
+
// before this ceiling does. The backstop is then the cooldown: the guard reads the 429 off the
|
|
35
|
+
// response and refuses every later call without touching Stripe. The honest claim is therefore
|
|
36
|
+
// "bounds the minute, and converts Stripe's first 429 into a hard stop", not "refuses before Stripe
|
|
37
|
+
// ever 429s". Pacing is the caller's job; this is the ceiling underneath it.
|
|
38
|
+
//
|
|
39
|
+
// ── HOW THE WEIGHTS WERE CHOSEN ─────────────────────────────────────────────────────────────
|
|
40
|
+
// • `POST` / `DELETE` cost 2. Stripe counts them the same as a read, so this is NOT a published
|
|
41
|
+
// ratio — it is a judgement call about blast radius: a write creates a charge, a refund, a
|
|
42
|
+
// subscription or a receipt e-mail, and unlike a read it cannot be taken back. Halving the rate
|
|
43
|
+
// at which a runaway loop can do that is worth the cost to a legitimate push, which is small
|
|
44
|
+
// (a push is a handful of writes).
|
|
45
|
+
// • `POST /v1/payouts` costs 5, because Stripe documents payout creation at 15/second — six times
|
|
46
|
+
// tighter than live mode's 100/s — and a payout is the single most irreversible object in the
|
|
47
|
+
// API. At weight 5 at most 24 land in a window: 0.4/s against a documented 15/s.
|
|
48
|
+
import {
|
|
49
|
+
declareRateBudget,
|
|
50
|
+
rateBudgetPath,
|
|
51
|
+
rateBudgetWeight,
|
|
52
|
+
RateBudget,
|
|
53
|
+
type RateBudgetDeclaration,
|
|
54
|
+
type RateBudgetOptions,
|
|
55
|
+
type RateBudgetReservation,
|
|
56
|
+
type RateBudgetSnapshot,
|
|
57
|
+
} from '@volter/twin';
|
|
58
|
+
|
|
59
|
+
const VENDOR = 'stripe';
|
|
60
|
+
|
|
61
|
+
/** Rolling window, in ms. Spend older than this is pruned. */
|
|
62
|
+
export const STRIPE_BUDGET_WINDOW_MS = 60_000;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Weighted units allowed inside one window. 120/60s = 120 reads a minute (2/second) — 2% of
|
|
66
|
+
* Stripe's documented live-mode 100/second and 8% of the 25/second a sandbox key or any single
|
|
67
|
+
* endpoint gets.
|
|
68
|
+
*/
|
|
69
|
+
export const STRIPE_BUDGET_CEILING = 120;
|
|
70
|
+
|
|
71
|
+
/** Seconds. A `Retry-After` above this means the key is throttled hard — fail loudly, don't sleep. */
|
|
72
|
+
export const STRIPE_BUDGET_MAX_RETRY_AFTER_S = 300;
|
|
73
|
+
|
|
74
|
+
/** Per-call cost, keyed by `"<METHOD> <path>"`. See the header for what is documented vs. judged. */
|
|
75
|
+
export const STRIPE_CALL_WEIGHTS = {
|
|
76
|
+
/** `POST /v1/payouts` — documented at 15 creates/second, and the most irreversible object here. */
|
|
77
|
+
payout: 5,
|
|
78
|
+
/** Any other `POST`/`DELETE` — creates charges, refunds, subscriptions, receipt e-mail. */
|
|
79
|
+
write: 2,
|
|
80
|
+
/** Reads: list/retrieve. */
|
|
81
|
+
other: 1,
|
|
82
|
+
} as const;
|
|
83
|
+
|
|
84
|
+
/** THE PACK'S DECLARATION — pure data, the only Stripe-specific thing in the whole budget. */
|
|
85
|
+
export const STRIPE_RATE_BUDGET: RateBudgetDeclaration = {
|
|
86
|
+
windowMs: STRIPE_BUDGET_WINDOW_MS,
|
|
87
|
+
ceiling: STRIPE_BUDGET_CEILING,
|
|
88
|
+
defaultWeight: STRIPE_CALL_WEIGHTS.other,
|
|
89
|
+
maxRetryAfterSeconds: STRIPE_BUDGET_MAX_RETRY_AFTER_S,
|
|
90
|
+
rules: [
|
|
91
|
+
{ match: '^POST /v1/payouts$', weight: STRIPE_CALL_WEIGHTS.payout },
|
|
92
|
+
{ match: '^(POST|DELETE) ', weight: STRIPE_CALL_WEIGHTS.write },
|
|
93
|
+
],
|
|
94
|
+
reason:
|
|
95
|
+
'Stripe documents scalar limits (docs.stripe.com/rate-limits, read 2026-07-26): 100 requests/' +
|
|
96
|
+
'second in live mode, 25/second in sandbox, 25/second for an individual endpoint unless noted, ' +
|
|
97
|
+
'15 payout creates/second, 10 new invoices per subscription per minute, 1,000 PaymentIntent ' +
|
|
98
|
+
'updates per intent per hour; over it, 429 with a Stripe-Rate-Limited-Reason header. 120 ' +
|
|
99
|
+
'weighted units / 60s is 120 reads a minute = 2 requests/second — 2% of live mode and 8% of the ' +
|
|
100
|
+
"tightest general limit. It is more permissive than the kernel's undeclared fallback (30 calls/" +
|
|
101
|
+
'min) BECAUSE those documented limits are one to three orders of magnitude higher. POST/DELETE ' +
|
|
102
|
+
'cost 2 — NOT a published ratio, a judgement call about blast radius: a write creates a charge, ' +
|
|
103
|
+
'refund or receipt e-mail and cannot be taken back. POST /v1/payouts costs 5 because Stripe ' +
|
|
104
|
+
'documents payout creation at 15/second, six times tighter than live mode. The window bounds the ' +
|
|
105
|
+
"60s AVERAGE and does not pace, and Stripe's own window is a SECOND, so an intra-second burst " +
|
|
106
|
+
"reaches Stripe's limiter first — the 429 cooldown is the backstop for that shape, not this " +
|
|
107
|
+
'ceiling.',
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Declared at module load, so merely importing this module (which `stripe-connector.ts` does) is
|
|
111
|
+
// enough to arm the real ceiling. `RateBudget` reads its policy live precisely so this declaration
|
|
112
|
+
// takes effect the moment it lands, and constructing through the subclass below (which imports this
|
|
113
|
+
// module) is what makes the ordering a non-issue in practice.
|
|
114
|
+
declareRateBudget(VENDOR, STRIPE_RATE_BUDGET);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Price one call. The key is `"<METHOD> <path>"` — Stripe's executor carries filters as separate
|
|
118
|
+
* `params` rather than in the path, so there is no query string to split off here; a `?` is still
|
|
119
|
+
* handled defensively in case a caller inlines one. An unclassified endpoint costs `defaultWeight`.
|
|
120
|
+
*/
|
|
121
|
+
export function stripeCallWeight(method: string, path: string): number {
|
|
122
|
+
const { bare, query } = splitQuery(path);
|
|
123
|
+
// UPPER-CASE the method: `fetch` normalizes a known lowercase method before sending, so
|
|
124
|
+
// `execute('post', …)` really does issue a POST and must be priced as one.
|
|
125
|
+
return rateBudgetWeight(VENDOR, `${String(method).toUpperCase()} ${bare}`, query);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* `/v1/x?a=1` -> `{ bare: '/v1/x', query: { a: '1' } }`. Rules match the path; `whenQuery*` the query.
|
|
130
|
+
*
|
|
131
|
+
* NORMALIZED, because the anchored rules are otherwise trivially evaded (§9 finding, 2026-07-26):
|
|
132
|
+
* `fetch` upper-cases a known method before sending, so `execute('post', …)` issues a real WRITE
|
|
133
|
+
* that a `^POST ` rule would price as a read; and a trailing slash makes a path miss a `$` anchor
|
|
134
|
+
* while most routers treat it as the same endpoint. Both are input variations, not attacks, and
|
|
135
|
+
* either one silently voids the "expensive endpoints are priced up" claim the ceiling rests on.
|
|
136
|
+
*/
|
|
137
|
+
function splitQuery(path: string): { bare: string; query: Record<string, string> } {
|
|
138
|
+
const at = path.indexOf('?');
|
|
139
|
+
const query: Record<string, string> = {};
|
|
140
|
+
if (at !== -1) for (const [k, v] of new URLSearchParams(path.slice(at + 1))) query[k] = v;
|
|
141
|
+
const raw = at === -1 ? path : path.slice(0, at);
|
|
142
|
+
// Collapse a trailing slash, but never turn the root path into the empty string.
|
|
143
|
+
const bare = raw.length > 1 && raw.endsWith('/') ? raw.replace(/\/+$/, '') : raw;
|
|
144
|
+
return { bare, query };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Where Stripe's ledger lives. Key-keyed and cwd-independent by default (limits attach to the
|
|
148
|
+
* account behind the secret key, so a cwd-scoped ledger would hand the same key a fresh allowance
|
|
149
|
+
* in every checkout, worktree and CI matrix leg); pass `root` for world-scoped accounting.
|
|
150
|
+
*
|
|
151
|
+
* A live key and a test key are different strings, so they get different ledgers — which matches
|
|
152
|
+
* Stripe, whose live and sandbox limits are separate (100/s vs 25/s). */
|
|
153
|
+
export function stripeBudgetPath(opts: { root?: string; token?: string } | string = {}): string {
|
|
154
|
+
const o = typeof opts === 'string' ? { root: opts } : opts;
|
|
155
|
+
// VENDOR spread LAST: a loosely-typed `{ vendor: 'other', … }` slipping through (TypeScript's
|
|
156
|
+
// excess-property check only catches object literals) must not redirect this pack's ledger to
|
|
157
|
+
// another vendor's file.
|
|
158
|
+
return rateBudgetPath({ ...o, vendor: VENDOR });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Construction options for Stripe's budget. The vendor is fixed; everything else may only TIGHTEN. */
|
|
162
|
+
export type StripeBudgetOptions = Omit<RateBudgetOptions, 'vendor'>;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Stripe's budget — the shared kernel guard bound to this vendor's declaration. A real subclass,
|
|
166
|
+
* not an alias, so `budget instanceof StripeBudget` in `liveStripeExecute` means "a budget that
|
|
167
|
+
* accounts against STRIPE's ledger under STRIPE's ceiling": another vendor's `RateBudget` (with its
|
|
168
|
+
* own, possibly larger, ceiling) is NOT assignable there.
|
|
169
|
+
*/
|
|
170
|
+
export class StripeBudget extends RateBudget {
|
|
171
|
+
constructor(opts: StripeBudgetOptions = {}) {
|
|
172
|
+
super({ ...opts, vendor: VENDOR });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The typed refusal. One error class shared with every other vendor's budget; `err.vendor` says
|
|
177
|
+
* which one refused, and `err.kind` says why. */
|
|
178
|
+
export { RateBudgetError as StripeBudgetError } from '@volter/twin';
|
|
179
|
+
export type { RateBudgetErrorKind as StripeBudgetErrorKind } from '@volter/twin';
|
|
180
|
+
export type StripeBudgetReservation = RateBudgetReservation;
|
|
181
|
+
export type StripeBudgetSnapshot = RateBudgetSnapshot;
|