@volter/twin-stripe 2.0.0 → 2.0.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.
Files changed (67) hide show
  1. package/README.md +33 -1
  2. package/dist/src/index.js +6 -4
  3. package/dist/src/manifest.js +8 -3
  4. package/dist/src/screens/checkout.js +20 -6
  5. package/dist/src/screens/connect-oauth.d.ts +27 -0
  6. package/dist/src/screens/connect-oauth.js +414 -0
  7. package/dist/src/screens/connect-settings.d.ts +22 -0
  8. package/dist/src/screens/connect-settings.js +103 -0
  9. package/dist/src/screens/portal.js +2 -0
  10. package/dist/src/semantics/after-payment.d.ts +1 -1
  11. package/dist/src/semantics/after-payment.js +6 -0
  12. package/dist/src/semantics/charges.js +10 -2
  13. package/dist/src/semantics/checkout.js +19 -6
  14. package/dist/src/semantics/connect.js +20 -3
  15. package/dist/src/semantics/invoices.js +4 -0
  16. package/dist/src/semantics/issuing.js +7 -2
  17. package/dist/src/semantics/ledger.d.ts +11 -6
  18. package/dist/src/semantics/ledger.js +40 -21
  19. package/dist/src/semantics/payment-methods.js +2 -0
  20. package/dist/src/semantics/shared.d.ts +5 -1
  21. package/dist/src/semantics/shared.js +14 -3
  22. package/dist/src/semantics/test-cards.d.ts +4 -0
  23. package/dist/src/semantics/test-cards.js +7 -0
  24. package/dist/src/semantics/transfers.js +1 -1
  25. package/dist/src/stripe-capabilities.js +829 -186
  26. package/dist/src/stripe-conformance.d.ts +2 -0
  27. package/dist/src/stripe-conformance.js +11 -2
  28. package/dist/src/stripe-emit.js +2 -2
  29. package/dist/src/stripe-events.js +14 -10
  30. package/dist/src/stripe-mirror-ui.js +3 -3
  31. package/dist/src/stripe-server.js +97 -24
  32. package/dist/src/stripe-shared.d.ts +3 -0
  33. package/dist/src/stripe-shared.js +3 -0
  34. package/dist/src/stripe-twin.js +7 -1
  35. package/dist/src/stripe-version.d.ts +2 -0
  36. package/dist/src/stripe-version.js +2 -0
  37. package/dist/test-fixtures/stripe-known-deviations.json +6 -1
  38. package/dist/test-fixtures/stripe-schemas.json +85 -12
  39. package/package.json +4 -4
  40. package/src/index.ts +6 -4
  41. package/src/manifest.ts +8 -3
  42. package/src/screens/checkout.tsx +21 -6
  43. package/src/screens/connect-oauth.tsx +400 -0
  44. package/src/screens/connect-settings.tsx +121 -0
  45. package/src/screens/portal.tsx +2 -0
  46. package/src/semantics/after-payment.ts +6 -1
  47. package/src/semantics/charges.ts +11 -2
  48. package/src/semantics/checkout.ts +19 -6
  49. package/src/semantics/connect.ts +19 -3
  50. package/src/semantics/invoices.ts +4 -0
  51. package/src/semantics/issuing.ts +7 -2
  52. package/src/semantics/ledger.ts +60 -23
  53. package/src/semantics/payment-methods.ts +2 -0
  54. package/src/semantics/shared.ts +14 -3
  55. package/src/semantics/test-cards.ts +7 -0
  56. package/src/semantics/transfers.ts +1 -1
  57. package/src/stripe-capabilities.ts +826 -182
  58. package/src/stripe-conformance.ts +13 -2
  59. package/src/stripe-emit.ts +2 -2
  60. package/src/stripe-events.ts +14 -10
  61. package/src/stripe-mirror-ui.ts +3 -3
  62. package/src/stripe-server.ts +85 -24
  63. package/src/stripe-shared.ts +3 -0
  64. package/src/stripe-twin.ts +6 -1
  65. package/src/stripe-version.ts +3 -0
  66. package/test-fixtures/stripe-known-deviations.json +6 -1
  67. package/test-fixtures/stripe-schemas.json +85 -12
