@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 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
- ## Four ways to use it
43
+ ## Interaction surfaces
44
44
 
45
- 1. **Mock SDK** — *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' })`.
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 the repo's GETTING-STARTED → "Four ways to use a twin".)
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
- **Planned** (known-missing, will do) — **Issuing** (cardholders/cards/authorizations/
96
- transactions + test helpers), **Terminal** (readers/locations/connection tokens), **Treasury**
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.0",
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.0"
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 new Promise(() => {});
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 new Promise(() => {});
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
- export { createStripeTwinServer } from './stripe-server.ts';
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;