@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.
@@ -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
+ };
@@ -12,7 +12,12 @@ export type StripeEvent = {
12
12
  livemode: false;
13
13
  data: { object: Record<string, unknown> };
14
14
  };
15
- export type StripeEventDelivery = (url: string, event: StripeEvent) => Promise<void> | void;
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
- export function registerStripeWebhook(url: string): void {
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
- try {
158
- await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', 'stripe-signature': 'twin' }, body: JSON.stringify(event) });
159
- } catch {
160
- /* fire-and-forget */
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
  }
@@ -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
- import { handleStripeTwinRequest } from './stripe-twin.ts';
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`/`handleStripeTwinRequest` usage.
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 REST API (the React client fetches /v1/<collection>)
334
- const body = request.method === 'GET' ? '' : await request.text();
335
- const { status, body: out } = await handleStripeTwinRequest({ method: request.method, path: url.pathname + (url.search || ''), body, ...(options.root !== undefined ? { root: options.root } : {}) });
336
- return new Response(JSON.stringify(out), { status, headers: { 'content-type': 'application/json' } });
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) };
@@ -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
- export function createStripeTwinServer(options: { root?: string; port?: number; readOnly?: boolean }): { port: number; stop: () => void } {
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
- async fetch(request) {
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
  }