@volter/twin-stripe 0.1.0 → 0.1.2
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
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// The Stripe DELIVER verb — `world-stripe emit` (feature-sweep friction: hand-constructing
|
|
2
|
+
// checkout.session.completed / invoice.paid envelopes + HMAC signing to poke an app's webhook
|
|
3
|
+
// handler). This is the pack side of the kernel's emit seam (@volter/twin emit.ts): the
|
|
4
|
+
// catalog of emittable event types, endpoint registrations read FROM TWIN STATE AT REST
|
|
5
|
+
// (webhook_endpoint rows minted by POST /v1/webhook_endpoints — the CLI runs in its own
|
|
6
|
+
// process, so the in-memory delivery registry in stripe-events.ts does not apply), and
|
|
7
|
+
// synthesis of the exact envelope real Stripe delivers, signed with the DESTINATION
|
|
8
|
+
// endpoint's own whsec_twin_* so `stripe.webhooks.constructEvent(rawBody, header, secret)`
|
|
9
|
+
// in the app's unmodified SDK verifies the delivery unchanged.
|
|
10
|
+
//
|
|
11
|
+
// DELIVER does not transition state: it snapshots the subject AS IT IS in the twin and fires
|
|
12
|
+
// the named event about it. Completing a checkout session / paying an invoice is the twin
|
|
13
|
+
// API's job; this verb answers "my app's webhook handler needs to SEE the event, now."
|
|
14
|
+
import { projectResources, type EmitEndpoint, type EmittableEvent, type SynthesizedDelivery, type TwinEmitter } from '@volter/twin';
|
|
15
|
+
import { OBJECT_NAME, TWIN_API_VERSION, view } from './stripe-twin.ts';
|
|
16
|
+
import { STRIPE_WEBHOOK_FALLBACK_SECRET, generateTestHeaderString } from './stripe-events.ts';
|
|
17
|
+
|
|
18
|
+
const SERVICE = 'stripe';
|
|
19
|
+
|
|
20
|
+
// event type → the twin resource type whose current state becomes `data.object`.
|
|
21
|
+
// The catalog is every event type the twin's own write path can produce (eventTypeFor's
|
|
22
|
+
// range) — the emit verb can re-fire anything the twin emits organically.
|
|
23
|
+
const EMITTABLE: Record<string, string> = {
|
|
24
|
+
// payment intents
|
|
25
|
+
'payment_intent.created': 'payment_intent',
|
|
26
|
+
'payment_intent.succeeded': 'payment_intent',
|
|
27
|
+
'payment_intent.processing': 'payment_intent',
|
|
28
|
+
'payment_intent.requires_action': 'payment_intent',
|
|
29
|
+
'payment_intent.amount_capturable_updated': 'payment_intent',
|
|
30
|
+
'payment_intent.payment_failed': 'payment_intent',
|
|
31
|
+
'payment_intent.canceled': 'payment_intent',
|
|
32
|
+
// charges + disputes
|
|
33
|
+
'charge.succeeded': 'charge',
|
|
34
|
+
'charge.captured': 'charge',
|
|
35
|
+
'charge.refunded': 'charge',
|
|
36
|
+
'charge.dispute.created': 'dispute',
|
|
37
|
+
// customers
|
|
38
|
+
'customer.created': 'customer',
|
|
39
|
+
'customer.updated': 'customer',
|
|
40
|
+
'customer.deleted': 'customer',
|
|
41
|
+
// checkout — the sweep's exact pain
|
|
42
|
+
'checkout.session.completed': 'checkout_session',
|
|
43
|
+
// invoices — the sweep's exact pain
|
|
44
|
+
'invoice.created': 'invoice',
|
|
45
|
+
'invoice.finalized': 'invoice',
|
|
46
|
+
'invoice.paid': 'invoice',
|
|
47
|
+
'invoice.payment_failed': 'invoice',
|
|
48
|
+
'invoice.voided': 'invoice',
|
|
49
|
+
'invoice.marked_uncollectible': 'invoice',
|
|
50
|
+
'invoice.sent': 'invoice',
|
|
51
|
+
// subscriptions
|
|
52
|
+
'customer.subscription.created': 'subscription',
|
|
53
|
+
'customer.subscription.updated': 'subscription',
|
|
54
|
+
'customer.subscription.deleted': 'subscription',
|
|
55
|
+
'customer.subscription.paused': 'subscription',
|
|
56
|
+
'customer.subscription.resumed': 'subscription',
|
|
57
|
+
// payment methods
|
|
58
|
+
'payment_method.attached': 'payment_method',
|
|
59
|
+
'payment_method.detached': 'payment_method',
|
|
60
|
+
// setup intents
|
|
61
|
+
'setup_intent.created': 'setup_intent',
|
|
62
|
+
'setup_intent.succeeded': 'setup_intent',
|
|
63
|
+
// payouts, catalog
|
|
64
|
+
'payout.created': 'payout',
|
|
65
|
+
'payout.paid': 'payout',
|
|
66
|
+
'payout.canceled': 'payout',
|
|
67
|
+
'price.created': 'price',
|
|
68
|
+
'product.created': 'product',
|
|
69
|
+
// issuing — the settlement consumer's diet (issuing_transaction.created) plus the
|
|
70
|
+
// authorization/card/cardholder lifecycle. issuing_authorization.request is emittable
|
|
71
|
+
// too: DELIVER re-fires the request event about an authorization AS IT IS in twin state
|
|
72
|
+
// (the organic synchronous leg lives in the present-authorization flow; this verb lets
|
|
73
|
+
// an app's handler see the event again without re-presenting).
|
|
74
|
+
'issuing_authorization.created': 'issuing_authorization',
|
|
75
|
+
'issuing_authorization.request': 'issuing_authorization',
|
|
76
|
+
'issuing_authorization.updated': 'issuing_authorization',
|
|
77
|
+
'issuing_card.created': 'issuing_card',
|
|
78
|
+
'issuing_card.updated': 'issuing_card',
|
|
79
|
+
'issuing_cardholder.created': 'issuing_cardholder',
|
|
80
|
+
'issuing_cardholder.updated': 'issuing_cardholder',
|
|
81
|
+
'issuing_transaction.created': 'issuing_transaction',
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
function rows(type: string, root?: string): Array<Record<string, unknown>> {
|
|
85
|
+
return projectResources(SERVICE, root).filter((r) => r.type === type);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function liveEndpointRows(root?: string): Array<Record<string, unknown>> {
|
|
89
|
+
return rows('webhook_endpoint', root).filter((w) => w._deleted !== true);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let emitSeq = 0;
|
|
93
|
+
|
|
94
|
+
export const stripeEmitter: TwinEmitter = {
|
|
95
|
+
vendor: 'stripe',
|
|
96
|
+
|
|
97
|
+
events(): EmittableEvent[] {
|
|
98
|
+
return Object.entries(EMITTABLE).map(([type, subjectType]) => ({ type, subjectType }));
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
endpoints(root?: string): EmitEndpoint[] {
|
|
102
|
+
return liveEndpointRows(root).map((w) => ({
|
|
103
|
+
id: String(w.id),
|
|
104
|
+
url: String(w.url ?? ''),
|
|
105
|
+
...(Array.isArray(w.enabled_events) ? { enabledEvents: w.enabled_events.map(String) } : {}),
|
|
106
|
+
}));
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
subjects(subjectType: string, root?: string): string[] {
|
|
110
|
+
return rows(subjectType, root).filter((r) => r._deleted !== true).map((r) => String(r.id));
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
synthesize({ type, subjectId, endpoint, root, occurredAt }): SynthesizedDelivery {
|
|
114
|
+
const subjectType = EMITTABLE[type];
|
|
115
|
+
if (!subjectType) throw new Error(`emit: the stripe pack cannot synthesize "${type}".`);
|
|
116
|
+
const row = rows(subjectType, root).find((r) => String(r.id) === subjectId && r._deleted !== true);
|
|
117
|
+
if (!row) {
|
|
118
|
+
const have = stripeEmitter.subjects(subjectType, root);
|
|
119
|
+
throw new Error(
|
|
120
|
+
`emit: no ${subjectType} "${subjectId}" in stripe twin state — cannot synthesize ${type}. ` +
|
|
121
|
+
(have.length ? `${subjectType} ids in state: ${have.join(', ')}.` : `No ${subjectType} exists in this twin yet (object name: ${OBJECT_NAME[subjectType] ?? subjectType}) — create one through the vendor API first.`),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
// The endpoint's own signing secret, from its state row (real Stripe signs per-endpoint).
|
|
125
|
+
const endpointRow = liveEndpointRows(root).find((w) => String(w.id) === endpoint.id || String(w.url) === endpoint.url);
|
|
126
|
+
const secret = typeof endpointRow?.secret === 'string' && endpointRow.secret ? endpointRow.secret : STRIPE_WEBHOOK_FALLBACK_SECRET;
|
|
127
|
+
|
|
128
|
+
const created = occurredAt ? Math.floor(Date.parse(occurredAt) / 1000) : Math.floor(Date.now() / 1000);
|
|
129
|
+
// Same envelope persistStripeEvent stores for GET /v1/events; `evt_twin_emit_*` marks it
|
|
130
|
+
// operator-fired (never colliding with the write path's organic `evt_twin_<n>` ids).
|
|
131
|
+
const event: Record<string, unknown> = {
|
|
132
|
+
id: `evt_twin_emit_${created}_${++emitSeq}`,
|
|
133
|
+
object: 'event',
|
|
134
|
+
api_version: TWIN_API_VERSION,
|
|
135
|
+
created,
|
|
136
|
+
data: { object: view(subjectType, row) },
|
|
137
|
+
livemode: false,
|
|
138
|
+
pending_webhooks: 1,
|
|
139
|
+
request: { id: null, idempotency_key: null },
|
|
140
|
+
type,
|
|
141
|
+
};
|
|
142
|
+
const payload = JSON.stringify(event);
|
|
143
|
+
const header = generateTestHeaderString({ payload, secret, ...(occurredAt ? { timestamp: created } : {}) });
|
|
144
|
+
return {
|
|
145
|
+
payload,
|
|
146
|
+
headers: { 'content-type': 'application/json', 'stripe-signature': header },
|
|
147
|
+
event,
|
|
148
|
+
};
|
|
149
|
+
},
|
|
150
|
+
};
|
package/src/stripe-events.ts
CHANGED
|
@@ -12,7 +12,12 @@ export type StripeEvent = {
|
|
|
12
12
|
livemode: false;
|
|
13
13
|
data: { object: Record<string, unknown> };
|
|
14
14
|
};
|
|
15
|
-
|
|
15
|
+
// A deliverer MAY return the endpoint's raw HTTP response. Ordinary (asynchronous) event
|
|
16
|
+
// emission ignores the return value; the SYNCHRONOUS real-time-authorization leg
|
|
17
|
+
// (issuing_authorization.request) reads it to honor the endpoint's approve/decline. A
|
|
18
|
+
// fire-and-forget deliverer returning void stays valid for the asynchronous path.
|
|
19
|
+
export type StripeWebhookEndpointResponse = { status: number; body: string };
|
|
20
|
+
export type StripeEventDelivery = (url: string, event: StripeEvent) => Promise<void | StripeWebhookEndpointResponse> | void | StripeWebhookEndpointResponse;
|
|
16
21
|
|
|
17
22
|
// ── Webhook signature verification (Stripe's scheme) ────────────────────────────
|
|
18
23
|
// Real Stripe signs each webhook delivery with an HMAC-SHA256 over `${timestamp}.${payload}`
|
|
@@ -97,15 +102,43 @@ export function constructEvent(payload: string, header: string, secret: string,
|
|
|
97
102
|
}
|
|
98
103
|
|
|
99
104
|
const registry: string[] = [];
|
|
100
|
-
|
|
105
|
+
// Endpoint signing secrets by URL. POST /v1/webhook_endpoints mints a `whsec_twin_*`
|
|
106
|
+
// secret on the WebhookEndpoint object and registers it here so live HTTP delivery
|
|
107
|
+
// signs each POST with THAT endpoint's secret (real Stripe signs per-endpoint). A
|
|
108
|
+
// URL registered without a secret (test seam) is signed with the fallback below so
|
|
109
|
+
// the header is still a real, verifiable signature — never a placeholder.
|
|
110
|
+
const secrets = new Map<string, string>();
|
|
111
|
+
// Subscriptions by URL: the endpoint's enabled_events, carried through registration so the
|
|
112
|
+
// organic fan-out filters like the vendor. A URL registered WITHOUT a list (test seam)
|
|
113
|
+
// receives every event — equivalent to ['*'].
|
|
114
|
+
const subscriptions = new Map<string, string[]>();
|
|
115
|
+
export const STRIPE_WEBHOOK_FALLBACK_SECRET = 'whsec_twin_default';
|
|
116
|
+
export function registerStripeWebhook(url: string, secret?: string, enabledEvents?: string[]): void {
|
|
101
117
|
if (!registry.includes(url)) registry.push(url);
|
|
118
|
+
if (secret) secrets.set(url, secret);
|
|
119
|
+
if (enabledEvents) subscriptions.set(url, enabledEvents.map(String));
|
|
102
120
|
}
|
|
103
121
|
export function unregisterStripeWebhook(url: string): void {
|
|
104
122
|
const i = registry.indexOf(url);
|
|
105
123
|
if (i !== -1) registry.splice(i, 1);
|
|
124
|
+
secrets.delete(url);
|
|
125
|
+
subscriptions.delete(url);
|
|
106
126
|
}
|
|
107
127
|
export function clearStripeWebhooks(): void {
|
|
108
128
|
registry.length = 0;
|
|
129
|
+
secrets.clear();
|
|
130
|
+
subscriptions.clear();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Does an enabled_events list subscribe an endpoint to `type`? Stripe semantics: '*'
|
|
134
|
+
// matches everything, 'issuing_authorization.*' matches the family, else exact. Shared by
|
|
135
|
+
// the organic fan-out below and the synchronous issuing leg's enrollment check.
|
|
136
|
+
export function stripeEventMatches(enabledEvents: unknown, type: string): boolean {
|
|
137
|
+
if (!Array.isArray(enabledEvents)) return false;
|
|
138
|
+
return enabledEvents.some((p) => {
|
|
139
|
+
const s = String(p);
|
|
140
|
+
return s === '*' || (s.endsWith('.*') && type.startsWith(s.slice(0, -1))) || s === type;
|
|
141
|
+
});
|
|
109
142
|
}
|
|
110
143
|
export function listStripeWebhooks(): string[] {
|
|
111
144
|
return [...registry];
|
|
@@ -128,6 +161,16 @@ export function eventTypeFor(operation: string): string | null {
|
|
|
128
161
|
'customer.subscription.updated', 'customer.subscription.paused', 'customer.subscription.resumed',
|
|
129
162
|
'payout.created', 'payout.paid', 'payout.canceled', 'price.created', 'product.created',
|
|
130
163
|
'setup_intent.succeeded', 'setup_intent.created',
|
|
164
|
+
// The twin's checkout completion transition records the op AS the real event type:
|
|
165
|
+
// real Stripe fires checkout.session.completed (data.object = the completed Session)
|
|
166
|
+
// the moment a session reaches status=complete — the event most billing integrations
|
|
167
|
+
// fulfill from, so it must pass through, not be swallowed.
|
|
168
|
+
'checkout.session.completed',
|
|
169
|
+
// Issuing writes record the op AS the real event type (issuing_authorization.updated on
|
|
170
|
+
// approve/decline/update, issuing_card.updated on card updates, …) — all real vendor
|
|
171
|
+
// event types (stripe@22.3.0 WebhookEndpoint.EnabledEvent enum).
|
|
172
|
+
'issuing_authorization.updated', 'issuing_card.updated', 'issuing_cardholder.updated',
|
|
173
|
+
'issuing_transaction.updated',
|
|
131
174
|
]);
|
|
132
175
|
if (PASSTHROUGH.has(operation)) return operation;
|
|
133
176
|
switch (operation) {
|
|
@@ -148,17 +191,56 @@ export function eventTypeFor(operation: string): string | null {
|
|
|
148
191
|
case 'payout.create': return 'payout.created';
|
|
149
192
|
case 'product.create': return 'product.created';
|
|
150
193
|
case 'price.create': return 'price.created';
|
|
194
|
+
// Issuing: real Stripe fires issuing_authorization.created/.updated,
|
|
195
|
+
// issuing_card.created/.updated, issuing_cardholder.created/.updated and
|
|
196
|
+
// issuing_transaction.created (all present in the vendor SDK's
|
|
197
|
+
// WebhookEndpoint.EnabledEvent enum, stripe@22.3.0 WebhookEndpoints.d.ts).
|
|
198
|
+
// issuing_authorization.request is deliberately NOT here: it is not a
|
|
199
|
+
// state-change event — the twin delivers it SYNCHRONOUSLY from the
|
|
200
|
+
// present-authorization flow (requestAuthorizationDecision below).
|
|
201
|
+
case 'issuing_authorization.create': return 'issuing_authorization.created';
|
|
202
|
+
case 'issuing_card.create': return 'issuing_card.created';
|
|
203
|
+
case 'issuing_cardholder.create': return 'issuing_cardholder.created';
|
|
204
|
+
case 'issuing_transaction.create': return 'issuing_transaction.created';
|
|
151
205
|
default: return null;
|
|
152
206
|
}
|
|
153
207
|
}
|
|
154
208
|
|
|
155
209
|
let counter = 0;
|
|
210
|
+
// Live HTTP delivery signs exactly like real Stripe: HMAC-SHA256 over
|
|
211
|
+
// `${timestamp}.${payload}` keyed by the ENDPOINT'S OWN signing secret (the
|
|
212
|
+
// `whsec_twin_*` minted by POST /v1/webhook_endpoints and registered alongside the
|
|
213
|
+
// URL), sent as `Stripe-Signature: t=...,v1=...`. The signed bytes are the exact
|
|
214
|
+
// request body, so `stripe.webhooks.constructEvent(rawBody, header, secret)` in the
|
|
215
|
+
// consumer's real SDK verifies twin deliveries unchanged (a hardcoded placeholder
|
|
216
|
+
// header made every verifying consumer 400 — the delivery was unverifiable).
|
|
217
|
+
// Delivery is AT-LEAST-ONCE, like the vendor: real Stripe re-attempts a webhook whose
|
|
218
|
+
// delivery fails at the transport layer rather than dropping it, so a consumer that missed
|
|
219
|
+
// one event because a socket blipped is a consumer real Stripe would have reached. A single
|
|
220
|
+
// swallowed `fetch` rejection here is indistinguishable, downstream, from the consumer
|
|
221
|
+
// having a bug — it cost a week of "the settlement webhook is racy" before anyone could see
|
|
222
|
+
// that the event had simply never left the twin. Two cheap re-attempts cover a transient
|
|
223
|
+
// local failure (ECONNRESET / socket exhaustion under a boot storm); a genuinely unreachable
|
|
224
|
+
// endpoint refuses instantly, so the retries cost microseconds. A NON-2xx response is the
|
|
225
|
+
// consumer answering, not a transport failure — it is not retried here.
|
|
226
|
+
// A delivery that still fails after the re-attempts is DROPPED, and says so: silent loss is
|
|
227
|
+
// the one outcome a twin must never have, because it makes every consumer look flaky.
|
|
228
|
+
const DELIVERY_ATTEMPTS = 3;
|
|
156
229
|
const httpDelivery: StripeEventDelivery = async (url, event) => {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
230
|
+
const payload = JSON.stringify(event);
|
|
231
|
+
const secret = secrets.get(url) ?? STRIPE_WEBHOOK_FALLBACK_SECRET;
|
|
232
|
+
let last = '';
|
|
233
|
+
for (let attempt = 1; attempt <= DELIVERY_ATTEMPTS; attempt += 1) {
|
|
234
|
+
try {
|
|
235
|
+
const header = generateTestHeaderString({ payload, secret });
|
|
236
|
+
await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', 'stripe-signature': header }, body: payload });
|
|
237
|
+
return;
|
|
238
|
+
} catch (error) {
|
|
239
|
+
last = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
240
|
+
if (attempt < DELIVERY_ATTEMPTS) await new Promise((resolve) => setTimeout(resolve, 25 * attempt));
|
|
241
|
+
}
|
|
161
242
|
}
|
|
243
|
+
console.error(`[twin:stripe] webhook delivery DROPPED after ${DELIVERY_ATTEMPTS} attempts — ${event.type} ${event.id} -> ${url}: ${last}`);
|
|
162
244
|
};
|
|
163
245
|
|
|
164
246
|
// Default deliverer override (a TEST SEAM). The twin's write path emits events without
|
|
@@ -171,11 +253,97 @@ export function setStripeEventDelivery(deliver: StripeEventDelivery | null): voi
|
|
|
171
253
|
defaultDelivery = deliver;
|
|
172
254
|
}
|
|
173
255
|
|
|
256
|
+
// ── Real-time authorization (issuing_authorization.request) — the SYNCHRONOUS leg ────────
|
|
257
|
+
// Real Stripe delivers issuing_authorization.request to the ONE enrolled endpoint while the
|
|
258
|
+
// card network holds the authorization open, and the endpoint must answer within Stripe's
|
|
259
|
+
// documented 2-second window ("This request should be made within the timeout window of the
|
|
260
|
+
// real-time authorization flow" — stripe@22.3.0 Issuing/Authorizations.d.ts; the current
|
|
261
|
+
// method is to "respond directly to the webhook request to approve an authorization"). The
|
|
262
|
+
// endpoint's HTTP response body carries the decision: a JSON object with a boolean
|
|
263
|
+
// `approved` (and, for an amount-controllable request, an optional integer `amount` to hold
|
|
264
|
+
// a different amount). Outcomes map onto the vendor's request_history.reason values
|
|
265
|
+
// (stripe@22.3.0 RequestHistory.Reason):
|
|
266
|
+
// webhook_approved / webhook_declined — the endpoint answered in time;
|
|
267
|
+
// webhook_timeout — no response arrived inside the window (incl. unreachable endpoint);
|
|
268
|
+
// webhook_error — "the direct webhook response is invalid (for example, parsing errors
|
|
269
|
+
// or missing parameters)" (verbatim from the SDK's reason_message doc),
|
|
270
|
+
// i.e. non-2xx status, non-JSON body, or a missing `approved`.
|
|
271
|
+
// On timeout/error real Stripe DECLINES the authorization (fail-closed; the card's own
|
|
272
|
+
// spending_controls remain the backstop) — the account-level "approve on timeout" opt-in is
|
|
273
|
+
// not API-configurable and is not modeled.
|
|
274
|
+
export const STRIPE_REALTIME_AUTH_TIMEOUT_MS = 2_000;
|
|
275
|
+
|
|
276
|
+
export type StripeAuthRequestOutcome =
|
|
277
|
+
| { kind: 'approved'; amount?: number }
|
|
278
|
+
| { kind: 'declined' }
|
|
279
|
+
| { kind: 'timeout'; message: string }
|
|
280
|
+
| { kind: 'error'; message: string };
|
|
281
|
+
|
|
282
|
+
// The live synchronous deliverer: POST the signed event and WAIT for the response within
|
|
283
|
+
// the 2s window. Signs with the enrolled endpoint's own whsec (passed by the caller, read
|
|
284
|
+
// from the endpoint's state row) — never a placeholder.
|
|
285
|
+
async function httpAuthRequestDelivery(url: string, event: StripeEvent, secret: string): Promise<StripeWebhookEndpointResponse> {
|
|
286
|
+
const payload = JSON.stringify(event);
|
|
287
|
+
const header = generateTestHeaderString({ payload, secret });
|
|
288
|
+
const res = await fetch(url, {
|
|
289
|
+
method: 'POST',
|
|
290
|
+
headers: { 'content-type': 'application/json', 'stripe-signature': header },
|
|
291
|
+
body: payload,
|
|
292
|
+
signal: AbortSignal.timeout(STRIPE_REALTIME_AUTH_TIMEOUT_MS),
|
|
293
|
+
});
|
|
294
|
+
return { status: res.status, body: await res.text() };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Deliver an issuing_authorization.request event to the enrolled endpoint and interpret
|
|
299
|
+
* its synchronous response as the authorization decision. Honors the installed test
|
|
300
|
+
* deliverer (setStripeEventDelivery) so verifies run fully offline: a fake returning a
|
|
301
|
+
* {status, body} response drives the decision; a fake that throws a TimeoutError (the
|
|
302
|
+
* exact error AbortSignal.timeout produces) exercises the timeout fallback.
|
|
303
|
+
*/
|
|
304
|
+
export async function requestAuthorizationDecision(url: string, event: StripeEvent, secret: string): Promise<StripeAuthRequestOutcome> {
|
|
305
|
+
let res: void | StripeWebhookEndpointResponse;
|
|
306
|
+
try {
|
|
307
|
+
res = defaultDelivery ? await defaultDelivery(url, event) : await httpAuthRequestDelivery(url, event, secret);
|
|
308
|
+
} catch (e) {
|
|
309
|
+
const name = e instanceof Error ? e.name : '';
|
|
310
|
+
if (name === 'TimeoutError' || name === 'AbortError') {
|
|
311
|
+
return { kind: 'timeout', message: 'Webhook endpoint did not respond within the real-time authorization window.' };
|
|
312
|
+
}
|
|
313
|
+
// No response ever reached us (connection refused, DNS, socket reset) — like an
|
|
314
|
+
// endpoint that never answered inside the window: a timeout, not an invalid response.
|
|
315
|
+
return { kind: 'timeout', message: 'Webhook endpoint was unreachable within the real-time authorization window.' };
|
|
316
|
+
}
|
|
317
|
+
if (!res || typeof res !== 'object') {
|
|
318
|
+
return { kind: 'error', message: 'Webhook endpoint returned no response body.' };
|
|
319
|
+
}
|
|
320
|
+
if (res.status < 200 || res.status >= 300) {
|
|
321
|
+
return { kind: 'error', message: `Webhook endpoint responded with HTTP status ${res.status}.` };
|
|
322
|
+
}
|
|
323
|
+
let parsed: unknown;
|
|
324
|
+
try {
|
|
325
|
+
parsed = JSON.parse(res.body);
|
|
326
|
+
} catch {
|
|
327
|
+
return { kind: 'error', message: 'Webhook response body was not valid JSON.' };
|
|
328
|
+
}
|
|
329
|
+
const approved = (parsed as { approved?: unknown } | null)?.approved;
|
|
330
|
+
if (typeof approved !== 'boolean') {
|
|
331
|
+
return { kind: 'error', message: 'Webhook response is missing the required `approved` parameter.' };
|
|
332
|
+
}
|
|
333
|
+
if (!approved) return { kind: 'declined' };
|
|
334
|
+
const amount = (parsed as { amount?: unknown }).amount;
|
|
335
|
+
return { kind: 'approved', ...(typeof amount === 'number' && Number.isInteger(amount) && amount > 0 ? { amount } : {}) };
|
|
336
|
+
}
|
|
337
|
+
|
|
174
338
|
/**
|
|
175
339
|
* Emit a Stripe event for a twin write to registered endpoints. `resource` is the
|
|
176
340
|
* Stripe object the event is about. `occurredAt` is caller-supplied (deterministic).
|
|
177
341
|
* Returns the delivered events (for assertions); no-op when nothing is registered
|
|
178
342
|
* or the operation has no mapped event type.
|
|
343
|
+
*
|
|
344
|
+
* Fan-out filters by each endpoint's registered enabled_events (stripeEventMatches — '*',
|
|
345
|
+
* 'family.*', exact), like the vendor: a consumer enrolled for one event type receives
|
|
346
|
+
* only that type. A URL registered without a list (test seam) receives everything.
|
|
179
347
|
*/
|
|
180
348
|
export async function emitStripeEvent(
|
|
181
349
|
operation: string,
|
|
@@ -195,6 +363,8 @@ export async function emitStripeEvent(
|
|
|
195
363
|
};
|
|
196
364
|
const out: StripeEvent[] = [];
|
|
197
365
|
for (const url of registry) {
|
|
366
|
+
const enabled = subscriptions.get(url);
|
|
367
|
+
if (enabled && !stripeEventMatches(enabled, type)) continue;
|
|
198
368
|
await deliver(url, event);
|
|
199
369
|
out.push(event);
|
|
200
370
|
}
|
package/src/stripe-mirror-ui.ts
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
// React/TSX app (bundled by Bun, the repo convention; cf. tracker-visualizer).
|
|
3
3
|
// It renders by consuming the twin's OWN REST API on the same origin
|
|
4
4
|
// (/v1/<collection>) — the same endpoints the app uses. Per-vendor + concrete.
|
|
5
|
-
|
|
5
|
+
// PURE FRONTEND (R3): the mirror imports no handler and no twin internals — it MOUNTS the pack's
|
|
6
|
+
// own fetch adapter as its API backend and reads every byte of state back over the wire.
|
|
7
|
+
import { createStripeTwinFetch } from './stripe-server.ts';
|
|
6
8
|
|
|
7
|
-
const CLIENT_ENTRY = new URL('../client/stripe-mirror.tsx', import.meta.url).pathname;
|
|
8
|
-
const CLIENT_CSS = new URL('../client/stripe-mirror.css', import.meta.url).pathname;
|
|
9
|
+
const CLIENT_ENTRY = () => new URL('../client/stripe-mirror.tsx', import.meta.url).pathname; // lazy: workerd rejects top-level relative URL from import.meta.url (bundled via pack index)
|
|
10
|
+
const CLIENT_CSS = () => new URL('../client/stripe-mirror.css', import.meta.url).pathname; // lazy: workerd rejects top-level relative URL from import.meta.url (bundled via pack index)
|
|
9
11
|
|
|
10
12
|
// ---------------------------------------------------------------------------
|
|
11
13
|
// Pure, dependency-free render/format/resolution helpers.
|
|
@@ -13,7 +15,7 @@ const CLIENT_CSS = new URL('../client/stripe-mirror.css', import.meta.url).pathn
|
|
|
13
15
|
// These are intentionally framework-agnostic (plain data in → plain data out) so
|
|
14
16
|
// they can be unit-tested in isolation AND imported by the React/TSX client
|
|
15
17
|
// (Bun tree-shakes the server-only exports below out of the browser bundle).
|
|
16
|
-
// Keep them free of any `@volter/twin`/`Bun
|
|
18
|
+
// Keep them free of any `@volter/twin`/`Bun`/twin-adapter usage.
|
|
17
19
|
// ---------------------------------------------------------------------------
|
|
18
20
|
|
|
19
21
|
export type StripeRow = Record<string, any>;
|
|
@@ -303,7 +305,7 @@ let clientBundle: Promise<string> | null = null;
|
|
|
303
305
|
/** Build the React/TSX dashboard client to browser JS (Bun bundles TSX); cached. */
|
|
304
306
|
export function buildStripeMirrorClient(): Promise<string> {
|
|
305
307
|
if (!clientBundle) {
|
|
306
|
-
clientBundle = Bun.build({ entrypoints: [CLIENT_ENTRY], target: 'browser', minify: true })
|
|
308
|
+
clientBundle = Bun.build({ entrypoints: [CLIENT_ENTRY()], target: 'browser', minify: true })
|
|
307
309
|
.then(async (result) => {
|
|
308
310
|
if (!result.success) throw new Error(result.logs.map((l) => l.message).join('\n') || 'stripe mirror client build failed');
|
|
309
311
|
return result.outputs[0]!.text();
|
|
@@ -315,7 +317,16 @@ export function buildStripeMirrorClient(): Promise<string> {
|
|
|
315
317
|
|
|
316
318
|
/** Serve the Stripe dashboard mirror UI (React app) + its backing REST API. */
|
|
317
319
|
export function createStripeMirrorServer(options: { root?: string; port?: number }): { port: number; stop: () => void } {
|
|
320
|
+
const twin = createStripeTwinFetch(options);
|
|
318
321
|
const server = Bun.serve({
|
|
322
|
+
// LOOPBACK-SPECIFIC bind (2026-08-20, the roving ui-verify flake): with the default
|
|
323
|
+
// wildcard hostname, `port: 0` can be handed a port some long-running app already LISTENS
|
|
324
|
+
// on at 127.0.0.1 (SO_REUSEADDR allows the overlapping non-identical bind), and the more
|
|
325
|
+
// specific loopback listener then shadows this server for every 127.0.0.1 fetch — the
|
|
326
|
+
// verify talks to a STRANGER (captured: a desktop app's asset server answering 404s on the
|
|
327
|
+
// mirror's port). Binding 127.0.0.1 makes the kernel allocate a port that is actually free
|
|
328
|
+
// on loopback, so the verify's fetches deterministically reach THIS server.
|
|
329
|
+
hostname: '127.0.0.1',
|
|
319
330
|
port: options.port ?? 0,
|
|
320
331
|
idleTimeout: 60,
|
|
321
332
|
async fetch(request) {
|
|
@@ -325,15 +336,19 @@ export function createStripeMirrorServer(options: { root?: string; port?: number
|
|
|
325
336
|
catch (error) { return new Response(String(error), { status: 500 }); }
|
|
326
337
|
}
|
|
327
338
|
if (request.method === 'GET' && url.pathname === '/assets/styles.css') {
|
|
328
|
-
return new Response(Bun.file(CLIENT_CSS), { headers: { 'content-type': 'text/css; charset=utf-8' } });
|
|
339
|
+
return new Response(Bun.file(CLIENT_CSS()), { headers: { 'content-type': 'text/css; charset=utf-8' } });
|
|
329
340
|
}
|
|
330
341
|
if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '')) {
|
|
331
342
|
return new Response(APP_SHELL, { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
332
343
|
}
|
|
333
|
-
// everything else → the twin's
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
344
|
+
// everything else → the twin's OWN FETCH ADAPTER (composition, R2): the React client fetches
|
|
345
|
+
// the vendor's real paths (/v1/<collection>) and this is the same closure
|
|
346
|
+
// `createStripeTwinServer` mounts, so there is exactly ONE serving code path — the `/twin`
|
|
347
|
+
// manifest door, the form-encoded body passed through byte-for-byte, the Idempotency-Key /
|
|
348
|
+
// Stripe-Version / Stripe-Account threading, the `request-id` response header, the world
|
|
349
|
+
// clock and an honorable `readOnly` all come from it, and the mirror port cannot drift from
|
|
350
|
+
// the API port.
|
|
351
|
+
return twin(request);
|
|
337
352
|
},
|
|
338
353
|
});
|
|
339
354
|
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
package/src/stripe-server.ts
CHANGED
|
@@ -3,44 +3,79 @@
|
|
|
3
3
|
// backend sandbox's api.stripe.com interception at it. Form-encoded bodies (what
|
|
4
4
|
// the SDK sends) pass through to the handler. Writable by default (the local stack
|
|
5
5
|
// uses the twin as its authoritative Stripe); pass readOnly to reject writes (R4).
|
|
6
|
+
//
|
|
7
|
+
// The surface is a plain `fetch` (`createStripeTwinFetch`) and the SERVER is one line of
|
|
8
|
+
// `Bun.serve` around it — see that factory's docstring for why (a serverless entry has no
|
|
9
|
+
// port to bind, so it mounts the fetch in-process).
|
|
6
10
|
import { handleStripeTwinRequest } from './stripe-twin.ts';
|
|
11
|
+
import { worldNow, statefulTwinManifest} from '@volter/twin';
|
|
7
12
|
|
|
8
|
-
|
|
13
|
+
/** Options shared by the fetch handler and the Bun.serve wrapper around it. `port` is a
|
|
14
|
+
* BIND concern the fetch ignores; it stays in one shape so a caller configures the twin
|
|
15
|
+
* once whichever way it is mounted. */
|
|
16
|
+
export type StripeTwinOptions = { root?: string; port?: number; readOnly?: boolean };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The whole Stripe serve path as a plain `(Request) => Response` — the `/twin` manifest
|
|
20
|
+
* door, the header threading (idempotency key, pinned API version, Connect account) and
|
|
21
|
+
* the dispatch into `handleStripeTwinRequest`. NOTHING about it is port-bound.
|
|
22
|
+
*
|
|
23
|
+
* Why this is the factory and `createStripeTwinServer` is a wrapper (runtime contract R12,
|
|
24
|
+
* the Cloudflare ruling): a Durable Object / Worker entry has no loopback ports — it mounts
|
|
25
|
+
* pack fetches IN-PROCESS behind one router. Handing that entry `Bun.serve` is not an
|
|
26
|
+
* option, so the vendor HTTP adaptation has to be a value it can call. Keeping the server a
|
|
27
|
+
* thin `Bun.serve(fetch)` also means the local and hosted lanes execute the SAME bytes of
|
|
28
|
+
* serving code — the parity claim (R9) is about one code path, not two that resemble each
|
|
29
|
+
* other.
|
|
30
|
+
*
|
|
31
|
+
* SCOPING THE STORES AROUND IT: this fetch is ASYNC, so install the world store with
|
|
32
|
+
* `setActiveWorldStore(store)` in a try/finally — **not** `withWorldStore(store, fn)`, whose
|
|
33
|
+
* `finally` fires when `fn` RETURNS, i.e. at the first `await`, restoring the previous store
|
|
34
|
+
* under the rest of the request.
|
|
35
|
+
*/
|
|
36
|
+
export function createStripeTwinFetch(options: StripeTwinOptions): (request: Request) => Promise<Response> {
|
|
9
37
|
const readOnly = options.readOnly ?? false;
|
|
38
|
+
return async function stripeTwinFetch(request: Request): Promise<Response> {
|
|
39
|
+
const url = new URL(request.url);
|
|
40
|
+
// GET /twin — the discovery manifest (education inside the twin).
|
|
41
|
+
if (request.method === 'GET' && url.pathname.replace(/\/+$/, '') === '/twin') {
|
|
42
|
+
return Response.json(statefulTwinManifest({ vendor: 'stripe', twinOf: 'the Stripe REST API', stores: 'customers, payment objects, checkout sessions, products/prices and webhook endpoints (signed events)' }));
|
|
43
|
+
}
|
|
44
|
+
const body = request.method === 'GET' ? '' : await request.text();
|
|
45
|
+
// Stripe sends the idempotency key as the `Idempotency-Key` request header; the
|
|
46
|
+
// real SDK sets it from { idempotencyKey } on a write call. Thread it through so
|
|
47
|
+
// a replay returns the stored response without re-applying the write.
|
|
48
|
+
const idempotencyKey = request.headers.get('idempotency-key') ?? undefined;
|
|
49
|
+
// Stripe pins API behavior via the `Stripe-Version` request header; the real SDK
|
|
50
|
+
// sets it from { apiVersion }. Thread it through so the twin can reflect it (and
|
|
51
|
+
// reject a malformed value with a 400) exactly like real Stripe.
|
|
52
|
+
const apiVersion = request.headers.get('stripe-version') ?? undefined;
|
|
53
|
+
// Connect: the SDK sets `Stripe-Account` from { stripeAccount } to act on behalf of a
|
|
54
|
+
// connected account. Thread it through so account-scoped writes (e.g. a payout created
|
|
55
|
+
// on the connected account's balance) are attributed to that account, like real Stripe.
|
|
56
|
+
const stripeAccount = request.headers.get('stripe-account') ?? undefined;
|
|
57
|
+
const { status, body: out } = await handleStripeTwinRequest({
|
|
58
|
+
method: request.method,
|
|
59
|
+
path: url.pathname + (url.search || ''),
|
|
60
|
+
body,
|
|
61
|
+
readOnly,
|
|
62
|
+
...(apiVersion ? { apiVersion } : {}),
|
|
63
|
+
...(stripeAccount ? { stripeAccount } : {}),
|
|
64
|
+
// The WORLD instant (R9): served `created` epochs come from the world clock, never
|
|
65
|
+
// wall time. Embedded/test callers pass their own occurredAt.
|
|
66
|
+
occurredAt: worldNow(),
|
|
67
|
+
...(idempotencyKey ? { idempotencyKey } : {}),
|
|
68
|
+
...(options.root !== undefined ? { root: options.root } : {}),
|
|
69
|
+
});
|
|
70
|
+
return new Response(JSON.stringify(out), { status, headers: { 'content-type': 'application/json', 'request-id': 'req_twin' } });
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createStripeTwinServer(options: StripeTwinOptions): { port: number; stop: () => void } {
|
|
10
75
|
const server = Bun.serve({
|
|
11
76
|
port: options.port ?? 0,
|
|
12
77
|
idleTimeout: 60,
|
|
13
|
-
|
|
14
|
-
const url = new URL(request.url);
|
|
15
|
-
const body = request.method === 'GET' ? '' : await request.text();
|
|
16
|
-
// Stripe sends the idempotency key as the `Idempotency-Key` request header; the
|
|
17
|
-
// real SDK sets it from { idempotencyKey } on a write call. Thread it through so
|
|
18
|
-
// a replay returns the stored response without re-applying the write.
|
|
19
|
-
const idempotencyKey = request.headers.get('idempotency-key') ?? undefined;
|
|
20
|
-
// Stripe pins API behavior via the `Stripe-Version` request header; the real SDK
|
|
21
|
-
// sets it from { apiVersion }. Thread it through so the twin can reflect it (and
|
|
22
|
-
// reject a malformed value with a 400) exactly like real Stripe.
|
|
23
|
-
const apiVersion = request.headers.get('stripe-version') ?? undefined;
|
|
24
|
-
// Connect: the SDK sets `Stripe-Account` from { stripeAccount } to act on behalf of a
|
|
25
|
-
// connected account. Thread it through so account-scoped writes (e.g. a payout created
|
|
26
|
-
// on the connected account's balance) are attributed to that account, like real Stripe.
|
|
27
|
-
const stripeAccount = request.headers.get('stripe-account') ?? undefined;
|
|
28
|
-
const { status, body: out } = await handleStripeTwinRequest({
|
|
29
|
-
method: request.method,
|
|
30
|
-
path: url.pathname + (url.search || ''),
|
|
31
|
-
body,
|
|
32
|
-
readOnly,
|
|
33
|
-
...(apiVersion ? { apiVersion } : {}),
|
|
34
|
-
...(stripeAccount ? { stripeAccount } : {}),
|
|
35
|
-
// Stamp real wall-clock time so served objects get a real `created` epoch (not 1970).
|
|
36
|
-
// Embedded/test callers pass their own occurredAt for determinism; the live HTTP path
|
|
37
|
-
// — what the real SDK hits — should reflect actual time, like the real vendor.
|
|
38
|
-
occurredAt: new Date().toISOString(),
|
|
39
|
-
...(idempotencyKey ? { idempotencyKey } : {}),
|
|
40
|
-
...(options.root !== undefined ? { root: options.root } : {}),
|
|
41
|
-
});
|
|
42
|
-
return new Response(JSON.stringify(out), { status, headers: { 'content-type': 'application/json', 'request-id': 'req_twin' } });
|
|
43
|
-
},
|
|
78
|
+
fetch: createStripeTwinFetch(options),
|
|
44
79
|
});
|
|
45
80
|
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
46
81
|
}
|