@@ -13,6 +13,8 @@ export type StripeConformanceReport = {
13
13
  fieldsChecked: number;
14
14
  violations: StripeViolation[];
15
15
  knownIgnored: number;
16
+ /** fields checked per twin resource type: a type the check skipped (no schema) is absent */
17
+ fieldsByType: Record<string, number>;
16
18
  };
17
19
  /** Load the vendored per-object Stripe JSON Schemas (type+required+enum from OpenAPI). */
18
20
  export declare function loadStripeSchemas(): Promise<StripeSchemas>;
@@ -14,6 +14,7 @@ import { readFile } from 'node:fs/promises';
14
14
  import { twinResources } from '@volter/world-core';
15
15
  import { specConformance } from '@volter/world-tooling';
16
16
  import { OBJECT_NAME } from "./stripe-twin.js";
17
+ import { INCLUDABLE_FIELDS, render } from "./stripe-version.js";
17
18
  // twin resource type -> Stripe OpenAPI object name (the resources the twin serves)
18
19
  const TYPE_TO_OBJECT = {
19
20
  charge: 'charge', customer: 'customer', payment_intent: 'payment_intent', refund: 'refund',
@@ -33,12 +34,18 @@ const fixturePath = (name) => new URL(`../test-fixtures/${name}`, import.meta.ur
33
34
  // The emitted REST body == the twin's view(): strip internal fields, add object + id.
34
35
  // Mirrors stripe-twin.ts view(): a vendor `type` field collides with the kernel
35
36
  // discriminator, so it is stored under `_stripe_type` and restored to `type` on emit.
37
+ // ...and then rendered in the version a caller that pins none is served (stripe-version.ts render): since 24e757f60
38
+ // the twin KEEPS objects in the 2024-06-20 shape its rules were written against and answers them in the served
39
+ // version's (basil onward moved an invoice's subscription and charge, a charge's source, ...), so the stored row is
40
+ // not what any client receives. The schemas are projected from a post-basil spec, so the answer is what is checked.
36
41
  function emitted(r) {
37
42
  const { type, updatedAt, _stripe_type, ...rest } = r;
38
43
  const out = { object: OBJECT_NAME[type] ?? type, ...rest, id: r.id };
39
44
  if (_stripe_type !== undefined)
40
45
  out.type = _stripe_type;
41
- return out;
46
+ // expanded: an includable field (a session's line_items, a charge's refunds) is answered only when a request asks, and
47
+ // the check reads the answer that carries it so its shape is still checked
48
+ return render(out, undefined, INCLUDABLE_FIELDS);
42
49
  }
43
50
  /** Load the vendored per-object Stripe JSON Schemas (type+required+enum from OpenAPI). */
44
51
  export async function loadStripeSchemas() {
@@ -59,6 +66,7 @@ export function checkStripeConformance(schemas, opts = {}) {
59
66
  const violations = [];
60
67
  let fieldsChecked = 0;
61
68
  let knownIgnored = 0;
69
+ const fieldsByType = {};
62
70
  for (const r of resources) {
63
71
  const objectName = TYPE_TO_OBJECT[r.type];
64
72
  if (!objectName)
@@ -68,11 +76,12 @@ export function checkStripeConformance(schemas, opts = {}) {
68
76
  continue;
69
77
  const rep = specConformance.checkSpecConformance(emitted(r), schema, { exemptKey: isTwinExtra, known: opts.known ?? [] });
70
78
  fieldsChecked += rep.fieldsChecked;
79
+ fieldsByType[r.type] = (fieldsByType[r.type] ?? 0) + rep.fieldsChecked;
71
80
  knownIgnored += rep.knownIgnored;
72
81
  for (const v of rep.violations)
73
82
  violations.push({ ...v, object: objectName, id: r.id });
74
83
  }
75
- return { ok: violations.length === 0, resourcesChecked: resources.length, fieldsChecked, violations, knownIgnored };
84
+ return { ok: violations.length === 0, resourcesChecked: resources.length, fieldsChecked, violations, knownIgnored, fieldsByType };
76
85
  }
77
86
  /**
78
87
  * Per-object coverage: of the fields each Stripe object declares, which does the
@@ -11,7 +11,7 @@
11
11
  // DELIVER does not transition state: it snapshots the subject AS IT IS in the twin and fires
12
12
  // the named event about it. Completing a checkout session / paying an invoice is the twin
13
13
  // API's job; this verb answers "my app's webhook handler needs to SEE the event, now."
14
- import { projectResources } from '@volter/world-core';
14
+ import { projectResources, worldNow } from '@volter/world-core';
15
15
  import { OBJECT_NAME, PLATFORM_ACCOUNT_ID, TWIN_API_VERSION, view } from "./stripe-twin.js";
16
16
  import { STRIPE_WEBHOOK_FALLBACK_SECRET, generateTestHeaderString } from "./stripe-events.js";
17
17
  import { render } from "./stripe-version.js";
@@ -115,7 +115,7 @@ export const stripeEmitter = {
115
115
  // The endpoint's own signing secret, from its state row (real Stripe signs per-endpoint).
116
116
  const endpointRow = liveEndpointRows(root).find((w) => String(w.id) === endpoint.id || String(w.url) === endpoint.url);
117
117
  const secret = typeof endpointRow?.secret === 'string' && endpointRow.secret ? endpointRow.secret : STRIPE_WEBHOOK_FALLBACK_SECRET;
118
- const created = occurredAt ? Math.floor(Date.parse(occurredAt) / 1000) : Math.floor(Date.now() / 1000);
118
+ const created = occurredAt ? Math.floor(Date.parse(occurredAt) / 1000) : Math.floor(Date.parse(worldNow()) / 1000);
119
119
  // a connected account's object is its event's, as the write path scopes it (stripe-twin.ts afterStripeWrite): the
120
120
  // account itself, or a row kept on its books (`_account`). "Each event for a connected account contains a top-level
121
121
  // `account` property that identifies the connected account" (docs.stripe.com/connect/webhooks).
@@ -1,4 +1,5 @@
1
- import { nodeBuiltin } from '@volter/world-core';
1
+ import { nodeBuiltin, worldNow, deliveryTraceHeaders } from '@volter/world-core';
2
+ import { appDestination, appFetch } from '@volter/world-core/app-route';
2
3
  import { worldEgressRefusal } from '@volter/world-core/network-policy';
3
4
  import vendorEvents from './generated/events.gen.json' with { type: 'json' };
4
5
  import { render } from "./stripe-version.js";
@@ -12,7 +13,8 @@ export function computeStripeSignature(payload, secret, timestamp) {
12
13
  }
13
14
  /** Build a `Stripe-Signature` header value for `payload` (mirrors generateTestHeaderString). */
14
15
  export function generateTestHeaderString(opts) {
15
- const timestamp = opts.timestamp ?? Math.floor(Date.now() / 1000);
16
+ // signed at the World's time, as its application reads the time (a moved World clock; the app keeps it too)
17
+ const timestamp = opts.timestamp ?? Math.floor(Date.parse(worldNow()) / 1000);
16
18
  const scheme = opts.scheme ?? 'v1';
17
19
  const signature = computeStripeSignature(opts.payload, opts.secret, timestamp);
18
20
  return `t=${timestamp},${scheme}=${signature}`;
@@ -63,7 +65,7 @@ export function constructEvent(payload, header, secret, opts = {}) {
63
65
  throw new StripeSignatureVerificationError('No signatures found matching the expected signature for payload.');
64
66
  }
65
67
  if (opts.tolerance !== undefined) {
66
- const now = opts.now ?? Math.floor(Date.now() / 1000);
68
+ const now = opts.now ?? Math.floor(Date.parse(worldNow()) / 1000);
67
69
  if (now - timestamp > opts.tolerance) {
68
70
  throw new StripeSignatureVerificationError('Timestamp outside the tolerance zone');
69
71
  }
@@ -225,8 +227,9 @@ const httpDelivery = async (url, event, endpointSecret) => {
225
227
  // the event as its API version renders it (stripe-version.ts)
226
228
  const payload = JSON.stringify(render(event, event.api_version));
227
229
  const secret = endpointSecret ?? secrets.get(url) ?? STRIPE_WEBHOOK_FALLBACK_SECRET;
228
- // the World's egress rule: an endpoint it refuses is not delivered to, and says so
229
- const refusal = worldEgressRefusal(url);
230
+ // the World's egress rule: an endpoint it refuses is not delivered to, and says so; the application's own hostnames
231
+ // reach it inside the World
232
+ const refusal = appDestination(url) ? null : worldEgressRefusal(url);
230
233
  if (refusal !== null)
231
234
  console.error(`[twin:stripe] webhook delivery DROPPED — ${event.type} ${event.id} -> ${url}: ${refusal}`);
232
235
  return refusal !== null ? undefined : postEvent(url, event, payload, secret);
@@ -237,7 +240,7 @@ async function postEvent(url, event, payload, secret) {
237
240
  for (let attempt = 1; attempt <= DELIVERY_ATTEMPTS; attempt += 1) {
238
241
  try {
239
242
  const header = generateTestHeaderString({ payload, secret });
240
- await fetch(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 });
241
244
  return;
242
245
  }
243
246
  catch (error) {
@@ -284,15 +287,16 @@ async function httpAuthRequestDelivery(url, event, secret) {
284
287
  // the event as its API version renders it (stripe-version.ts)
285
288
  const payload = JSON.stringify(render(event, event.api_version));
286
289
  const header = generateTestHeaderString({ payload, secret });
287
- // the World's egress rule: a refused endpoint is an unreachable one (the caller's timeout fallback)
288
- const refusal = worldEgressRefusal(url);
290
+ // the World's egress rule: a refused endpoint is an unreachable one (the caller's timeout fallback); the
291
+ // application's own hostnames reach it inside the World
292
+ const refusal = appDestination(url) ? null : worldEgressRefusal(url);
289
293
  return refusal !== null ? Promise.reject(new Error(refusal)) : postAuthRequest(url, payload, header);
290
294
  }
291
295
  /** The HTTP POST of a real-time authorization request, cut off at the window, and the endpoint's answer. */
292
296
  async function postAuthRequest(url, payload, header) {
293
- const res = await fetch(url, {
297
+ const res = await appFetch(url, {
294
298
  method: 'POST',
295
- headers: { 'content-type': 'application/json', 'stripe-signature': header },
299
+ headers: { 'content-type': 'application/json', 'stripe-signature': header, ...deliveryTraceHeaders() },
296
300
  body: payload,
297
301
  signal: AbortSignal.timeout(STRIPE_REALTIME_AUTH_TIMEOUT_MS),
298
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";
@@ -15,6 +15,8 @@ import { stripeIdentityFlow } from "./screens/identity.js";
15
15
  import { stripeOnboardingFlow } from "./screens/onboarding.js";
16
16
  import { stripePortalFlow } from "./screens/portal.js";
17
17
  import { stripePublicDetailsFlow } from "./screens/public-details.js";
18
+ import { connectionRevoked, oauthKeyAccount, redactKey, stripeConnectOAuthFlow } from "./screens/connect-oauth.js";
19
+ import { stripeConnectSettingsFlow } from "./screens/connect-settings.js";
18
20
  import surface from './generated/surface.gen.json' with { type: 'json' };
19
21
  import { manifest } from "./manifest.js";
20
22
  import { appsSecrets } from "./semantics/apps-secrets.js";
@@ -108,6 +110,42 @@ async function publishableKeyRefused(call, scope) {
108
110
  const ctx = await semanticsContext(manifest, call.request.clone(), call.operation, scope);
109
111
  return ctx.get(scoped.type, id)?.client_secret === secret ? undefined : wrongClientSecret(scoped, id);
110
112
  }
113
+ // ── an account whose OAuth connection was revoked ──
114
+ // "After revocation, the account can't be accessed by your platform in the Dashboard or through the API"
115
+ // (docs.stripe.com/connect/oauth-reference, deauthorize; so too a connection revoked by a reused code). A call made as it
116
+ // (the Stripe-Account header) or naming it (/v1/accounts/{account}…) is refused with the error Stripe documents for a
117
+ // Stripe-Account the key cannot use: "account_invalid | The account ID provided as a value for the Stripe-Account header
118
+ // is invalid" (docs.stripe.com/error-codes), as 403, "The API key doesn't have permissions to perform the request"
119
+ // (docs.stripe.com/api/errors). Where the documentation stops and the twin decides: the message's wording; an account
120
+ // the platform created itself, or never connected by OAuth, is not affected.
121
+ async function revokedAccountRefused(call, scope) {
122
+ const named = [call.request.headers.get('stripe-account') ?? undefined, call.operation.path.startsWith('/v1/accounts/{account}') ? call.params.account : undefined].filter((a) => !!a);
123
+ if (!named.length)
124
+ return undefined;
125
+ const ctx = await semanticsContext(manifest, new Request(call.request.url), call.operation, scope); // the rows only: no body read
126
+ const revoked = named.find((a) => connectionRevoked(ctx, a));
127
+ if (!revoked)
128
+ return undefined;
129
+ const key = requestKey(call.request);
130
+ return vendorError(manifest, { status: 403, code: 'account_invalid', message: `The provided key '${redactKey(key)}' does not have access to account '${revoked}' (or that account does not exist). Application access may have been revoked.` });
131
+ }
132
+ /** The API key a request carries: Bearer, or Basic's user (`curl -u sk_…:`, docs.stripe.com/api/authentication). */
133
+ function requestKey(request) {
134
+ const header = request.headers.get('authorization') ?? '';
135
+ const bearer = /^bearer\s+(\S+)/i.exec(header)?.[1];
136
+ if (bearer)
137
+ return bearer;
138
+ const basic = /^basic\s+(\S+)/i.exec(header)?.[1];
139
+ if (basic) {
140
+ try {
141
+ return atob(basic).split(':')[0] ?? '';
142
+ }
143
+ catch {
144
+ return '';
145
+ }
146
+ }
147
+ return '';
148
+ }
111
149
  /** A client-secret call whose secret is not the object's: answered as though the key could not see it (the twin's
112
150
  * decision, above). */
113
151
  function wrongClientSecret(scoped, id) {
@@ -146,14 +184,16 @@ export function createStripeTwinFetch(options) {
146
184
  core,
147
185
  // after the credential and the version, a publishable key is held to the client-side calls, then a request's
148
186
  // parameters are checked against the operation's (stripe-params.ts)
149
- around: (call, next) => guard(call, async () => (await publishableKeyRefused(call, scope)) ?? (await refuseParameters(call)) ?? next()),
187
+ around: (call, next) => guard(call, async () => (await publishableKeyRefused(call, scope)) ?? (await revokedAccountRefused(call, scope)) ?? (await refuseParameters(call)) ?? next()),
150
188
  gap: (request) => vendorError(manifest, { status: 404, message: `Unrecognized request URL (${request.method}: ${new URL(request.url).pathname}).` }),
151
189
  });
152
190
  // the hosted flows sit beside the API: checkout.stripe.com's payment page, billing.stripe.com's customer portal,
153
- // connect.stripe.com's onboarding, verify.stripe.com's identity check and the bank-linking flow Stripe.js opens, and
154
- // the Dashboard's Public details page (dashboard.stripe.com/settings/public, the platform's customer-facing name);
191
+ // connect.stripe.com's onboarding and its OAuth endpoints (screens/connect-oauth.tsx), verify.stripe.com's identity
192
+ // check and the bank-linking flow Stripe.js opens, and the Dashboard's Public details page
193
+ // (dashboard.stripe.com/settings/public, the platform's customer-facing name) and Connect OAuth settings
194
+ // (dashboard.stripe.com/settings/connect/onboarding-options/oauth: the client_id, OAuth on, the redirect URIs);
155
195
  // a read-only twin takes no payments and moves nothing
156
- const flows = [stripeCheckoutFlow(scope), stripePortalFlow(scope), stripeOnboardingFlow(scope), stripeIdentityFlow(scope), stripeFinancialConnectionsFlow(scope), stripePublicDetailsFlow(scope)];
196
+ const flows = [stripeCheckoutFlow(scope), stripePortalFlow(scope), stripeOnboardingFlow(scope), stripeIdentityFlow(scope), stripeFinancialConnectionsFlow(scope), stripePublicDetailsFlow(scope), stripeConnectOAuthFlow(scope), stripeConnectSettingsFlow(scope)];
157
197
  // the twin's own doors sit in front of the API: discovery, and what stands in for an act Stripe's API
158
198
  // does not have (stripe-twin.ts)
159
199
  // GET /twin: what this twin is (not Stripe's; a host reads it)
@@ -179,26 +219,29 @@ export function createStripeTwinFetch(options) {
179
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
180
220
  // (the API, the hosted pages) reads the account as it stands now
181
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)
182
223
  const catchUp = async (request) => {
183
224
  if (readOnly)
184
225
  return;
185
- const ctx = await semanticsContext(manifest, new Request(request.url), billingClock, scope);
186
- // a test clock advanced by the last request has reached its time (semantics/test-clocks.ts)
187
- await finishClockAdvances(ctx);
188
- await advanceBilling(ctx);
189
- // a submitted bank debit settles (in test mode at once) before anything is answered (semantics/payment-intents.ts)
190
- await settleBankDebits(ctx);
191
- // a coupon past its redeem_by is no longer valid (semantics/coupons.ts)
192
- await lapseCoupons(ctx);
193
- // a top-up's funds arrive five days after it is made (semantics/terminal.ts)
194
- await settleTopups(ctx);
195
- // a report run completes (semantics/platform.ts)
196
- await finishReportRuns(ctx);
197
- // a real-time authorization request no one answered is decided when its window ends (semantics/issuing.ts)
198
- await lapseRealtimeRequests(ctx);
199
- // a refund held for want of balance is made when funds cover it, before any payout takes them (semantics/refunds.ts)
200
- await settleHeldRefunds(ctx);
201
- 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
+ });
202
245
  };
203
246
  // Time's EVENTS, sent on the vendor's clock through a twin-only drain door, as the qstash and vercel twins send
204
247
  // theirs (a caller — a runner's drainer — drives each move). POST /_twin/drain catches time up as every request
@@ -245,8 +288,35 @@ export function createStripeTwinFetch(options) {
245
288
  if (page)
246
289
  return page;
247
290
  }
291
+ const acting = await actingAsOAuthKey(request);
292
+ if (acting instanceof Response)
293
+ return acting;
248
294
  // an update's metadata is merged into what the object holds before the handler or the core serves it (mergeMetadata)
249
- return derived(readOnly ? request : await mergeMetadata(request, (op) => !(op.id in handlerMap) && core.owns(op), scope));
295
+ return derived(readOnly ? acting : await mergeMetadata(acting, (op) => !(op.id in handlerMap) && core.owns(op), scope));
296
+ };
297
+ // A connected account's OAuth keys (screens/connect-oauth.tsx): the token endpoint's access_token is "Use the
298
+ // Stripe-Account header with your platform's secret key (that can make requests on behalf of this Stripe account)" and
299
+ // its stripe_publishable_key the same with the publishable key (docs.stripe.com/connect/oauth-reference): a request made
300
+ // with one acts as that account, as a request with the Stripe-Account header does. A key no live connection holds (its
301
+ // connection revoked, or replaced by a refresh: "Any existing access token with the same scope and mode ... is
302
+ // revoked") is Stripe's unknown key, 401 "Invalid API Key provided" (docs.stripe.com/api/authentication, docs.stripe.com
303
+ // /error-codes); one sent with a Stripe-Account header naming another account cannot act as it (account_invalid).
304
+ // Where the documentation stops and the twin decides: the messages' wording; a read_only key is not held to reads
305
+ // (manifest todo stripe.connect.oauth_scope_enforcement).
306
+ const actingAsOAuthKey = async (request) => {
307
+ const key = requestKey(request);
308
+ if (!/^(sk|pk)_test_oauth_/.test(key))
309
+ return request;
310
+ const held = oauthKeyAccount(await semanticsContext(manifest, new Request(request.url), billingClock, scope), key);
311
+ if (!held)
312
+ return vendorError(manifest, { status: 401, message: `Invalid API Key provided: ${redactKey(key)}` });
313
+ const named = request.headers.get('stripe-account');
314
+ if (named && named !== held.account)
315
+ return vendorError(manifest, { status: 403, code: 'account_invalid', message: `The provided key '${redactKey(key)}' does not have access to account '${named}' (or that account does not exist). Application access may have been revoked.` });
316
+ const headers = new Headers(request.headers);
317
+ headers.set('stripe-account', held.account);
318
+ const bodied = request.method !== 'GET' && request.method !== 'HEAD';
319
+ return new Request(request.url, { method: request.method, headers, ...(bodied ? { body: await request.arrayBuffer() } : {}) });
250
320
  };
251
321
  // every answer in the shape of the API version the caller is served (stripe-version.ts)
252
322
  const rendered = async (request) => {
@@ -263,7 +333,10 @@ export function createStripeTwinFetch(options) {
263
333
  const answered = creates ? body : withoutEndpointSecret(body);
264
334
  return new Response(JSON.stringify(servesCurrent(pinned) ? render(answered, pinned, await expand) : answered), { status: res.status, statusText: res.statusText, headers: res.headers });
265
335
  };
266
- 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 }));
267
340
  }
268
341
  export async function createStripeTwinServer(options) {
269
342
  const server = await serveHttp({
@@ -7,6 +7,9 @@ export type StripeRow = Record<string, any>;
7
7
  * Dashboard to differentiate between accounts", the same object page), and with neither to "Twin Inc.", the name the
8
8
  * Dashboard mirror has always given the World's own account. */
9
9
  export declare const PLATFORM_DEFAULT_NAME = "Twin Inc.";
10
+ /** The bookkeeping type of a connected account's Connect OAuth connection (screens/connect-oauth.tsx): one row per
11
+ * account, `revoked: true` once deauthorized or its code reused. Read by the account list and the event path too. */
12
+ export declare const OAUTH_CONNECTIONS = "_oauth_connection";
10
13
  export declare function publicBusinessName(account: StripeRow | undefined): string;
11
14
  /**
12
15
  * Format a Stripe minor-unit amount (cents) as a localized currency string.
@@ -10,6 +10,9 @@
10
10
  * Dashboard to differentiate between accounts", the same object page), and with neither to "Twin Inc.", the name the
11
11
  * Dashboard mirror has always given the World's own account. */
12
12
  export const PLATFORM_DEFAULT_NAME = 'Twin Inc.';
13
+ /** The bookkeeping type of a connected account's Connect OAuth connection (screens/connect-oauth.tsx): one row per
14
+ * account, `revoked: true` once deauthorized or its code reused. Read by the account list and the event path too. */
15
+ export const OAUTH_CONNECTIONS = '_oauth_connection';
13
16
  export function publicBusinessName(account) {
14
17
  const named = (v) => (typeof v === 'string' && v.trim() ? v : undefined);
15
18
  return named(account?.business_profile?.name) ?? named(account?.settings?.dashboard?.display_name) ?? PLATFORM_DEFAULT_NAME;
@@ -15,6 +15,7 @@ import { emitStripeEvent, eventTypeFor } from "./stripe-events.js";
15
15
  import { SERVED_VERSION } from "./stripe-version.js";
16
16
  import { planOf } from "./semantics/plans.js";
17
17
  import { platformAccountDefault } from "./semantics/connect.js";
18
+ import { OAUTH_CONNECTIONS } from "./stripe-shared.js";
18
19
  const SERVICE = 'stripe';
19
20
  // The Stripe API version a request that pins none is served in: the vendored spec's (stripe-version.ts renders
20
21
  // every answer in it). A request may override it via the `Stripe-Version` header (apiVersion);
@@ -440,6 +441,11 @@ export async function afterStripeWrite(type, op, out, root, occurredAt, apiVersi
440
441
  // stored before it is delivered: a consumer that looks its webhook's event up finds it
441
442
  if (eventType)
442
443
  await persistStripeEvent(eventType, object, root, occurredAt, apiVersion, account, id);
444
+ // a connected account whose OAuth connection was revoked "can't be accessed by your platform" (docs.stripe.com/connect/
445
+ // oauth-reference): its events (a payout time makes, a renewal) are its own and no longer reach the platform's Connect
446
+ // endpoints, except the account.application.deauthorized that says so (screens/connect-oauth.tsx)
447
+ if (account && eventType !== 'account.application.deauthorized' && rows(OAUTH_CONNECTIONS, root).some((c) => c.id === account && c.revoked === true))
448
+ return;
443
449
  await emitStripeEvent(op, object, { occurredAt: occurredAt ?? '1970-01-01T00:00:00.000Z', endpoints: webhookTargets(root), ...(account ? { account } : {}), ...(id ? { id } : {}) });
444
450
  }
445
451
  /** Event numbers taken in this process, per root, before their events are stored: two writes whose events overlap
@@ -1170,7 +1176,7 @@ export async function handleStripeTwinRequest(req) {
1170
1176
  let path = req.path.startsWith('/') ? req.path : `/${req.path}`;
1171
1177
  if ((method === 'GET' || method === 'HEAD') && req.body)
1172
1178
  path += (path.includes('?') ? '&' : '?') + req.body;
1173
- const response = await twin(new Request(`https://api.stripe.com${path}`, { method, headers, ...(method !== 'GET' && method !== 'HEAD' && req.body ? { body: req.body } : {}) }));
1179
+ const response = await twin(new Request(`http://stripe.test${path}`, { method, headers, ...(method !== 'GET' && method !== 'HEAD' && req.body ? { body: req.body } : {}) }));
1174
1180
  const text = await response.text();
1175
1181
  return { status: response.status, body: text ? JSON.parse(text) : null };
1176
1182
  }
@@ -2,6 +2,8 @@
2
2
  export declare const SERVED_VERSION: string;
3
3
  /** Whether a request is answered in the served version's shape. */
4
4
  export declare const servesCurrent: (pinned?: string | null) => boolean;
5
+ /** Every includable field name, as `expand` paths on a top-level object: what a check of an object's FULL answer expands. */
6
+ export declare const INCLUDABLE_FIELDS: string[];
5
7
  /** The `expand[]` paths a request names, in its query or its body (JSON or form). */
6
8
  export declare function expandOf(request: Request): Promise<string[]>;
7
9
  /** An answer with every webhook endpoint's `secret` left out: Stripe answers it only when the endpoint is created. */
@@ -221,6 +221,8 @@ const INCLUDABLE = {
221
221
  // request it with the `expand` request parameter))"
222
222
  invoice: ['confirmation_secret'],
223
223
  };
224
+ /** Every includable field name, as `expand` paths on a top-level object: what a check of an object's FULL answer expands. */
225
+ export const INCLUDABLE_FIELDS = [...new Set(Object.values(INCLUDABLE).flat())];
224
226
  /** The `expand[]` paths a request names, in its query or its body (JSON or form). */
225
227
  export async function expandOf(request) {
226
228
  const url = new URL(request.url);
@@ -100,6 +100,11 @@
100
100
  "path": "price",
101
101
  "kind": "extra",
102
102
  "reason": "PEAK-3102 round 2: POST /v1/invoiceitems now stores the caller-supplied `price` id on the invoiceitem (needed to resolve amount = unit_amount * quantity for PeakHealth's catalog-product order path). Real Stripe replaced the top-level InvoiceItem.price field with a `pricing.price_details` object in a later API-version migration — stripe-schemas.json (LATEST published shape) reflects the post-migration object and has no price property, hence 'extra' under the harness's single-snapshot schema, same mechanism as current_period_start/_end and payment_intent above. This twin's default-served TWIN_API_VERSION ('2024-06-20') pre-dates that migration, where real Stripe DOES emit invoiceitem.price top-level, so emitting it here is faithful to what this twin actually serves by default."
103
+ },
104
+ {
105
+ "path": "net_amount",
106
+ "kind": "type",
107
+ "reason": "invoiceitem.net_amount is typed a non-nullable integer in the served spec, but its own description says \"This field is `null` for `discountable=true` items\" (served spec, invoiceitem.net_amount). The twin follows the description: a discountable item answers null. The spec's type and its description disagree; this declares the twin's side."
103
108
  }
104
- ]
109
+ ]
105
110
  }