@volter/twin-stripe 0.1.2 → 2.0.0

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 (219) hide show
  1. package/README.md +64 -27
  2. package/client/dashboard-api.ts +286 -0
  3. package/client/stripe-mirror.css +272 -159
  4. package/client/stripe-mirror.tsx +1384 -541
  5. package/dist/client/dashboard-api.d.ts +107 -0
  6. package/dist/client/dashboard-api.js +238 -0
  7. package/dist/client/dashboard-api.ts +286 -0
  8. package/dist/client/stripe-mirror.bundle.js +236 -0
  9. package/dist/client/stripe-mirror.css +275 -0
  10. package/dist/client/stripe-mirror.d.ts +134 -0
  11. package/dist/client/stripe-mirror.js +823 -0
  12. package/dist/client/stripe-mirror.tsx +1534 -0
  13. package/dist/src/cli.d.ts +2 -0
  14. package/dist/src/cli.js +39 -0
  15. package/dist/src/generated/events.gen.json +1 -0
  16. package/dist/src/generated/surface.gen.json +1 -0
  17. package/dist/src/generated/ui.gen.json +1 -0
  18. package/dist/src/index.d.ts +14 -0
  19. package/dist/src/index.js +73 -0
  20. package/dist/src/manifest.d.ts +2 -0
  21. package/dist/src/manifest.js +1065 -0
  22. package/dist/src/screens/checkout.d.ts +31 -0
  23. package/dist/src/screens/checkout.js +241 -0
  24. package/dist/src/screens/consent-skin.d.ts +4 -0
  25. package/dist/src/screens/consent-skin.js +18 -0
  26. package/dist/src/screens/financial-connections.d.ts +5 -0
  27. package/dist/src/screens/financial-connections.js +90 -0
  28. package/dist/src/screens/identity.d.ts +5 -0
  29. package/dist/src/screens/identity.js +86 -0
  30. package/dist/src/screens/industries.d.ts +1 -0
  31. package/dist/src/screens/industries.js +267 -0
  32. package/dist/src/screens/onboarding.d.ts +13 -0
  33. package/dist/src/screens/onboarding.js +225 -0
  34. package/dist/src/screens/portal.d.ts +5 -0
  35. package/dist/src/screens/portal.js +214 -0
  36. package/dist/src/screens/public-details.d.ts +5 -0
  37. package/dist/src/screens/public-details.js +90 -0
  38. package/dist/src/semantics/after-payment.d.ts +22 -0
  39. package/dist/src/semantics/after-payment.js +93 -0
  40. package/dist/src/semantics/apps-secrets.d.ts +2 -0
  41. package/dist/src/semantics/apps-secrets.js +54 -0
  42. package/dist/src/semantics/balance.d.ts +11 -0
  43. package/dist/src/semantics/balance.js +195 -0
  44. package/dist/src/semantics/billing.d.ts +2 -0
  45. package/dist/src/semantics/billing.js +220 -0
  46. package/dist/src/semantics/charges.d.ts +28 -0
  47. package/dist/src/semantics/charges.js +201 -0
  48. package/dist/src/semantics/checkout.d.ts +15 -0
  49. package/dist/src/semantics/checkout.js +303 -0
  50. package/dist/src/semantics/connect.d.ts +5 -0
  51. package/dist/src/semantics/connect.js +476 -0
  52. package/dist/src/semantics/coupons.d.ts +6 -0
  53. package/dist/src/semantics/coupons.js +92 -0
  54. package/dist/src/semantics/credit-notes.d.ts +2 -0
  55. package/dist/src/semantics/credit-notes.js +172 -0
  56. package/dist/src/semantics/customers.d.ts +6 -0
  57. package/dist/src/semantics/customers.js +429 -0
  58. package/dist/src/semantics/disputes.d.ts +2 -0
  59. package/dist/src/semantics/disputes.js +51 -0
  60. package/dist/src/semantics/entitlements.d.ts +2 -0
  61. package/dist/src/semantics/entitlements.js +95 -0
  62. package/dist/src/semantics/ephemeral-keys.d.ts +2 -0
  63. package/dist/src/semantics/ephemeral-keys.js +34 -0
  64. package/dist/src/semantics/files.d.ts +2 -0
  65. package/dist/src/semantics/files.js +125 -0
  66. package/dist/src/semantics/invoices.d.ts +18 -0
  67. package/dist/src/semantics/invoices.js +541 -0
  68. package/dist/src/semantics/issuing.d.ts +13 -0
  69. package/dist/src/semantics/issuing.js +570 -0
  70. package/dist/src/semantics/ledger.d.ts +54 -0
  71. package/dist/src/semantics/ledger.js +181 -0
  72. package/dist/src/semantics/payment-intents.d.ts +18 -0
  73. package/dist/src/semantics/payment-intents.js +404 -0
  74. package/dist/src/semantics/payment-links.d.ts +2 -0
  75. package/dist/src/semantics/payment-links.js +133 -0
  76. package/dist/src/semantics/payment-methods.d.ts +20 -0
  77. package/dist/src/semantics/payment-methods.js +138 -0
  78. package/dist/src/semantics/plans.d.ts +5 -0
  79. package/dist/src/semantics/plans.js +121 -0
  80. package/dist/src/semantics/platform.d.ts +9 -0
  81. package/dist/src/semantics/platform.js +206 -0
  82. package/dist/src/semantics/products.d.ts +2 -0
  83. package/dist/src/semantics/products.js +140 -0
  84. package/dist/src/semantics/radar.d.ts +2 -0
  85. package/dist/src/semantics/radar.js +83 -0
  86. package/dist/src/semantics/refunds.d.ts +9 -0
  87. package/dist/src/semantics/refunds.js +195 -0
  88. package/dist/src/semantics/renewals.d.ts +47 -0
  89. package/dist/src/semantics/renewals.js +251 -0
  90. package/dist/src/semantics/setup-intents.d.ts +2 -0
  91. package/dist/src/semantics/setup-intents.js +84 -0
  92. package/dist/src/semantics/shared.d.ts +78 -0
  93. package/dist/src/semantics/shared.js +192 -0
  94. package/dist/src/semantics/subscription-schedules.d.ts +2 -0
  95. package/dist/src/semantics/subscription-schedules.js +119 -0
  96. package/dist/src/semantics/subscriptions.d.ts +11 -0
  97. package/dist/src/semantics/subscriptions.js +605 -0
  98. package/dist/src/semantics/tax.d.ts +2 -0
  99. package/dist/src/semantics/tax.js +197 -0
  100. package/dist/src/semantics/terminal.d.ts +5 -0
  101. package/dist/src/semantics/terminal.js +182 -0
  102. package/dist/src/semantics/test-clocks.d.ts +6 -0
  103. package/dist/src/semantics/test-clocks.js +73 -0
  104. package/dist/src/semantics/tokens.d.ts +4 -0
  105. package/dist/src/semantics/tokens.js +44 -0
  106. package/dist/src/semantics/transfers.d.ts +2 -0
  107. package/dist/src/semantics/transfers.js +154 -0
  108. package/dist/src/semantics/treasury.d.ts +2 -0
  109. package/dist/src/semantics/treasury.js +377 -0
  110. package/dist/src/semantics/webhook-endpoints.d.ts +3 -0
  111. package/dist/src/semantics/webhook-endpoints.js +85 -0
  112. package/dist/src/stripe-budget.d.ts +55 -0
  113. package/dist/src/stripe-budget.js +155 -0
  114. package/dist/src/stripe-capabilities.d.ts +3 -0
  115. package/dist/src/stripe-capabilities.js +5052 -0
  116. package/dist/src/stripe-conformance.d.ts +41 -0
  117. package/dist/src/stripe-conformance.js +96 -0
  118. package/dist/src/stripe-connector.d.ts +161 -0
  119. package/dist/src/stripe-connector.js +414 -0
  120. package/dist/src/stripe-emit.d.ts +2 -0
  121. package/dist/src/stripe-emit.js +145 -0
  122. package/dist/src/stripe-events.d.ts +93 -0
  123. package/dist/src/stripe-events.js +388 -0
  124. package/dist/src/stripe-js.d.ts +4 -0
  125. package/dist/src/stripe-js.js +70 -0
  126. package/dist/src/stripe-mirror-ui.d.ts +15 -0
  127. package/dist/src/stripe-mirror-ui.js +87 -0
  128. package/dist/src/stripe-params.d.ts +3 -0
  129. package/dist/src/stripe-params.js +43 -0
  130. package/dist/src/stripe-perform-harness.d.ts +9 -0
  131. package/dist/src/stripe-perform-harness.js +26 -0
  132. package/dist/src/stripe-server.d.ts +33 -0
  133. package/dist/src/stripe-server.js +326 -0
  134. package/dist/src/stripe-shared.d.ts +106 -0
  135. package/dist/src/stripe-shared.js +273 -0
  136. package/dist/src/stripe-twin.d.ts +155 -0
  137. package/dist/src/stripe-twin.js +1226 -0
  138. package/dist/src/stripe-ui-conformance.d.ts +5 -0
  139. package/dist/src/stripe-ui-conformance.js +79 -0
  140. package/dist/src/stripe-ui-structure.d.ts +3 -0
  141. package/dist/src/stripe-ui-structure.js +168 -0
  142. package/dist/src/stripe-version.d.ts +10 -0
  143. package/dist/src/stripe-version.js +285 -0
  144. package/dist/test-fixtures/stripe-known-deviations.json +105 -0
  145. package/dist/test-fixtures/stripe-openapi-operations.SOURCE.md +14 -0
  146. package/dist/test-fixtures/stripe-openapi-operations.json +4717 -0
  147. package/dist/test-fixtures/stripe-schemas.SOURCE.md +35 -0
  148. package/dist/test-fixtures/stripe-schemas.json +3740 -0
  149. package/package.json +18 -10
  150. package/src/cli.ts +7 -7
  151. package/src/generated/events.gen.json +1 -0
  152. package/src/generated/surface.gen.json +1 -0
  153. package/src/generated/ui.gen.json +1 -0
  154. package/src/index.ts +31 -9
  155. package/src/manifest.ts +1097 -0
  156. package/src/screens/checkout.tsx +252 -0
  157. package/src/screens/consent-skin.ts +20 -0
  158. package/src/screens/financial-connections.tsx +101 -0
  159. package/src/screens/identity.tsx +96 -0
  160. package/src/screens/industries.ts +267 -0
  161. package/src/screens/onboarding.tsx +243 -0
  162. package/src/screens/portal.tsx +218 -0
  163. package/src/screens/public-details.tsx +105 -0
  164. package/src/semantics/after-payment.ts +113 -0
  165. package/src/semantics/apps-secrets.ts +58 -0
  166. package/src/semantics/balance.ts +209 -0
  167. package/src/semantics/billing.ts +216 -0
  168. package/src/semantics/charges.ts +211 -0
  169. package/src/semantics/checkout.ts +297 -0
  170. package/src/semantics/connect.ts +471 -0
  171. package/src/semantics/coupons.ts +97 -0
  172. package/src/semantics/credit-notes.ts +168 -0
  173. package/src/semantics/customers.ts +432 -0
  174. package/src/semantics/disputes.ts +62 -0
  175. package/src/semantics/entitlements.ts +94 -0
  176. package/src/semantics/ephemeral-keys.ts +34 -0
  177. package/src/semantics/files.ts +143 -0
  178. package/src/semantics/invoices.ts +541 -0
  179. package/src/semantics/issuing.ts +585 -0
  180. package/src/semantics/ledger.ts +216 -0
  181. package/src/semantics/payment-intents.ts +420 -0
  182. package/src/semantics/payment-links.ts +148 -0
  183. package/src/semantics/payment-methods.ts +143 -0
  184. package/src/semantics/plans.ts +131 -0
  185. package/src/semantics/platform.ts +220 -0
  186. package/src/semantics/products.ts +154 -0
  187. package/src/semantics/radar.ts +85 -0
  188. package/src/semantics/refunds.ts +218 -0
  189. package/src/semantics/renewals.ts +274 -0
  190. package/src/semantics/setup-intents.ts +87 -0
  191. package/src/semantics/shared.ts +215 -0
  192. package/src/semantics/subscription-schedules.ts +129 -0
  193. package/src/semantics/subscriptions.ts +610 -0
  194. package/src/semantics/tax.ts +220 -0
  195. package/src/semantics/terminal.ts +195 -0
  196. package/src/semantics/test-clocks.ts +77 -0
  197. package/src/semantics/tokens.ts +52 -0
  198. package/src/semantics/transfers.ts +174 -0
  199. package/src/semantics/treasury.ts +383 -0
  200. package/src/semantics/webhook-endpoints.ts +87 -0
  201. package/src/stripe-budget.ts +4 -4
  202. package/src/stripe-capabilities.ts +1456 -222
  203. package/src/stripe-conformance.ts +6 -5
  204. package/src/stripe-connector.ts +68 -40
  205. package/src/stripe-emit.ts +14 -7
  206. package/src/stripe-events.ts +94 -36
  207. package/src/stripe-js.ts +70 -0
  208. package/src/stripe-mirror-ui.ts +28 -298
  209. package/src/stripe-params.ts +44 -0
  210. package/src/stripe-perform-harness.ts +29 -0
  211. package/src/stripe-server.ts +263 -38
  212. package/src/stripe-shared.ts +294 -0
  213. package/src/stripe-twin.ts +429 -5325
  214. package/src/stripe-ui-conformance.ts +70 -107
  215. package/src/stripe-ui-structure.ts +124 -348
  216. package/src/stripe-version.ts +278 -0
  217. package/test-fixtures/stripe-known-deviations.json +2 -7
  218. package/test-fixtures/stripe-openapi-operations.json +1188 -2855
  219. package/src/stripe-form.ts +0 -35
