@volter/twin-stripe 2.0.1 → 2.0.3

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.
@@ -213,6 +213,20 @@ export const STRIPE_CAPABILITIES = [
213
213
  const l = await h({ m: 'GET', p: '/v1/customers' });
214
214
  return ok(g) && id(g) === id(c) && ok(u) && field(u, 'name') === 'Ada Lovelace' && field(l, 'object') === 'list';
215
215
  })),
216
+ // stripe-node posts a null metadata value as `metadata[key]=` (Cal.com's PaymentIntent metadata, a guest with no phone)
217
+ done('stripe.metadata.create_empty_value_unset', 'metadata', 'A create\'s metadata key posted empty is not set (an empty value unsets a key); metadata posted empty is {}', 'api', 'common', () => withRoot(async (h) => {
218
+ const c = await h({ m: 'POST', p: '/v1/customers', b: 'email=sam@twin.test&metadata[identifier]=cal.com&metadata[bookerPhoneNumber]=' });
219
+ const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: `amount=5000&currency=usd&customer=${id(c)}&metadata[bookingId]=12&metadata[calAccountId]=` });
220
+ const none = await h({ m: 'POST', p: '/v1/products', b: 'name=Consultation&metadata=' });
221
+ const got = await h({ m: 'GET', p: `/v1/payment_intents/${id(pi)}` });
222
+ const md = (r) => r.body.metadata;
223
+ return ok(c) && JSON.stringify(md(c)) === JSON.stringify({ identifier: 'cal.com' })
224
+ && ok(pi) && JSON.stringify(md(pi)) === JSON.stringify({ bookingId: '12' }) && JSON.stringify(md(got)) === JSON.stringify({ bookingId: '12' })
225
+ && ok(none) && JSON.stringify(md(none)) === '{}';
226
+ })),
227
+ // Objects on a connected account are that account's: Stripe answers a retrieve of one without its Stripe-Account
228
+ // header (or with another account's) 404 resource_missing. The twin keeps one store for the platform and its accounts.
229
+ todo('stripe.connect.account_scoped_objects', 'connect', 'Objects made with a Stripe-Account header are visible only as that account (retrieve/list/update from the platform or another account is resource_missing)', 'api', 'common'),
216
230
  done('stripe.customers.list_filter', 'customers', 'Customer list filter by email', 'api', 'core', () => withRoot(async (h) => {
217
231
  await h({ m: 'POST', p: '/v1/customers', b: 'email=match@twin.test' });
218
232
  await h({ m: 'POST', p: '/v1/customers', b: 'email=other@twin.test' });
@@ -1,4 +1,4 @@
1
- import { nodeBuiltin, worldNow } from '@volter/world-core';
1
+ import { nodeBuiltin, worldNow, deliveryTraceHeaders } from '@volter/world-core';
2
2
  import { appDestination, appFetch } from '@volter/world-core/app-route';
3
3
  import { worldEgressRefusal } from '@volter/world-core/network-policy';
4
4
  import vendorEvents from './generated/events.gen.json' with { type: 'json' };
@@ -240,7 +240,7 @@ async function postEvent(url, event, payload, secret) {
240
240
  for (let attempt = 1; attempt <= DELIVERY_ATTEMPTS; attempt += 1) {
241
241
  try {
242
242
  const header = generateTestHeaderString({ payload, secret });
243
- await appFetch(url, { method: 'POST', headers: { 'content-type': 'application/json', 'stripe-signature': header }, body: payload });
243
+ await appFetch(url, { method: 'POST', headers: { 'content-type': 'application/json', 'stripe-signature': header, ...deliveryTraceHeaders() }, body: payload });
244
244
  return;
245
245
  }
246
246
  catch (error) {
@@ -296,7 +296,7 @@ async function httpAuthRequestDelivery(url, event, secret) {
296
296
  async function postAuthRequest(url, payload, header) {
297
297
  const res = await appFetch(url, {
298
298
  method: 'POST',
299
- headers: { 'content-type': 'application/json', 'stripe-signature': header },
299
+ headers: { 'content-type': 'application/json', 'stripe-signature': header, ...deliveryTraceHeaders() },
300
300
  body: payload,
301
301
  signal: AbortSignal.timeout(STRIPE_REALTIME_AUTH_TIMEOUT_MS),
302
302
  });
@@ -4,11 +4,11 @@
4
4
  // twin internals; the client reads and writes only through Stripe's API (`/v1/...`, client/dashboard-api.ts),
5
5
  // so it renders a twin or a real account's test data unchanged, pointed at any origin by configuration.
6
6
  import { readFile } from 'node:fs/promises';
7
- import { bundleClient, fileResponse } from '@volter/world-core';
7
+ import { bundleClient, fileResponse, filePathOf } from '@volter/world-core';
8
8
  import { serveHttp } from '@volter/world-core';
9
9
  import { createStripeTwinFetch } from "./stripe-server.js";
10
- 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)
11
- 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)
10
+ const CLIENT_ENTRY = () => filePathOf(new URL('../client/stripe-mirror.tsx', import.meta.url)); // lazy: workerd rejects top-level relative URL from import.meta.url (bundled via pack index)
11
+ const CLIENT_CSS = () => filePathOf(new URL('../client/stripe-mirror.css', import.meta.url)); // lazy: workerd rejects top-level relative URL from import.meta.url (bundled via pack index)
12
12
  // ---------------------------------------------------------------------------
13
13
  // Pure, dependency-free render/format/resolution helpers.
14
14
  //
@@ -7,7 +7,7 @@
7
7
  // The surface is a plain `fetch` (`createStripeTwinFetch`) and the SERVER is one line of
8
8
  // `Bun.serve` around it — see that factory's docstring for why (a serverless entry has no
9
9
  // port to bind, so it mounts the fetch in-process).
10
- import { bindSemantics, coreFor, createDerivedFetch, crossCutting, readParams, semanticsContext, serveHttp, vendorError } from '@volter/world-core';
10
+ import { bindSemantics, coreFor, createDerivedFetch, crossCutting, derivedRequestScopes, readParams, runAsVendorMove, runWithRequestTrace, semanticsContext, serveHttp, vendorError } from '@volter/world-core';
11
11
  import { stripeCheckoutFlow } from "./screens/checkout.js";
12
12
  import { stripeJs } from "./stripe-js.js";
13
13
  import { stripeFinancialConnectionsFlow } from "./screens/financial-connections.js";
@@ -219,26 +219,29 @@ export function createStripeTwinFetch(options) {
219
219
  // later: semantics/renewals.ts; each account's automatic payouts: semantics/balance.ts) are caught up to the World's clock before anything is answered, so every door
220
220
  // (the API, the hosted pages) reads the account as it stands now
221
221
  const billingClock = surface.operations.find((o) => o.id === 'GetSubscriptions');
222
+ // time's moves are Stripe's own, not the caller's: they land under a read-only request too (runAsVendorMove)
222
223
  const catchUp = async (request) => {
223
224
  if (readOnly)
224
225
  return;
225
- const ctx = await semanticsContext(manifest, new Request(request.url), billingClock, scope);
226
- // a test clock advanced by the last request has reached its time (semantics/test-clocks.ts)
227
- await finishClockAdvances(ctx);
228
- await advanceBilling(ctx);
229
- // a submitted bank debit settles (in test mode at once) before anything is answered (semantics/payment-intents.ts)
230
- await settleBankDebits(ctx);
231
- // a coupon past its redeem_by is no longer valid (semantics/coupons.ts)
232
- await lapseCoupons(ctx);
233
- // a top-up's funds arrive five days after it is made (semantics/terminal.ts)
234
- await settleTopups(ctx);
235
- // a report run completes (semantics/platform.ts)
236
- await finishReportRuns(ctx);
237
- // a real-time authorization request no one answered is decided when its window ends (semantics/issuing.ts)
238
- await lapseRealtimeRequests(ctx);
239
- // a refund held for want of balance is made when funds cover it, before any payout takes them (semantics/refunds.ts)
240
- await settleHeldRefunds(ctx);
241
- await advancePayouts(ctx);
226
+ await runAsVendorMove(async () => {
227
+ const ctx = await semanticsContext(manifest, new Request(request.url), billingClock, scope);
228
+ // a test clock advanced by the last request has reached its time (semantics/test-clocks.ts)
229
+ await finishClockAdvances(ctx);
230
+ await advanceBilling(ctx);
231
+ // a submitted bank debit settles (in test mode at once) before anything is answered (semantics/payment-intents.ts)
232
+ await settleBankDebits(ctx);
233
+ // a coupon past its redeem_by is no longer valid (semantics/coupons.ts)
234
+ await lapseCoupons(ctx);
235
+ // a top-up's funds arrive five days after it is made (semantics/terminal.ts)
236
+ await settleTopups(ctx);
237
+ // a report run completes (semantics/platform.ts)
238
+ await finishReportRuns(ctx);
239
+ // a real-time authorization request no one answered is decided when its window ends (semantics/issuing.ts)
240
+ await lapseRealtimeRequests(ctx);
241
+ // a refund held for want of balance is made when funds cover it, before any payout takes them (semantics/refunds.ts)
242
+ await settleHeldRefunds(ctx);
243
+ await advancePayouts(ctx);
244
+ });
242
245
  };
243
246
  // Time's EVENTS, sent on the vendor's clock through a twin-only drain door, as the qstash and vercel twins send
244
247
  // theirs (a caller — a runner's drainer — drives each move). POST /_twin/drain catches time up as every request
@@ -330,7 +333,10 @@ export function createStripeTwinFetch(options) {
330
333
  const answered = creates ? body : withoutEndpointSecret(body);
331
334
  return new Response(JSON.stringify(servesCurrent(pinned) ? render(answered, pinned, await expand) : answered), { status: res.status, statusText: res.statusText, headers: res.headers });
332
335
  };
333
- return Object.assign(rendered, { owners: derived.owners });
336
+ // the request's W3C trace context scopes every door (the API, the hosted flows, the drain): their writes record it and
337
+ // the webhooks they cause continue it (world-core trace-context)
338
+ // a read-only request (x-volter-read-only) writes nothing, answered with Stripe's read-only error
339
+ return derivedRequestScopes(manifest, Object.assign((request) => runWithRequestTrace(request, () => rendered(request)), { owners: derived.owners }));
334
340
  }
335
341
  export async function createStripeTwinServer(options) {
336
342
  const server = await serveHttp({
@@ -340,6 +346,34 @@ export async function createStripeTwinServer(options) {
340
346
  });
341
347
  return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
342
348
  }
349
+ /** A create's `metadata` holds no key posted empty. "Individual keys can be unset by posting an empty value to them. All
350
+ * keys can be unset by posting an empty value to `metadata`" (every metadata parameter of docs.stripe.com/api): an empty
351
+ * value is the absence of the key, on a create as on an update, so an object never holds a key set to "". Clients post
352
+ * one without meaning to: stripe-node encodes a `null` value as `metadata[key]=` (Cal.com's PaymentIntent metadata
353
+ * carries `bookerPhoneNumber: null` for a guest who gave no phone). The request is rewritten as JSON without them. */
354
+ async function createMetadata(request, path) {
355
+ const op = CREATES.find((c) => c.re.test(path))?.op;
356
+ if (!op)
357
+ return request;
358
+ const params = await readParams(manifest, request.clone(), op);
359
+ if (!('metadata' in params))
360
+ return request;
361
+ const given = params.metadata;
362
+ let metadata;
363
+ if (given === '')
364
+ metadata = {};
365
+ else if (given && typeof given === 'object' && !Array.isArray(given) && Object.values(given).includes('')) {
366
+ metadata = Object.fromEntries(Object.entries(given).filter(([, v]) => v !== ''));
367
+ }
368
+ else
369
+ return request;
370
+ const headers = new Headers(request.headers);
371
+ headers.set('content-type', 'application/json');
372
+ return new Request(request.url, { method: request.method, headers, body: JSON.stringify({ ...params, metadata }) });
373
+ }
374
+ // the create operations, by their path as a pattern, for createMetadata
375
+ const CREATES = surface.operations.filter((o) => o.class === 'create' && o.method.toUpperCase() === 'POST')
376
+ .map((op) => ({ op, re: new RegExp(`^${op.path.replace(/[.*+?^$()|[\]\\]/g, '\\$&').replace(/\\\{[^}]+\\\}|\{[^}]+\}/g, '([^/]+)')}/?$`) }));
343
377
  // the update operations, by their path as a pattern, for mergeMetadata
344
378
  const UPDATES = surface.operations.filter((o) => o.class === 'update' && o.method.toUpperCase() === 'POST')
345
379
  .map((op) => ({ op, re: new RegExp(`^${op.path.replace(/[.*+?^$()|[\]\\]/g, '\\$&').replace(/\\\{[^}]+\\\}|\{[^}]+\}/g, '([^/]+)')}/?$`) }));
@@ -365,7 +399,9 @@ async function mergeMetadata(request, coreMerges, scope) {
365
399
  break;
366
400
  }
367
401
  }
368
- if (!op?.resource)
402
+ if (!op)
403
+ return createMetadata(request, path);
404
+ if (!op.resource)
369
405
  return request;
370
406
  const params = await readParams(manifest, request.clone(), op);
371
407
  if (!('metadata' in params))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/twin-stripe",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "description": "Local Stripe twin — a faithful, stateful local Stripe API your real `stripe` SDK talks to unmodified. Mirror, simulate, and fork. Built on @volter/world-core.",
5
5
  "keywords": [
6
6
  "twin",
@@ -56,15 +56,15 @@
56
56
  "postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
57
57
  },
58
58
  "dependencies": {
59
- "@volter/world-ui": "0.1.1",
59
+ "@volter/world-ui": "0.1.3",
60
60
  "react": "^19.2.7",
61
61
  "react-dom": "^19.2.7"
62
62
  },
63
63
  "peerDependencies": {
64
- "@volter/world-core": "2.0.1"
64
+ "@volter/world-core": "2.0.3"
65
65
  },
66
66
  "devDependencies": {
67
- "@volter/world-core": "2.0.1",
67
+ "@volter/world-core": "2.0.3",
68
68
  "@volter/world-tooling": "0.1.0",
69
69
  "@types/bun": "^1.2.20",
70
70
  "@types/node": "^24.0.0",
@@ -256,6 +256,22 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
256
256
  return ok(g) && id(g) === id(c) && ok(u) && field(u, 'name') === 'Ada Lovelace' && field(l, 'object') === 'list';
257
257
  }),
258
258
  ),
259
+ // stripe-node posts a null metadata value as `metadata[key]=` (Cal.com's PaymentIntent metadata, a guest with no phone)
260
+ done('stripe.metadata.create_empty_value_unset', 'metadata', 'A create\'s metadata key posted empty is not set (an empty value unsets a key); metadata posted empty is {}', 'api', 'common', () =>
261
+ withRoot(async (h) => {
262
+ const c = await h({ m: 'POST', p: '/v1/customers', b: 'email=sam@twin.test&metadata[identifier]=cal.com&metadata[bookerPhoneNumber]=' });
263
+ const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: `amount=5000&currency=usd&customer=${id(c)}&metadata[bookingId]=12&metadata[calAccountId]=` });
264
+ const none = await h({ m: 'POST', p: '/v1/products', b: 'name=Consultation&metadata=' });
265
+ const got = await h({ m: 'GET', p: `/v1/payment_intents/${id(pi)}` });
266
+ const md = (r: StripeResponse) => (r.body as Body).metadata as Body;
267
+ return ok(c) && JSON.stringify(md(c)) === JSON.stringify({ identifier: 'cal.com' })
268
+ && ok(pi) && JSON.stringify(md(pi)) === JSON.stringify({ bookingId: '12' }) && JSON.stringify(md(got)) === JSON.stringify({ bookingId: '12' })
269
+ && ok(none) && JSON.stringify(md(none)) === '{}';
270
+ }),
271
+ ),
272
+ // Objects on a connected account are that account's: Stripe answers a retrieve of one without its Stripe-Account
273
+ // header (or with another account's) 404 resource_missing. The twin keeps one store for the platform and its accounts.
274
+ todo('stripe.connect.account_scoped_objects', 'connect', 'Objects made with a Stripe-Account header are visible only as that account (retrieve/list/update from the platform or another account is resource_missing)', 'api', 'common'),
259
275
  done('stripe.customers.list_filter', 'customers', 'Customer list filter by email', 'api', 'core', () =>
260
276
  withRoot(async (h) => {
261
277
  await h({ m: 'POST', p: '/v1/customers', b: 'email=match@twin.test' });
@@ -1,4 +1,4 @@
1
- import { nodeBuiltin, worldNow } from '@volter/world-core';
1
+ import { nodeBuiltin, worldNow, deliveryTraceHeaders } from '@volter/world-core';
2
2
  import { appDestination, appFetch } from '@volter/world-core/app-route';
3
3
  import { worldEgressRefusal } from '@volter/world-core/network-policy';
4
4
  import vendorEvents from './generated/events.gen.json' with { type: 'json' };
@@ -274,7 +274,7 @@ async function postEvent(url: string, event: StripeEvent, payload: string, secre
274
274
  for (let attempt = 1; attempt <= DELIVERY_ATTEMPTS; attempt += 1) {
275
275
  try {
276
276
  const header = generateTestHeaderString({ payload, secret });
277
- await appFetch(url, { method: 'POST', headers: { 'content-type': 'application/json', 'stripe-signature': header }, body: payload });
277
+ await appFetch(url, { method: 'POST', headers: { 'content-type': 'application/json', 'stripe-signature': header, ...deliveryTraceHeaders() }, body: payload });
278
278
  return;
279
279
  } catch (error) {
280
280
  last = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
@@ -338,7 +338,7 @@ async function httpAuthRequestDelivery(url: string, event: StripeEvent, secret:
338
338
  async function postAuthRequest(url: string, payload: string, header: string): Promise<StripeWebhookEndpointResponse> {
339
339
  const res = await appFetch(url, {
340
340
  method: 'POST',
341
- headers: { 'content-type': 'application/json', 'stripe-signature': header },
341
+ headers: { 'content-type': 'application/json', 'stripe-signature': header, ...deliveryTraceHeaders() },
342
342
  body: payload,
343
343
  signal: AbortSignal.timeout(STRIPE_REALTIME_AUTH_TIMEOUT_MS),
344
344
  });
@@ -4,12 +4,12 @@
4
4
  // twin internals; the client reads and writes only through Stripe's API (`/v1/...`, client/dashboard-api.ts),
5
5
  // so it renders a twin or a real account's test data unchanged, pointed at any origin by configuration.
6
6
  import { readFile } from 'node:fs/promises';
7
- import { bundleClient, fileResponse } from '@volter/world-core';
7
+ import { bundleClient, fileResponse, filePathOf } from '@volter/world-core';
8
8
  import { serveHttp } from '@volter/world-core';
9
9
  import { createStripeTwinFetch } from './stripe-server.ts';
10
10
 
11
- 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)
12
- 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)
11
+ const CLIENT_ENTRY = () => filePathOf(new URL('../client/stripe-mirror.tsx', import.meta.url)); // lazy: workerd rejects top-level relative URL from import.meta.url (bundled via pack index)
12
+ const CLIENT_CSS = () => filePathOf(new URL('../client/stripe-mirror.css', import.meta.url)); // lazy: workerd rejects top-level relative URL from import.meta.url (bundled via pack index)
13
13
 
14
14
  // ---------------------------------------------------------------------------
15
15
  // Pure, dependency-free render/format/resolution helpers.
@@ -7,7 +7,7 @@
7
7
  // The surface is a plain `fetch` (`createStripeTwinFetch`) and the SERVER is one line of
8
8
  // `Bun.serve` around it — see that factory's docstring for why (a serverless entry has no
9
9
  // port to bind, so it mounts the fetch in-process).
10
- import { bindSemantics, coreFor, createDerivedFetch, crossCutting, readParams, semanticsContext, serveHttp, vendorError, type DerivedCall, type DerivedFetch } from '@volter/world-core';
10
+ import { bindSemantics, coreFor, createDerivedFetch, crossCutting, derivedRequestScopes, readParams, runAsVendorMove, runWithRequestTrace, semanticsContext, serveHttp, vendorError, type DerivedCall, type DerivedFetch } from '@volter/world-core';
11
11
  import { stripeCheckoutFlow } from './screens/checkout.tsx';
12
12
  import { stripeJs } from './stripe-js.ts';
13
13
  import { stripeFinancialConnectionsFlow } from './screens/financial-connections.tsx';
@@ -215,25 +215,28 @@ export function createStripeTwinFetch(options: StripeTwinOptions): DerivedFetch
215
215
  // later: semantics/renewals.ts; each account's automatic payouts: semantics/balance.ts) are caught up to the World's clock before anything is answered, so every door
216
216
  // (the API, the hosted pages) reads the account as it stands now
217
217
  const billingClock = (surface.operations as Array<{ id: string; method: string; path: string; class: string }>).find((o) => o.id === 'GetSubscriptions')!;
218
+ // time's moves are Stripe's own, not the caller's: they land under a read-only request too (runAsVendorMove)
218
219
  const catchUp = async (request: Request): Promise<void> => {
219
220
  if (readOnly) return;
220
- const ctx = await semanticsContext(manifest, new Request(request.url), billingClock, scope);
221
- // a test clock advanced by the last request has reached its time (semantics/test-clocks.ts)
222
- await finishClockAdvances(ctx);
223
- await advanceBilling(ctx);
224
- // a submitted bank debit settles (in test mode at once) before anything is answered (semantics/payment-intents.ts)
225
- await settleBankDebits(ctx);
226
- // a coupon past its redeem_by is no longer valid (semantics/coupons.ts)
227
- await lapseCoupons(ctx);
228
- // a top-up's funds arrive five days after it is made (semantics/terminal.ts)
229
- await settleTopups(ctx);
230
- // a report run completes (semantics/platform.ts)
231
- await finishReportRuns(ctx);
232
- // a real-time authorization request no one answered is decided when its window ends (semantics/issuing.ts)
233
- await lapseRealtimeRequests(ctx);
234
- // a refund held for want of balance is made when funds cover it, before any payout takes them (semantics/refunds.ts)
235
- await settleHeldRefunds(ctx);
236
- await advancePayouts(ctx);
221
+ await runAsVendorMove(async () => {
222
+ const ctx = await semanticsContext(manifest, new Request(request.url), billingClock, scope);
223
+ // a test clock advanced by the last request has reached its time (semantics/test-clocks.ts)
224
+ await finishClockAdvances(ctx);
225
+ await advanceBilling(ctx);
226
+ // a submitted bank debit settles (in test mode at once) before anything is answered (semantics/payment-intents.ts)
227
+ await settleBankDebits(ctx);
228
+ // a coupon past its redeem_by is no longer valid (semantics/coupons.ts)
229
+ await lapseCoupons(ctx);
230
+ // a top-up's funds arrive five days after it is made (semantics/terminal.ts)
231
+ await settleTopups(ctx);
232
+ // a report run completes (semantics/platform.ts)
233
+ await finishReportRuns(ctx);
234
+ // a real-time authorization request no one answered is decided when its window ends (semantics/issuing.ts)
235
+ await lapseRealtimeRequests(ctx);
236
+ // a refund held for want of balance is made when funds cover it, before any payout takes them (semantics/refunds.ts)
237
+ await settleHeldRefunds(ctx);
238
+ await advancePayouts(ctx);
239
+ });
237
240
  };
238
241
  // Time's EVENTS, sent on the vendor's clock through a twin-only drain door, as the qstash and vercel twins send
239
242
  // theirs (a caller — a runner's drainer — drives each move). POST /_twin/drain catches time up as every request
@@ -309,7 +312,10 @@ export function createStripeTwinFetch(options: StripeTwinOptions): DerivedFetch
309
312
  const answered = creates ? body : withoutEndpointSecret(body);
310
313
  return new Response(JSON.stringify(servesCurrent(pinned) ? render(answered, pinned, await expand) : answered), { status: res.status, statusText: res.statusText, headers: res.headers });
311
314
  };
312
- return Object.assign(rendered, { owners: derived.owners });
315
+ // the request's W3C trace context scopes every door (the API, the hosted flows, the drain): their writes record it and
316
+ // the webhooks they cause continue it (world-core trace-context)
317
+ // a read-only request (x-volter-read-only) writes nothing, answered with Stripe's read-only error
318
+ return derivedRequestScopes(manifest, Object.assign((request: Request) => runWithRequestTrace(request, () => rendered(request)), { owners: derived.owners }));
313
319
  }
314
320
 
315
321
  export async function createStripeTwinServer(options: StripeTwinOptions): Promise<{ port: number; stop: () => void }> {
@@ -321,6 +327,31 @@ export async function createStripeTwinServer(options: StripeTwinOptions): Promis
321
327
  return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
322
328
  }
323
329
 
330
+ /** A create's `metadata` holds no key posted empty. "Individual keys can be unset by posting an empty value to them. All
331
+ * keys can be unset by posting an empty value to `metadata`" (every metadata parameter of docs.stripe.com/api): an empty
332
+ * value is the absence of the key, on a create as on an update, so an object never holds a key set to "". Clients post
333
+ * one without meaning to: stripe-node encodes a `null` value as `metadata[key]=` (Cal.com's PaymentIntent metadata
334
+ * carries `bookerPhoneNumber: null` for a guest who gave no phone). The request is rewritten as JSON without them. */
335
+ async function createMetadata(request: Request, path: string): Promise<Request> {
336
+ const op = CREATES.find((c) => c.re.test(path))?.op;
337
+ if (!op) return request;
338
+ const params = await readParams(manifest, request.clone(), op);
339
+ if (!('metadata' in params)) return request;
340
+ const given = params.metadata;
341
+ let metadata: Record<string, unknown>;
342
+ if (given === '') metadata = {};
343
+ else if (given && typeof given === 'object' && !Array.isArray(given) && Object.values(given).includes('')) {
344
+ metadata = Object.fromEntries(Object.entries(given as Record<string, unknown>).filter(([, v]) => v !== ''));
345
+ } else return request;
346
+ const headers = new Headers(request.headers);
347
+ headers.set('content-type', 'application/json');
348
+ return new Request(request.url, { method: request.method, headers, body: JSON.stringify({ ...params, metadata }) });
349
+ }
350
+
351
+ // the create operations, by their path as a pattern, for createMetadata
352
+ const CREATES = (surface.operations as Array<DerivedCall['operation']>).filter((o) => o.class === 'create' && o.method.toUpperCase() === 'POST')
353
+ .map((op) => ({ op, re: new RegExp(`^${op.path.replace(/[.*+?^$()|[\]\\]/g, '\\$&').replace(/\\\{[^}]+\\\}|\{[^}]+\}/g, '([^/]+)')}/?$`) }));
354
+
324
355
  // the update operations, by their path as a pattern, for mergeMetadata
325
356
  const UPDATES = (surface.operations as Array<DerivedCall['operation']>).filter((o) => o.class === 'update' && o.method.toUpperCase() === 'POST')
326
357
  .map((op) => ({ op, re: new RegExp(`^${op.path.replace(/[.*+?^$()|[\]\\]/g, '\\$&').replace(/\\\{[^}]+\\\}|\{[^}]+\}/g, '([^/]+)')}/?$`) }));
@@ -339,7 +370,8 @@ async function mergeMetadata(request: Request, coreMerges: (op: DerivedCall['ope
339
370
  let op: DerivedCall['operation'] | undefined;
340
371
  let id = '';
341
372
  for (const u of UPDATES) { const m = u.re.exec(path); if (m) { op = u.op; id = decodeURIComponent(m.at(-1) ?? ''); break; } }
342
- if (!op?.resource) return request;
373
+ if (!op) return createMetadata(request, path);
374
+ if (!op.resource) return request;
343
375
  const params = await readParams(manifest, request.clone(), op);
344
376
  if (!('metadata' in params)) return request;
345
377
  const given = params.metadata;