@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.
- package/README.md +33 -1
- package/dist/src/index.js +6 -4
- package/dist/src/manifest.js +8 -3
- package/dist/src/screens/checkout.js +20 -6
- package/dist/src/screens/connect-oauth.d.ts +27 -0
- package/dist/src/screens/connect-oauth.js +414 -0
- package/dist/src/screens/connect-settings.d.ts +22 -0
- package/dist/src/screens/connect-settings.js +103 -0
- package/dist/src/screens/portal.js +2 -0
- package/dist/src/semantics/after-payment.d.ts +1 -1
- package/dist/src/semantics/after-payment.js +6 -0
- package/dist/src/semantics/charges.js +10 -2
- package/dist/src/semantics/checkout.js +19 -6
- package/dist/src/semantics/connect.js +20 -3
- package/dist/src/semantics/invoices.js +4 -0
- package/dist/src/semantics/issuing.js +7 -2
- package/dist/src/semantics/ledger.d.ts +11 -6
- package/dist/src/semantics/ledger.js +40 -21
- package/dist/src/semantics/payment-methods.js +2 -0
- package/dist/src/semantics/shared.d.ts +5 -1
- package/dist/src/semantics/shared.js +14 -3
- package/dist/src/semantics/test-cards.d.ts +4 -0
- package/dist/src/semantics/test-cards.js +7 -0
- package/dist/src/semantics/transfers.js +1 -1
- package/dist/src/stripe-capabilities.js +829 -186
- package/dist/src/stripe-conformance.d.ts +2 -0
- package/dist/src/stripe-conformance.js +11 -2
- package/dist/src/stripe-emit.js +2 -2
- package/dist/src/stripe-events.js +14 -10
- package/dist/src/stripe-mirror-ui.js +3 -3
- package/dist/src/stripe-server.js +97 -24
- package/dist/src/stripe-shared.d.ts +3 -0
- package/dist/src/stripe-shared.js +3 -0
- package/dist/src/stripe-twin.js +7 -1
- package/dist/src/stripe-version.d.ts +2 -0
- package/dist/src/stripe-version.js +2 -0
- package/dist/test-fixtures/stripe-known-deviations.json +6 -1
- package/dist/test-fixtures/stripe-schemas.json +85 -12
- package/package.json +4 -4
- package/src/index.ts +6 -4
- package/src/manifest.ts +8 -3
- package/src/screens/checkout.tsx +21 -6
- package/src/screens/connect-oauth.tsx +400 -0
- package/src/screens/connect-settings.tsx +121 -0
- package/src/screens/portal.tsx +2 -0
- package/src/semantics/after-payment.ts +6 -1
- package/src/semantics/charges.ts +11 -2
- package/src/semantics/checkout.ts +19 -6
- package/src/semantics/connect.ts +19 -3
- package/src/semantics/invoices.ts +4 -0
- package/src/semantics/issuing.ts +7 -2
- package/src/semantics/ledger.ts +60 -23
- package/src/semantics/payment-methods.ts +2 -0
- package/src/semantics/shared.ts +14 -3
- package/src/semantics/test-cards.ts +7 -0
- package/src/semantics/transfers.ts +1 -1
- package/src/stripe-capabilities.ts +826 -182
- package/src/stripe-conformance.ts +13 -2
- package/src/stripe-emit.ts +2 -2
- package/src/stripe-events.ts +14 -10
- package/src/stripe-mirror-ui.ts +3 -3
- package/src/stripe-server.ts +85 -24
- package/src/stripe-shared.ts +3 -0
- package/src/stripe-twin.ts +6 -1
- package/src/stripe-version.ts +3 -0
- package/test-fixtures/stripe-known-deviations.json +6 -1
- package/test-fixtures/stripe-schemas.json +85 -12
|
@@ -15,6 +15,7 @@ import { twinResources } from '@volter/world-core';
|
|
|
15
15
|
import { specConformance } from '@volter/world-tooling';
|
|
16
16
|
import type { TwinResource } from '@volter/world-core';
|
|
17
17
|
import { OBJECT_NAME } from './stripe-twin.ts';
|
|
18
|
+
import { INCLUDABLE_FIELDS, render } from './stripe-version.ts';
|
|
18
19
|
|
|
19
20
|
type JsonSchema = specConformance.JsonSchema;
|
|
20
21
|
type KnownDeviation = specConformance.KnownDeviation;
|
|
@@ -43,6 +44,8 @@ export type StripeConformanceReport = {
|
|
|
43
44
|
fieldsChecked: number;
|
|
44
45
|
violations: StripeViolation[];
|
|
45
46
|
knownIgnored: number;
|
|
47
|
+
/** fields checked per twin resource type: a type the check skipped (no schema) is absent */
|
|
48
|
+
fieldsByType: Record<string, number>;
|
|
46
49
|
};
|
|
47
50
|
|
|
48
51
|
const isTwinExtra = (k: string): boolean => k.startsWith('_');
|
|
@@ -51,11 +54,17 @@ const fixturePath = (name: string): string => new URL(`../test-fixtures/${name}`
|
|
|
51
54
|
// The emitted REST body == the twin's view(): strip internal fields, add object + id.
|
|
52
55
|
// Mirrors stripe-twin.ts view(): a vendor `type` field collides with the kernel
|
|
53
56
|
// discriminator, so it is stored under `_stripe_type` and restored to `type` on emit.
|
|
57
|
+
// ...and then rendered in the version a caller that pins none is served (stripe-version.ts render): since 24e757f60
|
|
58
|
+
// the twin KEEPS objects in the 2024-06-20 shape its rules were written against and answers them in the served
|
|
59
|
+
// version's (basil onward moved an invoice's subscription and charge, a charge's source, ...), so the stored row is
|
|
60
|
+
// not what any client receives. The schemas are projected from a post-basil spec, so the answer is what is checked.
|
|
54
61
|
function emitted(r: TwinResource): Record<string, unknown> {
|
|
55
62
|
const { type, updatedAt, _stripe_type, ...rest } = r as TwinResource & { _stripe_type?: unknown };
|
|
56
63
|
const out: Record<string, unknown> = { object: OBJECT_NAME[type] ?? type, ...rest, id: r.id };
|
|
57
64
|
if (_stripe_type !== undefined) out.type = _stripe_type;
|
|
58
|
-
|
|
65
|
+
// expanded: an includable field (a session's line_items, a charge's refunds) is answered only when a request asks, and
|
|
66
|
+
// the check reads the answer that carries it so its shape is still checked
|
|
67
|
+
return render(out, undefined, INCLUDABLE_FIELDS) as Record<string, unknown>;
|
|
59
68
|
}
|
|
60
69
|
|
|
61
70
|
/** Load the vendored per-object Stripe JSON Schemas (type+required+enum from OpenAPI). */
|
|
@@ -79,6 +88,7 @@ export function checkStripeConformance(schemas: StripeSchemas, opts: { root?: st
|
|
|
79
88
|
const violations: StripeViolation[] = [];
|
|
80
89
|
let fieldsChecked = 0;
|
|
81
90
|
let knownIgnored = 0;
|
|
91
|
+
const fieldsByType: Record<string, number> = {};
|
|
82
92
|
for (const r of resources) {
|
|
83
93
|
const objectName = TYPE_TO_OBJECT[r.type];
|
|
84
94
|
if (!objectName) continue;
|
|
@@ -86,10 +96,11 @@ export function checkStripeConformance(schemas: StripeSchemas, opts: { root?: st
|
|
|
86
96
|
if (!schema) continue;
|
|
87
97
|
const rep = specConformance.checkSpecConformance(emitted(r), schema, { exemptKey: isTwinExtra, known: opts.known ?? [] });
|
|
88
98
|
fieldsChecked += rep.fieldsChecked;
|
|
99
|
+
fieldsByType[r.type] = (fieldsByType[r.type] ?? 0) + rep.fieldsChecked;
|
|
89
100
|
knownIgnored += rep.knownIgnored;
|
|
90
101
|
for (const v of rep.violations) violations.push({ ...v, object: objectName, id: r.id });
|
|
91
102
|
}
|
|
92
|
-
return { ok: violations.length === 0, resourcesChecked: resources.length, fieldsChecked, violations, knownIgnored };
|
|
103
|
+
return { ok: violations.length === 0, resourcesChecked: resources.length, fieldsChecked, violations, knownIgnored, fieldsByType };
|
|
93
104
|
}
|
|
94
105
|
|
|
95
106
|
export type StripeCoverageReport = specConformance.SpecCoverageReport & { object: string };
|
package/src/stripe-emit.ts
CHANGED
|
@@ -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, type EmitEndpoint, type EmittableEvent, type SynthesizedDelivery, type TwinEmitter } from '@volter/world-core';
|
|
14
|
+
import { projectResources, worldNow, type EmitEndpoint, type EmittableEvent, type SynthesizedDelivery, type TwinEmitter } from '@volter/world-core';
|
|
15
15
|
import { OBJECT_NAME, PLATFORM_ACCOUNT_ID, TWIN_API_VERSION, view } from './stripe-twin.ts';
|
|
16
16
|
import { STRIPE_WEBHOOK_FALLBACK_SECRET, generateTestHeaderString } from './stripe-events.ts';
|
|
17
17
|
import { render } from './stripe-version.ts';
|
|
@@ -127,7 +127,7 @@ export const stripeEmitter: TwinEmitter = {
|
|
|
127
127
|
const endpointRow = liveEndpointRows(root).find((w) => String(w.id) === endpoint.id || String(w.url) === endpoint.url);
|
|
128
128
|
const secret = typeof endpointRow?.secret === 'string' && endpointRow.secret ? endpointRow.secret : STRIPE_WEBHOOK_FALLBACK_SECRET;
|
|
129
129
|
|
|
130
|
-
const created = occurredAt ? Math.floor(Date.parse(occurredAt) / 1000) : Math.floor(Date.
|
|
130
|
+
const created = occurredAt ? Math.floor(Date.parse(occurredAt) / 1000) : Math.floor(Date.parse(worldNow()) / 1000);
|
|
131
131
|
// a connected account's object is its event's, as the write path scopes it (stripe-twin.ts afterStripeWrite): the
|
|
132
132
|
// account itself, or a row kept on its books (`_account`). "Each event for a connected account contains a top-level
|
|
133
133
|
// `account` property that identifies the connected account" (docs.stripe.com/connect/webhooks).
|
package/src/stripe-events.ts
CHANGED
|
@@ -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.ts';
|
|
@@ -51,7 +52,8 @@ export function computeStripeSignature(payload: string, secret: string, timestam
|
|
|
51
52
|
|
|
52
53
|
/** Build a `Stripe-Signature` header value for `payload` (mirrors generateTestHeaderString). */
|
|
53
54
|
export function generateTestHeaderString(opts: { payload: string; secret: string; timestamp?: number; scheme?: string }): string {
|
|
54
|
-
|
|
55
|
+
// signed at the World's time, as its application reads the time (a moved World clock; the app keeps it too)
|
|
56
|
+
const timestamp = opts.timestamp ?? Math.floor(Date.parse(worldNow()) / 1000);
|
|
55
57
|
const scheme = opts.scheme ?? 'v1';
|
|
56
58
|
const signature = computeStripeSignature(opts.payload, opts.secret, timestamp);
|
|
57
59
|
return `t=${timestamp},${scheme}=${signature}`;
|
|
@@ -101,7 +103,7 @@ export function constructEvent(payload: string, header: string, secret: string,
|
|
|
101
103
|
throw new StripeSignatureVerificationError('No signatures found matching the expected signature for payload.');
|
|
102
104
|
}
|
|
103
105
|
if (opts.tolerance !== undefined) {
|
|
104
|
-
const now = opts.now ?? Math.floor(Date.
|
|
106
|
+
const now = opts.now ?? Math.floor(Date.parse(worldNow()) / 1000);
|
|
105
107
|
if (now - timestamp > opts.tolerance) {
|
|
106
108
|
throw new StripeSignatureVerificationError('Timestamp outside the tolerance zone');
|
|
107
109
|
}
|
|
@@ -259,8 +261,9 @@ const httpDelivery: StripeEventDelivery = async (url, event, endpointSecret) =>
|
|
|
259
261
|
// the event as its API version renders it (stripe-version.ts)
|
|
260
262
|
const payload = JSON.stringify(render(event, (event as { api_version?: string }).api_version));
|
|
261
263
|
const secret = endpointSecret ?? secrets.get(url) ?? STRIPE_WEBHOOK_FALLBACK_SECRET;
|
|
262
|
-
// the World's egress rule: an endpoint it refuses is not delivered to, and says so
|
|
263
|
-
|
|
264
|
+
// the World's egress rule: an endpoint it refuses is not delivered to, and says so; the application's own hostnames
|
|
265
|
+
// reach it inside the World
|
|
266
|
+
const refusal = appDestination(url) ? null : worldEgressRefusal(url);
|
|
264
267
|
if (refusal !== null) console.error(`[twin:stripe] webhook delivery DROPPED — ${event.type} ${event.id} -> ${url}: ${refusal}`);
|
|
265
268
|
return refusal !== null ? undefined : postEvent(url, event, payload, secret);
|
|
266
269
|
};
|
|
@@ -271,7 +274,7 @@ async function postEvent(url: string, event: StripeEvent, payload: string, secre
|
|
|
271
274
|
for (let attempt = 1; attempt <= DELIVERY_ATTEMPTS; attempt += 1) {
|
|
272
275
|
try {
|
|
273
276
|
const header = generateTestHeaderString({ payload, secret });
|
|
274
|
-
await
|
|
277
|
+
await appFetch(url, { method: 'POST', headers: { 'content-type': 'application/json', 'stripe-signature': header, ...deliveryTraceHeaders() }, body: payload });
|
|
275
278
|
return;
|
|
276
279
|
} catch (error) {
|
|
277
280
|
last = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
@@ -325,16 +328,17 @@ async function httpAuthRequestDelivery(url: string, event: StripeEvent, secret:
|
|
|
325
328
|
// the event as its API version renders it (stripe-version.ts)
|
|
326
329
|
const payload = JSON.stringify(render(event, (event as { api_version?: string }).api_version));
|
|
327
330
|
const header = generateTestHeaderString({ payload, secret });
|
|
328
|
-
// the World's egress rule: a refused endpoint is an unreachable one (the caller's timeout fallback)
|
|
329
|
-
|
|
331
|
+
// the World's egress rule: a refused endpoint is an unreachable one (the caller's timeout fallback); the
|
|
332
|
+
// application's own hostnames reach it inside the World
|
|
333
|
+
const refusal = appDestination(url) ? null : worldEgressRefusal(url);
|
|
330
334
|
return refusal !== null ? Promise.reject(new Error(refusal)) : postAuthRequest(url, payload, header);
|
|
331
335
|
}
|
|
332
336
|
|
|
333
337
|
/** The HTTP POST of a real-time authorization request, cut off at the window, and the endpoint's answer. */
|
|
334
338
|
async function postAuthRequest(url: string, payload: string, header: string): Promise<StripeWebhookEndpointResponse> {
|
|
335
|
-
const res = await
|
|
339
|
+
const res = await appFetch(url, {
|
|
336
340
|
method: 'POST',
|
|
337
|
-
headers: { 'content-type': 'application/json', 'stripe-signature': header },
|
|
341
|
+
headers: { 'content-type': 'application/json', 'stripe-signature': header, ...deliveryTraceHeaders() },
|
|
338
342
|
body: payload,
|
|
339
343
|
signal: AbortSignal.timeout(STRIPE_REALTIME_AUTH_TIMEOUT_MS),
|
|
340
344
|
});
|
package/src/stripe-mirror-ui.ts
CHANGED
|
@@ -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)
|
|
12
|
-
const CLIENT_CSS = () => new URL('../client/stripe-mirror.css', import.meta.url)
|
|
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.
|
package/src/stripe-server.ts
CHANGED
|
@@ -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';
|
|
@@ -15,6 +15,8 @@ import { stripeIdentityFlow } from './screens/identity.tsx';
|
|
|
15
15
|
import { stripeOnboardingFlow } from './screens/onboarding.tsx';
|
|
16
16
|
import { stripePortalFlow } from './screens/portal.tsx';
|
|
17
17
|
import { stripePublicDetailsFlow } from './screens/public-details.tsx';
|
|
18
|
+
import { connectionRevoked, oauthKeyAccount, redactKey, stripeConnectOAuthFlow } from './screens/connect-oauth.tsx';
|
|
19
|
+
import { stripeConnectSettingsFlow } from './screens/connect-settings.tsx';
|
|
18
20
|
import surface from './generated/surface.gen.json' with { type: 'json' };
|
|
19
21
|
import { manifest } from './manifest.ts';
|
|
20
22
|
import { appsSecrets } from './semantics/apps-secrets.ts';
|
|
@@ -112,6 +114,34 @@ async function publishableKeyRefused(call: DerivedCall, scope: { root?: string;
|
|
|
112
114
|
return ctx.get(scoped.type, id)?.client_secret === secret ? undefined : wrongClientSecret(scoped, id);
|
|
113
115
|
}
|
|
114
116
|
|
|
117
|
+
// ── an account whose OAuth connection was revoked ──
|
|
118
|
+
// "After revocation, the account can't be accessed by your platform in the Dashboard or through the API"
|
|
119
|
+
// (docs.stripe.com/connect/oauth-reference, deauthorize; so too a connection revoked by a reused code). A call made as it
|
|
120
|
+
// (the Stripe-Account header) or naming it (/v1/accounts/{account}…) is refused with the error Stripe documents for a
|
|
121
|
+
// Stripe-Account the key cannot use: "account_invalid | The account ID provided as a value for the Stripe-Account header
|
|
122
|
+
// is invalid" (docs.stripe.com/error-codes), as 403, "The API key doesn't have permissions to perform the request"
|
|
123
|
+
// (docs.stripe.com/api/errors). Where the documentation stops and the twin decides: the message's wording; an account
|
|
124
|
+
// the platform created itself, or never connected by OAuth, is not affected.
|
|
125
|
+
async function revokedAccountRefused(call: DerivedCall, scope: { root?: string; clock?: () => string }): Promise<Response | undefined> {
|
|
126
|
+
const named = [call.request.headers.get('stripe-account') ?? undefined, call.operation.path.startsWith('/v1/accounts/{account}') ? call.params.account : undefined].filter((a): a is string => !!a);
|
|
127
|
+
if (!named.length) return undefined;
|
|
128
|
+
const ctx = await semanticsContext(manifest, new Request(call.request.url), call.operation, scope); // the rows only: no body read
|
|
129
|
+
const revoked = named.find((a) => connectionRevoked(ctx, a));
|
|
130
|
+
if (!revoked) return undefined;
|
|
131
|
+
const key = requestKey(call.request);
|
|
132
|
+
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.` });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The API key a request carries: Bearer, or Basic's user (`curl -u sk_…:`, docs.stripe.com/api/authentication). */
|
|
136
|
+
function requestKey(request: Request): string {
|
|
137
|
+
const header = request.headers.get('authorization') ?? '';
|
|
138
|
+
const bearer = /^bearer\s+(\S+)/i.exec(header)?.[1];
|
|
139
|
+
if (bearer) return bearer;
|
|
140
|
+
const basic = /^basic\s+(\S+)/i.exec(header)?.[1];
|
|
141
|
+
if (basic) { try { return atob(basic).split(':')[0] ?? ''; } catch { return ''; } }
|
|
142
|
+
return '';
|
|
143
|
+
}
|
|
144
|
+
|
|
115
145
|
/** A client-secret call whose secret is not the object's: answered as though the key could not see it (the twin's
|
|
116
146
|
* decision, above). */
|
|
117
147
|
function wrongClientSecret(scoped: { type: string; param: string }, id: string): Response {
|
|
@@ -151,14 +181,16 @@ export function createStripeTwinFetch(options: StripeTwinOptions): DerivedFetch
|
|
|
151
181
|
core,
|
|
152
182
|
// after the credential and the version, a publishable key is held to the client-side calls, then a request's
|
|
153
183
|
// parameters are checked against the operation's (stripe-params.ts)
|
|
154
|
-
around: (call, next) => guard(call, async () => (await publishableKeyRefused(call, scope)) ?? (await refuseParameters(call)) ?? next()),
|
|
184
|
+
around: (call, next) => guard(call, async () => (await publishableKeyRefused(call, scope)) ?? (await revokedAccountRefused(call, scope)) ?? (await refuseParameters(call)) ?? next()),
|
|
155
185
|
gap: (request) => vendorError(manifest, { status: 404, message: `Unrecognized request URL (${request.method}: ${new URL(request.url).pathname}).` }),
|
|
156
186
|
});
|
|
157
187
|
// the hosted flows sit beside the API: checkout.stripe.com's payment page, billing.stripe.com's customer portal,
|
|
158
|
-
// connect.stripe.com's onboarding, verify.stripe.com's identity
|
|
159
|
-
//
|
|
188
|
+
// connect.stripe.com's onboarding and its OAuth endpoints (screens/connect-oauth.tsx), verify.stripe.com's identity
|
|
189
|
+
// check and the bank-linking flow Stripe.js opens, and the Dashboard's Public details page
|
|
190
|
+
// (dashboard.stripe.com/settings/public, the platform's customer-facing name) and Connect OAuth settings
|
|
191
|
+
// (dashboard.stripe.com/settings/connect/onboarding-options/oauth: the client_id, OAuth on, the redirect URIs);
|
|
160
192
|
// a read-only twin takes no payments and moves nothing
|
|
161
|
-
const flows = [stripeCheckoutFlow(scope), stripePortalFlow(scope), stripeOnboardingFlow(scope), stripeIdentityFlow(scope), stripeFinancialConnectionsFlow(scope), stripePublicDetailsFlow(scope)];
|
|
193
|
+
const flows = [stripeCheckoutFlow(scope), stripePortalFlow(scope), stripeOnboardingFlow(scope), stripeIdentityFlow(scope), stripeFinancialConnectionsFlow(scope), stripePublicDetailsFlow(scope), stripeConnectOAuthFlow(scope), stripeConnectSettingsFlow(scope)];
|
|
162
194
|
// the twin's own doors sit in front of the API: discovery, and what stands in for an act Stripe's API
|
|
163
195
|
// does not have (stripe-twin.ts)
|
|
164
196
|
// GET /twin: what this twin is (not Stripe's; a host reads it)
|
|
@@ -183,25 +215,28 @@ export function createStripeTwinFetch(options: StripeTwinOptions): DerivedFetch
|
|
|
183
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
|
|
184
216
|
// (the API, the hosted pages) reads the account as it stands now
|
|
185
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)
|
|
186
219
|
const catchUp = async (request: Request): Promise<void> => {
|
|
187
220
|
if (readOnly) return;
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
+
});
|
|
205
240
|
};
|
|
206
241
|
// Time's EVENTS, sent on the vendor's clock through a twin-only drain door, as the qstash and vercel twins send
|
|
207
242
|
// theirs (a caller — a runner's drainer — drives each move). POST /_twin/drain catches time up as every request
|
|
@@ -238,8 +273,31 @@ export function createStripeTwinFetch(options: StripeTwinOptions): DerivedFetch
|
|
|
238
273
|
if (library) return library;
|
|
239
274
|
await catchUp(request);
|
|
240
275
|
if (!readOnly) for (const flow of flows) { const page = await flow(request); if (page) return page; }
|
|
276
|
+
const acting = await actingAsOAuthKey(request);
|
|
277
|
+
if (acting instanceof Response) return acting;
|
|
241
278
|
// an update's metadata is merged into what the object holds before the handler or the core serves it (mergeMetadata)
|
|
242
|
-
return derived(readOnly ?
|
|
279
|
+
return derived(readOnly ? acting : await mergeMetadata(acting, (op) => !(op.id in handlerMap) && core.owns(op), scope));
|
|
280
|
+
};
|
|
281
|
+
// A connected account's OAuth keys (screens/connect-oauth.tsx): the token endpoint's access_token is "Use the
|
|
282
|
+
// Stripe-Account header with your platform's secret key (that can make requests on behalf of this Stripe account)" and
|
|
283
|
+
// its stripe_publishable_key the same with the publishable key (docs.stripe.com/connect/oauth-reference): a request made
|
|
284
|
+
// with one acts as that account, as a request with the Stripe-Account header does. A key no live connection holds (its
|
|
285
|
+
// connection revoked, or replaced by a refresh: "Any existing access token with the same scope and mode ... is
|
|
286
|
+
// revoked") is Stripe's unknown key, 401 "Invalid API Key provided" (docs.stripe.com/api/authentication, docs.stripe.com
|
|
287
|
+
// /error-codes); one sent with a Stripe-Account header naming another account cannot act as it (account_invalid).
|
|
288
|
+
// Where the documentation stops and the twin decides: the messages' wording; a read_only key is not held to reads
|
|
289
|
+
// (manifest todo stripe.connect.oauth_scope_enforcement).
|
|
290
|
+
const actingAsOAuthKey = async (request: Request): Promise<Request | Response> => {
|
|
291
|
+
const key = requestKey(request);
|
|
292
|
+
if (!/^(sk|pk)_test_oauth_/.test(key)) return request;
|
|
293
|
+
const held = oauthKeyAccount(await semanticsContext(manifest, new Request(request.url), billingClock, scope), key);
|
|
294
|
+
if (!held) return vendorError(manifest, { status: 401, message: `Invalid API Key provided: ${redactKey(key)}` });
|
|
295
|
+
const named = request.headers.get('stripe-account');
|
|
296
|
+
if (named && named !== held.account) 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.` });
|
|
297
|
+
const headers = new Headers(request.headers);
|
|
298
|
+
headers.set('stripe-account', held.account);
|
|
299
|
+
const bodied = request.method !== 'GET' && request.method !== 'HEAD';
|
|
300
|
+
return new Request(request.url, { method: request.method, headers, ...(bodied ? { body: await request.arrayBuffer() } : {}) });
|
|
243
301
|
};
|
|
244
302
|
// every answer in the shape of the API version the caller is served (stripe-version.ts)
|
|
245
303
|
const rendered = async (request: Request): Promise<Response> => {
|
|
@@ -254,7 +312,10 @@ export function createStripeTwinFetch(options: StripeTwinOptions): DerivedFetch
|
|
|
254
312
|
const answered = creates ? body : withoutEndpointSecret(body);
|
|
255
313
|
return new Response(JSON.stringify(servesCurrent(pinned) ? render(answered, pinned, await expand) : answered), { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
256
314
|
};
|
|
257
|
-
|
|
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 }));
|
|
258
319
|
}
|
|
259
320
|
|
|
260
321
|
export async function createStripeTwinServer(options: StripeTwinOptions): Promise<{ port: number; stop: () => void }> {
|
package/src/stripe-shared.ts
CHANGED
|
@@ -13,6 +13,9 @@ export type StripeRow = Record<string, any>;
|
|
|
13
13
|
* Dashboard to differentiate between accounts", the same object page), and with neither to "Twin Inc.", the name the
|
|
14
14
|
* Dashboard mirror has always given the World's own account. */
|
|
15
15
|
export const PLATFORM_DEFAULT_NAME = 'Twin Inc.';
|
|
16
|
+
/** The bookkeeping type of a connected account's Connect OAuth connection (screens/connect-oauth.tsx): one row per
|
|
17
|
+
* account, `revoked: true` once deauthorized or its code reused. Read by the account list and the event path too. */
|
|
18
|
+
export const OAUTH_CONNECTIONS = '_oauth_connection';
|
|
16
19
|
export function publicBusinessName(account: StripeRow | undefined): string {
|
|
17
20
|
const named = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v : undefined);
|
|
18
21
|
return named(account?.business_profile?.name) ?? named(account?.settings?.dashboard?.display_name) ?? PLATFORM_DEFAULT_NAME;
|
package/src/stripe-twin.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { emitStripeEvent, eventTypeFor, type StripeWebhookTarget } from './strip
|
|
|
15
15
|
import { SERVED_VERSION } from './stripe-version.ts';
|
|
16
16
|
import { planOf } from './semantics/plans.ts';
|
|
17
17
|
import { platformAccountDefault } from './semantics/connect.ts';
|
|
18
|
+
import { OAUTH_CONNECTIONS } from './stripe-shared.ts';
|
|
18
19
|
|
|
19
20
|
const SERVICE = 'stripe';
|
|
20
21
|
|
|
@@ -483,6 +484,10 @@ export async function afterStripeWrite(type: string, op: string, out: Record<str
|
|
|
483
484
|
const id = eventType ? nextStripeEventId(root) : undefined;
|
|
484
485
|
// stored before it is delivered: a consumer that looks its webhook's event up finds it
|
|
485
486
|
if (eventType) await persistStripeEvent(eventType, object, root, occurredAt, apiVersion, account, id);
|
|
487
|
+
// a connected account whose OAuth connection was revoked "can't be accessed by your platform" (docs.stripe.com/connect/
|
|
488
|
+
// oauth-reference): its events (a payout time makes, a renewal) are its own and no longer reach the platform's Connect
|
|
489
|
+
// endpoints, except the account.application.deauthorized that says so (screens/connect-oauth.tsx)
|
|
490
|
+
if (account && eventType !== 'account.application.deauthorized' && rows(OAUTH_CONNECTIONS, root).some((c) => (c as Record<string, unknown>).id === account && (c as Record<string, unknown>).revoked === true)) return;
|
|
486
491
|
await emitStripeEvent(op, object, { occurredAt: occurredAt ?? '1970-01-01T00:00:00.000Z', endpoints: webhookTargets(root), ...(account ? { account } : {}), ...(id ? { id } : {}) });
|
|
487
492
|
}
|
|
488
493
|
|
|
@@ -1250,7 +1255,7 @@ export async function handleStripeTwinRequest(req: StripeRequest): Promise<Strip
|
|
|
1250
1255
|
// a GET carries its parameters in the query; a caller that handed them as a body keeps them
|
|
1251
1256
|
let path = req.path.startsWith('/') ? req.path : `/${req.path}`;
|
|
1252
1257
|
if ((method === 'GET' || method === 'HEAD') && req.body) path += (path.includes('?') ? '&' : '?') + req.body;
|
|
1253
|
-
const response = await twin(new Request(`
|
|
1258
|
+
const response = await twin(new Request(`http://stripe.test${path}`, { method, headers, ...(method !== 'GET' && method !== 'HEAD' && req.body ? { body: req.body } : {}) }));
|
|
1254
1259
|
const text = await response.text();
|
|
1255
1260
|
return { status: response.status, body: text ? JSON.parse(text) : null };
|
|
1256
1261
|
}
|
package/src/stripe-version.ts
CHANGED
|
@@ -228,6 +228,9 @@ const INCLUDABLE: Record<string, string[]> = {
|
|
|
228
228
|
invoice: ['confirmation_secret'],
|
|
229
229
|
};
|
|
230
230
|
|
|
231
|
+
/** Every includable field name, as `expand` paths on a top-level object: what a check of an object's FULL answer expands. */
|
|
232
|
+
export const INCLUDABLE_FIELDS: string[] = [...new Set(Object.values(INCLUDABLE).flat())];
|
|
233
|
+
|
|
231
234
|
/** The `expand[]` paths a request names, in its query or its body (JSON or form). */
|
|
232
235
|
export async function expandOf(request: Request): Promise<string[]> {
|
|
233
236
|
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
|
}
|