@@ -0,0 +1,388 @@
1
+ import { nodeBuiltin } from '@volter/world-core';
2
+ import { worldEgressRefusal } from '@volter/world-core/network-policy';
3
+ import vendorEvents from './generated/events.gen.json' with { type: 'json' };
4
+ import { render } from "./stripe-version.js";
5
+ function nodeCrypto() {
6
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
7
+ return nodeBuiltin('node:crypto');
8
+ }
9
+ /** HMAC-SHA256(secret, `${timestamp}.${payload}`) as lowercase hex — Stripe's v1 signature. */
10
+ export function computeStripeSignature(payload, secret, timestamp) {
11
+ return nodeCrypto().createHmac('sha256', secret).update(`${timestamp}.${payload}`, 'utf8').digest('hex');
12
+ }
13
+ /** Build a `Stripe-Signature` header value for `payload` (mirrors generateTestHeaderString). */
14
+ export function generateTestHeaderString(opts) {
15
+ const timestamp = opts.timestamp ?? Math.floor(Date.now() / 1000);
16
+ const scheme = opts.scheme ?? 'v1';
17
+ const signature = computeStripeSignature(opts.payload, opts.secret, timestamp);
18
+ return `t=${timestamp},${scheme}=${signature}`;
19
+ }
20
+ /** Parse a `Stripe-Signature` header into its timestamp + the list of v1 signatures. */
21
+ function parseSignatureHeader(header) {
22
+ let timestamp = -1;
23
+ const signatures = [];
24
+ for (const part of header.split(',')) {
25
+ const eq = part.indexOf('=');
26
+ if (eq === -1)
27
+ continue;
28
+ const key = part.slice(0, eq).trim();
29
+ const value = part.slice(eq + 1).trim();
30
+ if (key === 't')
31
+ timestamp = Number(value);
32
+ else if (key === 'v1')
33
+ signatures.push(value);
34
+ }
35
+ return { timestamp, signatures };
36
+ }
37
+ export class StripeSignatureVerificationError extends Error {
38
+ constructor(message) {
39
+ super(message);
40
+ this.name = 'StripeSignatureVerificationError';
41
+ }
42
+ }
43
+ /**
44
+ * Verify a webhook payload + signature header against the signing secret and return the
45
+ * parsed event (mirrors `stripe.webhooks.constructEvent`). Throws a
46
+ * StripeSignatureVerificationError on a malformed/missing header, a signature that does
47
+ * not match, or (when `tolerance` is given) a timestamp outside the allowed window.
48
+ */
49
+ export function constructEvent(payload, header, secret, opts = {}) {
50
+ if (!header)
51
+ throw new StripeSignatureVerificationError('No signatures found matching the expected signature for payload.');
52
+ const { timestamp, signatures } = parseSignatureHeader(header);
53
+ if (timestamp < 0 || signatures.length === 0) {
54
+ throw new StripeSignatureVerificationError('Unable to extract timestamp and signatures from header');
55
+ }
56
+ const expected = computeStripeSignature(payload, secret, timestamp);
57
+ const expectedBuf = Buffer.from(expected, 'utf8');
58
+ const matched = signatures.some((s) => {
59
+ const sBuf = Buffer.from(s, 'utf8');
60
+ return sBuf.length === expectedBuf.length && nodeCrypto().timingSafeEqual(sBuf, expectedBuf);
61
+ });
62
+ if (!matched) {
63
+ throw new StripeSignatureVerificationError('No signatures found matching the expected signature for payload.');
64
+ }
65
+ if (opts.tolerance !== undefined) {
66
+ const now = opts.now ?? Math.floor(Date.now() / 1000);
67
+ if (now - timestamp > opts.tolerance) {
68
+ throw new StripeSignatureVerificationError('Timestamp outside the tolerance zone');
69
+ }
70
+ }
71
+ return JSON.parse(payload);
72
+ }
73
+ const registry = [];
74
+ // URLs a HOST registered directly (registerStripeWebhook), with the secret to sign with and the
75
+ // enabled_events to filter on. A World's own endpoints are not here: POST /v1/webhook_endpoints
76
+ // writes the WebhookEndpoint to the tree, and every emit reads the tree (the caller passes them as
77
+ // `endpoints`), so delivery survives a restart and belongs to one World. A URL registered here
78
+ // without a secret is signed with the fallback below, so the header is still a real, verifiable
79
+ // signature; one registered without a list receives every event, equivalent to ['*'].
80
+ const secrets = new Map();
81
+ const subscriptions = new Map();
82
+ export const STRIPE_WEBHOOK_FALLBACK_SECRET = 'whsec_twin_default';
83
+ export function registerStripeWebhook(url, secret, enabledEvents) {
84
+ if (!registry.includes(url))
85
+ registry.push(url);
86
+ if (secret)
87
+ secrets.set(url, secret);
88
+ if (enabledEvents)
89
+ subscriptions.set(url, enabledEvents.map(String));
90
+ }
91
+ export function unregisterStripeWebhook(url) {
92
+ const i = registry.indexOf(url);
93
+ if (i !== -1)
94
+ registry.splice(i, 1);
95
+ secrets.delete(url);
96
+ subscriptions.delete(url);
97
+ }
98
+ export function clearStripeWebhooks() {
99
+ registry.length = 0;
100
+ secrets.clear();
101
+ subscriptions.clear();
102
+ }
103
+ // Does an enabled_events list subscribe an endpoint to `type`? Stripe semantics: '*'
104
+ // matches everything, 'issuing_authorization.*' matches the family, else exact. Shared by
105
+ // the organic fan-out below and the synchronous issuing leg's enrollment check.
106
+ export function stripeEventMatches(enabledEvents, type) {
107
+ if (!Array.isArray(enabledEvents))
108
+ return false;
109
+ return enabledEvents.some((p) => {
110
+ const s = String(p);
111
+ return s === '*' || (s.endsWith('.*') && type.startsWith(s.slice(0, -1))) || s === type;
112
+ });
113
+ }
114
+ export function listStripeWebhooks() {
115
+ return [...registry];
116
+ }
117
+ // twin write operation → Stripe event type (the ones an app's webhooks care about).
118
+ // Returns null when an operation has no event (e.g. a plain create/retrieve we
119
+ // don't model an event for).
120
+ const VENDOR_EVENT_TYPES = new Set(vendorEvents.types);
121
+ export function eventTypeFor(operation) {
122
+ // Several twin write operations ARE already the Stripe event type (we record the
123
+ // op as `<resource>.<event>` so it maps 1:1): payment_intent.amount_capturable_updated,
124
+ // payment_intent.canceled, payment_intent.succeeded. Those pass through directly.
125
+ const PASSTHROUGH = new Set([
126
+ 'payment_intent.amount_capturable_updated', 'payment_intent.canceled', 'payment_intent.succeeded',
127
+ 'payment_intent.payment_failed', 'payment_intent.created', 'payment_intent.processing',
128
+ 'payment_intent.requires_action',
129
+ 'charge.succeeded', 'charge.captured', 'charge.refunded', 'charge.dispute.created',
130
+ // A refund fires TWO real Stripe events: `refund.created` carrying the Refund, and
131
+ // `charge.refunded` carrying the CHARGE with its new totals. The twin records the charge's
132
+ // own write as `charge.refunded`, so the event a consumer receives is the charge — which is
133
+ // the whole point of that event and the reason a reconciler subscribes to it.
134
+ 'refund.created', 'refund.updated',
135
+ 'customer.updated', 'customer.deleted', 'invoice.created', 'invoice.finalized',
136
+ 'invoice.paid', 'invoice.payment_failed', 'invoice.voided', 'invoice.marked_uncollectible', 'invoice.sent',
137
+ 'customer.subscription.updated', 'customer.subscription.paused', 'customer.subscription.resumed',
138
+ 'payout.created', 'payout.paid', 'payout.canceled', 'price.created', 'product.created',
139
+ 'setup_intent.succeeded', 'setup_intent.created',
140
+ // The twin's checkout completion transition records the op AS the real event type:
141
+ // real Stripe fires checkout.session.completed (data.object = the completed Session)
142
+ // the moment a session reaches status=complete — the event most billing integrations
143
+ // fulfill from, so it must pass through, not be swallowed.
144
+ 'checkout.session.completed',
145
+ // Issuing writes record the op AS the real event type (issuing_authorization.updated on
146
+ // approve/decline/update, issuing_card.updated on card updates, …) — all real vendor
147
+ // event types (stripe@22.3.0 WebhookEndpoint.EnabledEvent enum).
148
+ 'issuing_authorization.updated', 'issuing_card.updated', 'issuing_cardholder.updated',
149
+ 'issuing_transaction.updated',
150
+ ]);
151
+ if (PASSTHROUGH.has(operation))
152
+ return operation;
153
+ switch (operation) {
154
+ case 'customer.create': return 'customer.created';
155
+ case 'payment_intent.create': return 'payment_intent.created';
156
+ case 'payment_intent.confirm': return 'payment_intent.succeeded';
157
+ case 'payment_method.attach': return 'payment_method.attached';
158
+ case 'payment_method.detach': return 'payment_method.detached';
159
+ case 'setup_intent.confirm': return 'setup_intent.succeeded';
160
+ case 'subscription.create': return 'customer.subscription.created';
161
+ case 'subscription.update': return 'customer.subscription.updated';
162
+ case 'subscription.cancel': return 'customer.subscription.deleted';
163
+ case 'invoice.finalize': return 'invoice.finalized';
164
+ case 'invoice.pay': return 'invoice.paid';
165
+ case 'invoice.void': return 'invoice.voided';
166
+ case 'refund.create': return 'refund.created';
167
+ case 'dispute.create': return 'charge.dispute.created';
168
+ case 'payout.create': return 'payout.created';
169
+ case 'product.create': return 'product.created';
170
+ case 'price.create': return 'price.created';
171
+ // Issuing: real Stripe fires issuing_authorization.created/.updated,
172
+ // issuing_card.created/.updated, issuing_cardholder.created/.updated and
173
+ // issuing_transaction.created (all present in the vendor SDK's
174
+ // WebhookEndpoint.EnabledEvent enum, stripe@22.3.0 WebhookEndpoints.d.ts).
175
+ // issuing_authorization.request is deliberately NOT here: it is not a
176
+ // state-change event — the twin delivers it SYNCHRONOUSLY from the
177
+ // present-authorization flow (requestAuthorizationDecision below).
178
+ case 'issuing_authorization.create': return 'issuing_authorization.created';
179
+ case 'issuing_card.create': return 'issuing_card.created';
180
+ case 'issuing_cardholder.create': return 'issuing_cardholder.created';
181
+ case 'issuing_transaction.create': return 'issuing_transaction.created';
182
+ default: return vendorEventType(operation);
183
+ }
184
+ }
185
+ /** Any other write records the event Stripe sends for it, when Stripe sends one: the operation itself, or its
186
+ * resource's create/update/delete as `<resource>.created|updated|deleted` (the types Stripe's spec enumerates). */
187
+ function vendorEventType(operation) {
188
+ if (VENDOR_EVENT_TYPES.has(operation))
189
+ return operation;
190
+ const verb = /^(.+)\.(create|update|delete)$/.exec(operation);
191
+ return verb && VENDOR_EVENT_TYPES.has(`${verb[1]}.${verb[2]}d`) ? `${verb[1]}.${verb[2]}d` : null;
192
+ }
193
+ /** An event's id, DERIVED from the event itself — the operation, the resource it carries and the instant —
194
+ * never a process counter (protocol 2: two identical worlds deliver identically, and a replay of the same
195
+ * write raises the same event id rather than one that drifts with process uptime). */
196
+ function eventId(operation, resource, occurredAt, account) {
197
+ let h = 0x811c9dc5;
198
+ const seed = `${operation}|${String(resource.id ?? '')}|${occurredAt}|${account ?? ''}`;
199
+ for (let i = 0; i < seed.length; i += 1) {
200
+ h ^= seed.charCodeAt(i);
201
+ h = Math.imul(h, 0x01000193) >>> 0;
202
+ }
203
+ return `evt_twin_${h.toString(16).padStart(8, '0')}`;
204
+ }
205
+ // Live HTTP delivery signs exactly like real Stripe: HMAC-SHA256 over
206
+ // `${timestamp}.${payload}` keyed by the ENDPOINT'S OWN signing secret (the
207
+ // `whsec_twin_*` minted by POST /v1/webhook_endpoints and registered alongside the
208
+ // URL), sent as `Stripe-Signature: t=...,v1=...`. The signed bytes are the exact
209
+ // request body, so `stripe.webhooks.constructEvent(rawBody, header, secret)` in the
210
+ // consumer's real SDK verifies twin deliveries unchanged (a hardcoded placeholder
211
+ // header made every verifying consumer 400 — the delivery was unverifiable).
212
+ // Delivery is AT-LEAST-ONCE, like the vendor: real Stripe re-attempts a webhook whose
213
+ // delivery fails at the transport layer rather than dropping it, so a consumer that missed
214
+ // one event because a socket blipped is a consumer real Stripe would have reached. A single
215
+ // swallowed `fetch` rejection here is indistinguishable, downstream, from the consumer
216
+ // having a bug — it cost a week of "the settlement webhook is racy" before anyone could see
217
+ // that the event had simply never left the twin. Two cheap re-attempts cover a transient
218
+ // local failure (ECONNRESET / socket exhaustion under a boot storm); a genuinely unreachable
219
+ // endpoint refuses instantly, so the retries cost microseconds. A NON-2xx response is the
220
+ // consumer answering, not a transport failure — it is not retried here.
221
+ // A delivery that still fails after the re-attempts is DROPPED, and says so: silent loss is
222
+ // the one outcome a twin must never have, because it makes every consumer look flaky.
223
+ const DELIVERY_ATTEMPTS = 3;
224
+ const httpDelivery = async (url, event, endpointSecret) => {
225
+ // the event as its API version renders it (stripe-version.ts)
226
+ const payload = JSON.stringify(render(event, event.api_version));
227
+ 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
+ if (refusal !== null)
231
+ console.error(`[twin:stripe] webhook delivery DROPPED — ${event.type} ${event.id} -> ${url}: ${refusal}`);
232
+ return refusal !== null ? undefined : postEvent(url, event, payload, secret);
233
+ };
234
+ /** The HTTP POST of a signed event to an endpoint the World lets it reach, re-attempted on a transport failure. */
235
+ async function postEvent(url, event, payload, secret) {
236
+ let last = '';
237
+ for (let attempt = 1; attempt <= DELIVERY_ATTEMPTS; attempt += 1) {
238
+ try {
239
+ const header = generateTestHeaderString({ payload, secret });
240
+ await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', 'stripe-signature': header }, body: payload });
241
+ return;
242
+ }
243
+ catch (error) {
244
+ last = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
245
+ if (attempt < DELIVERY_ATTEMPTS)
246
+ await new Promise((resolve) => setTimeout(resolve, 25 * attempt));
247
+ }
248
+ }
249
+ console.error(`[twin:stripe] webhook delivery DROPPED after ${DELIVERY_ATTEMPTS} attempts — ${event.type} ${event.id} -> ${url}: ${last}`);
250
+ }
251
+ // Default deliverer override (a TEST SEAM). The twin's write path emits events without
252
+ // threading a per-call deliverer, so to drive event delivery fully OFFLINE/deterministically
253
+ // — no real sockets — a test installs a fake sink here. When unset, real HTTP POST is used
254
+ // (prod behavior). This keeps D5 honest: verify() exercises the twin's own emission, in-process.
255
+ // a slot, not module truth (protocol 2): WHERE a delivery goes, set once by the host that mounts the twin
256
+ const defaultDelivery = { current: null };
257
+ /** Install a default deliverer used by the write path when no per-call `deliver` is passed. */
258
+ export function setStripeEventDelivery(deliver) {
259
+ defaultDelivery.current = deliver;
260
+ }
261
+ // ── Real-time authorization (issuing_authorization.request) — the SYNCHRONOUS leg ────────
262
+ // Real Stripe delivers issuing_authorization.request to the ONE enrolled endpoint while the
263
+ // card network holds the authorization open, and the endpoint must answer within Stripe's
264
+ // documented 2-second window ("This request should be made within the timeout window of the
265
+ // real-time authorization flow" — stripe@22.3.0 Issuing/Authorizations.d.ts; the current
266
+ // method is to "respond directly to the webhook request to approve an authorization"). The
267
+ // endpoint's HTTP response body carries the decision: a JSON object with a boolean
268
+ // `approved` (and, for an amount-controllable request, an optional integer `amount` to hold
269
+ // a different amount). Outcomes map onto the vendor's request_history.reason values
270
+ // (stripe@22.3.0 RequestHistory.Reason):
271
+ // webhook_approved / webhook_declined — the endpoint answered in time;
272
+ // webhook_timeout — no response arrived inside the window (incl. unreachable endpoint);
273
+ // webhook_error — "the direct webhook response is invalid (for example, parsing errors
274
+ // or missing parameters)" (verbatim from the SDK's reason_message doc),
275
+ // i.e. non-2xx status, non-JSON body, or a missing `approved`.
276
+ // On timeout/error real Stripe DECLINES the authorization (fail-closed; the card's own
277
+ // spending_controls remain the backstop) — the account-level "approve on timeout" opt-in is
278
+ // not API-configurable and is not modeled.
279
+ export const STRIPE_REALTIME_AUTH_TIMEOUT_MS = 2_000;
280
+ // The live synchronous deliverer: POST the signed event and WAIT for the response within
281
+ // the 2s window. Signs with the enrolled endpoint's own whsec (passed by the caller, read
282
+ // from the endpoint's state row) — never a placeholder.
283
+ async function httpAuthRequestDelivery(url, event, secret) {
284
+ // the event as its API version renders it (stripe-version.ts)
285
+ const payload = JSON.stringify(render(event, event.api_version));
286
+ 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);
289
+ return refusal !== null ? Promise.reject(new Error(refusal)) : postAuthRequest(url, payload, header);
290
+ }
291
+ /** The HTTP POST of a real-time authorization request, cut off at the window, and the endpoint's answer. */
292
+ async function postAuthRequest(url, payload, header) {
293
+ const res = await fetch(url, {
294
+ method: 'POST',
295
+ headers: { 'content-type': 'application/json', 'stripe-signature': header },
296
+ body: payload,
297
+ signal: AbortSignal.timeout(STRIPE_REALTIME_AUTH_TIMEOUT_MS),
298
+ });
299
+ return { status: res.status, body: await res.text() };
300
+ }
301
+ /**
302
+ * Deliver an issuing_authorization.request event to the enrolled endpoint and interpret
303
+ * its synchronous response as the authorization decision. Honors the installed test
304
+ * deliverer (setStripeEventDelivery) so verifies run fully offline: a fake returning a
305
+ * {status, body} response drives the decision; a fake that throws a TimeoutError (the
306
+ * exact error AbortSignal.timeout produces) exercises the timeout fallback.
307
+ */
308
+ export async function requestAuthorizationDecision(url, event, secret) {
309
+ let res;
310
+ try {
311
+ res = defaultDelivery.current ? await defaultDelivery.current(url, event) : await httpAuthRequestDelivery(url, event, secret);
312
+ }
313
+ catch (e) {
314
+ // no answer inside the window: cut off (TimeoutError, AbortError), or never reached (refused, DNS, reset), alike
315
+ return (e instanceof Error && (e.name === 'TimeoutError' || e.name === 'AbortError')) ? TIMED_OUT : UNREACHED;
316
+ }
317
+ return readDecision(res);
318
+ }
319
+ const TIMED_OUT = { kind: 'timeout', message: 'Webhook endpoint did not respond within the real-time authorization window.' };
320
+ const UNREACHED = { kind: 'timeout', message: 'Webhook endpoint was unreachable within the real-time authorization window.' };
321
+ /** The endpoint's synchronous answer read as the decision: `approved` (and an optional `amount`), or an error for an
322
+ * answer Stripe cannot read. */
323
+ function readDecision(res) {
324
+ if (!res || typeof res !== 'object') {
325
+ return { kind: 'error', message: 'Webhook endpoint returned no response body.' };
326
+ }
327
+ if (res.status < 200 || res.status >= 300) {
328
+ return { kind: 'error', message: `Webhook endpoint responded with HTTP status ${res.status}.` };
329
+ }
330
+ let parsed;
331
+ try {
332
+ parsed = JSON.parse(res.body);
333
+ }
334
+ catch {
335
+ return { kind: 'error', message: 'Webhook response body was not valid JSON.' };
336
+ }
337
+ const approved = parsed?.approved;
338
+ if (typeof approved !== 'boolean') {
339
+ return { kind: 'error', message: 'Webhook response is missing the required `approved` parameter.' };
340
+ }
341
+ if (!approved)
342
+ return { kind: 'declined' };
343
+ const amount = parsed.amount;
344
+ return { kind: 'approved', ...(typeof amount === 'number' && Number.isInteger(amount) && amount > 0 ? { amount } : {}) };
345
+ }
346
+ /**
347
+ * Emit a Stripe event for a twin write to registered endpoints. `resource` is the
348
+ * Stripe object the event is about. `occurredAt` is caller-supplied (deterministic).
349
+ * Returns the delivered events (for assertions); no-op when nothing is registered
350
+ * or the operation has no mapped event type.
351
+ *
352
+ * Fan-out filters by each endpoint's registered enabled_events (stripeEventMatches — '*',
353
+ * 'family.*', exact), like the vendor: a consumer enrolled for one event type receives
354
+ * only that type. A URL registered without a list (test seam) receives everything.
355
+ */
356
+ export async function emitStripeEvent(operation, resource, opts = { occurredAt: '1970-01-01T00:00:00.000Z' }) {
357
+ const type = eventTypeFor(operation);
358
+ // the World's own endpoints (its tree is the record), then any URL a host registered directly
359
+ const targets = [...(opts.endpoints ?? [])];
360
+ for (const url of registry)
361
+ if (!targets.some((t) => t.url === url))
362
+ targets.push({ url, ...(secrets.has(url) ? { secret: secrets.get(url) } : {}), ...(subscriptions.has(url) ? { enabledEvents: subscriptions.get(url) } : {}) });
363
+ if (!type || targets.length === 0)
364
+ return [];
365
+ const deliver = opts.deliver ?? defaultDelivery.current ?? httpDelivery;
366
+ const event = {
367
+ id: opts.id ?? eventId(operation, resource, opts.occurredAt, opts.account),
368
+ object: 'event',
369
+ type,
370
+ created: Math.floor(Date.parse(opts.occurredAt) / 1000) || 0,
371
+ livemode: false,
372
+ data: { object: resource },
373
+ // "Each event for a connected account contains a top-level `account` property that identifies the connected account"
374
+ ...(opts.account ? { account: opts.account } : {}),
375
+ };
376
+ const out = [];
377
+ for (const target of targets) {
378
+ if (target.enabledEvents && !stripeEventMatches(target.enabledEvents, type))
379
+ continue;
380
+ // an endpoint's scope (docs.stripe.com/connect/webhooks): `connect: true` receives the Connected accounts scope,
381
+ // `connect: false` Your account; a URL registered without a scope (a host's seam) receives both
382
+ if (target.connect !== undefined && target.connect !== Boolean(opts.account))
383
+ continue;
384
+ await deliver(target.url, event, target.secret);
385
+ out.push(event);
386
+ }
387
+ return out;
388
+ }
@@ -0,0 +1,4 @@
1
+ /** The release train a script path names: `/v3`, `/v3/` or `/v3/stripe.js` → 3, `/<train>/stripe.js` → the train. */
2
+ export declare function stripeJsTrain(pathname: string): 3 | string | undefined;
3
+ /** js.stripe.com's script for a GET of its path, or undefined for any other request. */
4
+ export declare function stripeJs(request: Request): Response | undefined;
@@ -0,0 +1,70 @@
1
+ // STRIPE.JS — the client library an application's page loads from js.stripe.com (docs/contributing/architecture.md,
2
+ // "Screens": a widget the vendor ships into the application's own page "is not a screen: it is a client library,
3
+ // twinned from its published types as an SDK surface is"). Written from @stripe/stripe-js's published loader and types
4
+ // (7.x: dist/index.mjs, dist/stripe-js/stripe.d.ts, dist/stripe-js/hosted-checkout.d.ts), nothing of Stripe's script
5
+ // copied:
6
+ // - the loader injects https://js.stripe.com/<release train>/stripe.js (or /v3/), and accepts a script already on the
7
+ // page whose src matches `/^https:\/\/js\.stripe\.com\/(v3|[a-z]+)\/stripe\.js(\?.*)?$/` (so /v3/stripe.js too);
8
+ // it resolves `window.Stripe`, reads `Stripe.version` (3 is the v3 URL, otherwise the train's name) and calls the
9
+ // instance's `_registerWrapper` when it has one;
10
+ // - `stripe.redirectToCheckout({ sessionId })` sends the customer to that Checkout Session's page (hosted-checkout.d.ts,
11
+ // RedirectToCheckoutServerOptions), which this twin serves at checkout.stripe.com/c/pay/{id} (screens/checkout.tsx),
12
+ // and its promise settles only on failure (`Promise<never | {error}>`);
13
+ // - `stripe.collectFinancialConnectionsAccounts({ clientSecret })` loads the Financial Connections authentication
14
+ // flow for the session whose client_secret it is (stripe.d.ts; docs.stripe.com/js/financial_connections/
15
+ // collect_financial_connections_accounts: "it will load the Authentication Flow, an on-page modal UI"). Where the
16
+ // twin decides: it navigates to the flow's page, js.stripe.com/v3/financial-connections/{client_secret}
17
+ // (screens/financial-connections.tsx), instead of a modal, so its promise does not settle; the flow returns the
18
+ // holder to the session's return_url, where the application reads the session's accounts.
19
+ // Demand: Dub's upgrade button (apps/web/ui/workspaces/upgrade-plan-button.tsx) creates a session on its server and
20
+ // calls redirectToCheckout. Every other member of the library throws, naming itself, when called: an application
21
+ // reaching one finds the gap instead of a silent no-op.
22
+ /** The release train a script path names: `/v3`, `/v3/` or `/v3/stripe.js` → 3, `/<train>/stripe.js` → the train. */
23
+ export function stripeJsTrain(pathname) {
24
+ if (/^\/v3(\/|\/stripe\.js)?$/.test(pathname))
25
+ return 3;
26
+ return /^\/([a-z]+)\/stripe\.js$/.exec(pathname)?.[1];
27
+ }
28
+ function script(version) {
29
+ return `(function () {
30
+ var version = ${JSON.stringify(version)};
31
+ function gap(name) {
32
+ return function () { throw new Error("Stripe.js (Volter twin): stripe." + name + " is not modelled by this twin yet"); };
33
+ }
34
+ function Stripe(publishableKey) {
35
+ if (typeof publishableKey !== "string" || !publishableKey) throw new Error("Stripe.js (Volter twin): Stripe() needs a publishable key");
36
+ var known = {
37
+ _registerWrapper: function () {},
38
+ redirectToCheckout: function (options) {
39
+ if (!options || typeof options.sessionId !== "string" || !options.sessionId) return gap("redirectToCheckout without a sessionId")();
40
+ window.location.assign("https://checkout.stripe.com/c/pay/" + encodeURIComponent(options.sessionId));
41
+ return new Promise(function () {});
42
+ },
43
+ collectFinancialConnectionsAccounts: function (options) {
44
+ if (!options || typeof options.clientSecret !== "string" || !options.clientSecret) return gap("collectFinancialConnectionsAccounts without a clientSecret")();
45
+ window.location.assign("https://js.stripe.com/v3/financial-connections/" + encodeURIComponent(options.clientSecret));
46
+ return new Promise(function () {});
47
+ }
48
+ };
49
+ return new Proxy(known, {
50
+ get: function (target, name) {
51
+ if (name in target) return target[name];
52
+ if (typeof name === "symbol" || name === "then") return undefined;
53
+ return gap(String(name));
54
+ }
55
+ });
56
+ }
57
+ Stripe.version = version;
58
+ window.Stripe = Stripe;
59
+ })();
60
+ `;
61
+ }
62
+ /** js.stripe.com's script for a GET of its path, or undefined for any other request. */
63
+ export function stripeJs(request) {
64
+ if (request.method !== 'GET')
65
+ return undefined;
66
+ const train = stripeJsTrain(new URL(request.url).pathname);
67
+ if (train === undefined)
68
+ return undefined;
69
+ return new Response(script(train), { headers: { 'content-type': 'application/javascript; charset=utf-8', 'access-control-allow-origin': '*' } });
70
+ }
@@ -0,0 +1,15 @@
1
+ export * from './stripe-shared.js';
2
+ /** Build the React/TSX dashboard client to browser JS (Bun bundles TSX); cached. */
3
+ export declare function buildStripeMirrorClient(): Promise<string>;
4
+ /** Serve the Stripe Dashboard (React app) with the twin's own fetch adapter mounted beside it. */
5
+ export declare function createStripeMirrorServer(options: {
6
+ root?: string;
7
+ port?: number;
8
+ }): Promise<{
9
+ port: number;
10
+ stop: () => void;
11
+ }>;
12
+ /** The app-shell HTML (pure, for tests). The Dashboard itself is the React client. */
13
+ export declare function stripeMirrorHtml(): string;
14
+ /** The mirror's stylesheet, for a host serving the shell's `assets/styles.css` itself (the hosted mirror mount). */
15
+ export declare function stripeMirrorStyles(): Promise<string>;
@@ -0,0 +1,87 @@
1
+ // The Stripe Dashboard (manifest screen `dashboard`, dashboard.stripe.com/test/{section}): a React app
2
+ // (Bun-bundled) served beside the twin. A PURE FRONTEND (runtime contract R3): this module is the shell, its
3
+ // assets and one listener that mounts the pack's OWN fetch adapter beside them. It imports no handler and no
4
+ // twin internals; the client reads and writes only through Stripe's API (`/v1/...`, client/dashboard-api.ts),
5
+ // so it renders a twin or a real account's test data unchanged, pointed at any origin by configuration.
6
+ import { readFile } from 'node:fs/promises';
7
+ import { bundleClient, fileResponse } from '@volter/world-core';
8
+ import { serveHttp } from '@volter/world-core';
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)
12
+ // ---------------------------------------------------------------------------
13
+ // Pure, dependency-free render/format/resolution helpers.
14
+ //
15
+ // These are intentionally framework-agnostic (plain data in → plain data out) so
16
+ // they can be unit-tested in isolation AND imported by the React/TSX client
17
+ // (Bun tree-shakes the server-only exports below out of the browser bundle).
18
+ // Keep them free of any `@volter/world-core`/`Bun`/twin-adapter usage.
19
+ // ---------------------------------------------------------------------------
20
+ export * from "./stripe-shared.js";
21
+ import { flattenStripeValue, resolveCrossRefs } from "./stripe-shared.js";
22
+ void flattenStripeValue;
23
+ void resolveCrossRefs;
24
+ const APP_SHELL = `<!doctype html>
25
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
26
+ <base href="/"><title>Dashboard – Stripe</title><link rel="stylesheet" href="assets/styles.css"></head>
27
+ <body><div id="root"></div><script type="module" src="assets/app.js"></script></body></html>`;
28
+ let clientBundle = null;
29
+ /** Build the React/TSX dashboard client to browser JS (Bun bundles TSX); cached. */
30
+ export function buildStripeMirrorClient() {
31
+ if (!clientBundle) {
32
+ clientBundle = bundleClient(CLIENT_ENTRY())
33
+ .catch((error) => { clientBundle = null; throw error; });
34
+ }
35
+ return clientBundle;
36
+ }
37
+ /** Serve the Stripe Dashboard (React app) with the twin's own fetch adapter mounted beside it. */
38
+ export async function createStripeMirrorServer(options) {
39
+ const twin = createStripeTwinFetch(options);
40
+ const server = await serveHttp({
41
+ // LOOPBACK-SPECIFIC bind (2026-08-20, the roving ui-verify flake): with the default
42
+ // wildcard hostname, `port: 0` can be handed a port some long-running app already LISTENS
43
+ // on at 127.0.0.1 (SO_REUSEADDR allows the overlapping non-identical bind), and the more
44
+ // specific loopback listener then shadows this server for every 127.0.0.1 fetch — the
45
+ // verify talks to a STRANGER (captured: a desktop app's asset server answering 404s on the
46
+ // mirror's port). Binding 127.0.0.1 makes the kernel allocate a port that is actually free
47
+ // on loopback, so the verify's fetches deterministically reach THIS server.
48
+ hostname: '127.0.0.1',
49
+ port: options.port ?? 0,
50
+ idleTimeout: 60,
51
+ async fetch(request) {
52
+ const url = new URL(request.url);
53
+ if (request.method === 'GET' && url.pathname === '/assets/app.js') {
54
+ try {
55
+ return new Response(await buildStripeMirrorClient(), { headers: { 'content-type': 'text/javascript; charset=utf-8' } });
56
+ }
57
+ catch (error) {
58
+ return new Response(String(error), { status: 500 });
59
+ }
60
+ }
61
+ if (request.method === 'GET' && url.pathname === '/assets/styles.css') {
62
+ return fileResponse(CLIENT_CSS(), { headers: { 'content-type': 'text/css; charset=utf-8' } });
63
+ }
64
+ // the Dashboard's own paths (/test/payments, /test/customers/cus_…) are the app; `/` opens Home
65
+ if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '' || url.pathname === '/test' || url.pathname.startsWith('/test/'))) {
66
+ return new Response(APP_SHELL, { headers: { 'content-type': 'text/html; charset=utf-8' } });
67
+ }
68
+ // everything else → the twin's OWN FETCH ADAPTER (composition, R2): the React client fetches
69
+ // the vendor's real paths (/v1/<collection>) and this is the same closure
70
+ // `createStripeTwinServer` mounts, so there is exactly ONE serving code path — the `/twin`
71
+ // manifest door, the form-encoded body passed through byte-for-byte, the Idempotency-Key /
72
+ // Stripe-Version / Stripe-Account threading, the `request-id` response header, the world
73
+ // clock and an honorable `readOnly` all come from it, and the mirror port cannot drift from
74
+ // the API port.
75
+ return twin(request);
76
+ },
77
+ });
78
+ return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
79
+ }
80
+ /** The app-shell HTML (pure, for tests). The Dashboard itself is the React client. */
81
+ export function stripeMirrorHtml() {
82
+ return APP_SHELL;
83
+ }
84
+ /** The mirror's stylesheet, for a host serving the shell's `assets/styles.css` itself (the hosted mirror mount). */
85
+ export function stripeMirrorStyles() {
86
+ return readFile(CLIENT_CSS(), 'utf8');
87
+ }
@@ -0,0 +1,3 @@
1
+ import { type DerivedCall } from '@volter/world-core';
2
+ /** Stripe's refusal of a request whose top-level parameters the operation does not take, or undefined. */
3
+ export declare function refuseParameters(call: DerivedCall): Promise<Response | undefined>;
@@ -0,0 +1,43 @@
1
+ // STRIPE'S PARAMETER CHECK — every API request's top-level parameters against the operation's own in the vendored
2
+ // spec, before any handler reads them. Stripe refuses a parameter the operation does not take with
3
+ // `400 parameter_unknown` ("Received unknown parameter: …", docs.stripe.com/error-codes#parameter-unknown) and a
4
+ // required one left out with `400 parameter_missing` ("Missing required param: …",
5
+ // docs.stripe.com/error-codes#parameter-missing). Without this check a handler read whatever it was sent, so a
6
+ // parameter Stripe had removed (a promotion code's top-level `coupon`, replaced by `promotion[coupon]` in
7
+ // 2025-09-30.clover) was accepted.
8
+ //
9
+ // Where the evidence stops: the check knows only the served version's parameters (the spec's, surface.version). A
10
+ // request pinning an earlier version with Stripe-Version is not checked, since the pack holds no earlier spec;
11
+ // nested parameters are not checked.
12
+ import { readParams, vendorError } from '@volter/world-core';
13
+ import surface from './generated/surface.gen.json' with { type: 'json' };
14
+ import { manifest } from "./manifest.js";
15
+ import { SERVED_VERSION } from "./stripe-version.js";
16
+ const OPS = new Map(surface.operations.map((o) => [o.id, o]));
17
+ /** Parameters Stripe documents outside its published spec, each with the page that documents it. */
18
+ const DOCUMENTED = {
19
+ // a top-up into the Issuing balance (docs.stripe.com/issuing/funding/balance: "destination_balance=issuing")
20
+ PostTopups: ['destination_balance'],
21
+ };
22
+ /** Stripe's refusal of a request whose top-level parameters the operation does not take, or undefined. */
23
+ export async function refuseParameters(call) {
24
+ const op = OPS.get(call.operation.id);
25
+ if (!op)
26
+ return undefined;
27
+ const pinned = call.request.headers.get('stripe-version');
28
+ if (pinned && pinned < SERVED_VERSION)
29
+ return undefined;
30
+ const method = call.request.method.toUpperCase();
31
+ const known = new Set([...(op.query ?? []), ...(op.body ?? [])].map((p) => p.name).concat(DOCUMENTED[op.id] ?? []));
32
+ const params = await readParams(manifest, call.request.clone(), call.operation);
33
+ for (const name of Object.keys(params)) {
34
+ if (!known.has(name))
35
+ return vendorError(manifest, { status: 400, code: 'parameter_unknown', param: name, message: `Received unknown parameter: ${name}` });
36
+ }
37
+ const required = method === 'GET' || method === 'DELETE' ? (op.query ?? []) : (op.body ?? []);
38
+ for (const p of required) {
39
+ if (p.required && params[p.name] === undefined)
40
+ return vendorError(manifest, { status: 400, code: 'parameter_missing', param: p.name, message: `Missing required param: ${p.name}.` });
41
+ }
42
+ return undefined;
43
+ }
@@ -0,0 +1,9 @@
1
+ import { type StripeExecute } from './stripe-connector.js';
2
+ export declare function performPending(execute: StripeExecute, opts: {
3
+ root?: string;
4
+ occurredAt: string;
5
+ }): Promise<{
6
+ pushed: number;
7
+ confirmed: string[];
8
+ externalIds: Record<string, string>;
9
+ }>;