@volter/twin-stripe 0.1.2 → 2.0.1
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 +96 -27
- package/client/dashboard-api.ts +286 -0
- package/client/stripe-mirror.css +272 -159
- package/client/stripe-mirror.tsx +1384 -541
- package/dist/client/dashboard-api.d.ts +107 -0
- package/dist/client/dashboard-api.js +238 -0
- package/dist/client/dashboard-api.ts +286 -0
- package/dist/client/stripe-mirror.bundle.js +236 -0
- package/dist/client/stripe-mirror.css +275 -0
- package/dist/client/stripe-mirror.d.ts +134 -0
- package/dist/client/stripe-mirror.js +823 -0
- package/dist/client/stripe-mirror.tsx +1534 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +39 -0
- package/dist/src/generated/events.gen.json +1 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +75 -0
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +1070 -0
- package/dist/src/screens/checkout.d.ts +31 -0
- package/dist/src/screens/checkout.js +255 -0
- 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/consent-skin.d.ts +4 -0
- package/dist/src/screens/consent-skin.js +18 -0
- package/dist/src/screens/financial-connections.d.ts +5 -0
- package/dist/src/screens/financial-connections.js +90 -0
- package/dist/src/screens/identity.d.ts +5 -0
- package/dist/src/screens/identity.js +86 -0
- package/dist/src/screens/industries.d.ts +1 -0
- package/dist/src/screens/industries.js +267 -0
- package/dist/src/screens/onboarding.d.ts +13 -0
- package/dist/src/screens/onboarding.js +225 -0
- package/dist/src/screens/portal.d.ts +5 -0
- package/dist/src/screens/portal.js +216 -0
- package/dist/src/screens/public-details.d.ts +5 -0
- package/dist/src/screens/public-details.js +90 -0
- package/dist/src/semantics/after-payment.d.ts +22 -0
- package/dist/src/semantics/after-payment.js +99 -0
- package/dist/src/semantics/apps-secrets.d.ts +2 -0
- package/dist/src/semantics/apps-secrets.js +54 -0
- package/dist/src/semantics/balance.d.ts +11 -0
- package/dist/src/semantics/balance.js +195 -0
- package/dist/src/semantics/billing.d.ts +2 -0
- package/dist/src/semantics/billing.js +220 -0
- package/dist/src/semantics/charges.d.ts +28 -0
- package/dist/src/semantics/charges.js +209 -0
- package/dist/src/semantics/checkout.d.ts +15 -0
- package/dist/src/semantics/checkout.js +316 -0
- package/dist/src/semantics/connect.d.ts +5 -0
- package/dist/src/semantics/connect.js +493 -0
- package/dist/src/semantics/coupons.d.ts +6 -0
- package/dist/src/semantics/coupons.js +92 -0
- package/dist/src/semantics/credit-notes.d.ts +2 -0
- package/dist/src/semantics/credit-notes.js +172 -0
- package/dist/src/semantics/customers.d.ts +6 -0
- package/dist/src/semantics/customers.js +429 -0
- package/dist/src/semantics/disputes.d.ts +2 -0
- package/dist/src/semantics/disputes.js +51 -0
- package/dist/src/semantics/entitlements.d.ts +2 -0
- package/dist/src/semantics/entitlements.js +95 -0
- package/dist/src/semantics/ephemeral-keys.d.ts +2 -0
- package/dist/src/semantics/ephemeral-keys.js +34 -0
- package/dist/src/semantics/files.d.ts +2 -0
- package/dist/src/semantics/files.js +125 -0
- package/dist/src/semantics/invoices.d.ts +18 -0
- package/dist/src/semantics/invoices.js +545 -0
- package/dist/src/semantics/issuing.d.ts +13 -0
- package/dist/src/semantics/issuing.js +575 -0
- package/dist/src/semantics/ledger.d.ts +59 -0
- package/dist/src/semantics/ledger.js +200 -0
- package/dist/src/semantics/payment-intents.d.ts +18 -0
- package/dist/src/semantics/payment-intents.js +404 -0
- package/dist/src/semantics/payment-links.d.ts +2 -0
- package/dist/src/semantics/payment-links.js +133 -0
- package/dist/src/semantics/payment-methods.d.ts +20 -0
- package/dist/src/semantics/payment-methods.js +140 -0
- package/dist/src/semantics/plans.d.ts +5 -0
- package/dist/src/semantics/plans.js +121 -0
- package/dist/src/semantics/platform.d.ts +9 -0
- package/dist/src/semantics/platform.js +206 -0
- package/dist/src/semantics/products.d.ts +2 -0
- package/dist/src/semantics/products.js +140 -0
- package/dist/src/semantics/radar.d.ts +2 -0
- package/dist/src/semantics/radar.js +83 -0
- package/dist/src/semantics/refunds.d.ts +9 -0
- package/dist/src/semantics/refunds.js +195 -0
- package/dist/src/semantics/renewals.d.ts +47 -0
- package/dist/src/semantics/renewals.js +251 -0
- package/dist/src/semantics/setup-intents.d.ts +2 -0
- package/dist/src/semantics/setup-intents.js +84 -0
- package/dist/src/semantics/shared.d.ts +82 -0
- package/dist/src/semantics/shared.js +203 -0
- package/dist/src/semantics/subscription-schedules.d.ts +2 -0
- package/dist/src/semantics/subscription-schedules.js +119 -0
- package/dist/src/semantics/subscriptions.d.ts +11 -0
- package/dist/src/semantics/subscriptions.js +605 -0
- package/dist/src/semantics/tax.d.ts +2 -0
- package/dist/src/semantics/tax.js +197 -0
- package/dist/src/semantics/terminal.d.ts +5 -0
- package/dist/src/semantics/terminal.js +182 -0
- package/dist/src/semantics/test-cards.d.ts +4 -0
- package/dist/src/semantics/test-cards.js +7 -0
- package/dist/src/semantics/test-clocks.d.ts +6 -0
- package/dist/src/semantics/test-clocks.js +73 -0
- package/dist/src/semantics/tokens.d.ts +4 -0
- package/dist/src/semantics/tokens.js +44 -0
- package/dist/src/semantics/transfers.d.ts +2 -0
- package/dist/src/semantics/transfers.js +154 -0
- package/dist/src/semantics/treasury.d.ts +2 -0
- package/dist/src/semantics/treasury.js +377 -0
- package/dist/src/semantics/webhook-endpoints.d.ts +3 -0
- package/dist/src/semantics/webhook-endpoints.js +85 -0
- package/dist/src/stripe-budget.d.ts +55 -0
- package/dist/src/stripe-budget.js +155 -0
- package/dist/src/stripe-capabilities.d.ts +3 -0
- package/dist/src/stripe-capabilities.js +5695 -0
- package/dist/src/stripe-conformance.d.ts +43 -0
- package/dist/src/stripe-conformance.js +105 -0
- package/dist/src/stripe-connector.d.ts +161 -0
- package/dist/src/stripe-connector.js +414 -0
- package/dist/src/stripe-emit.d.ts +2 -0
- package/dist/src/stripe-emit.js +145 -0
- package/dist/src/stripe-events.d.ts +93 -0
- package/dist/src/stripe-events.js +392 -0
- package/dist/src/stripe-js.d.ts +4 -0
- package/dist/src/stripe-js.js +70 -0
- package/dist/src/stripe-mirror-ui.d.ts +15 -0
- package/dist/src/stripe-mirror-ui.js +87 -0
- package/dist/src/stripe-params.d.ts +3 -0
- package/dist/src/stripe-params.js +43 -0
- package/dist/src/stripe-perform-harness.d.ts +9 -0
- package/dist/src/stripe-perform-harness.js +26 -0
- package/dist/src/stripe-server.d.ts +33 -0
- package/dist/src/stripe-server.js +393 -0
- package/dist/src/stripe-shared.d.ts +109 -0
- package/dist/src/stripe-shared.js +276 -0
- package/dist/src/stripe-twin.d.ts +155 -0
- package/dist/src/stripe-twin.js +1232 -0
- package/dist/src/stripe-ui-conformance.d.ts +5 -0
- package/dist/src/stripe-ui-conformance.js +79 -0
- package/dist/src/stripe-ui-structure.d.ts +3 -0
- package/dist/src/stripe-ui-structure.js +168 -0
- package/dist/src/stripe-version.d.ts +12 -0
- package/dist/src/stripe-version.js +287 -0
- package/dist/test-fixtures/stripe-known-deviations.json +110 -0
- package/dist/test-fixtures/stripe-openapi-operations.SOURCE.md +14 -0
- package/dist/test-fixtures/stripe-openapi-operations.json +4717 -0
- package/dist/test-fixtures/stripe-schemas.SOURCE.md +35 -0
- package/dist/test-fixtures/stripe-schemas.json +3813 -0
- package/package.json +18 -10
- package/src/cli.ts +7 -7
- package/src/generated/events.gen.json +1 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/index.ts +34 -10
- package/src/manifest.ts +1102 -0
- package/src/screens/checkout.tsx +267 -0
- package/src/screens/connect-oauth.tsx +400 -0
- package/src/screens/connect-settings.tsx +121 -0
- package/src/screens/consent-skin.ts +20 -0
- package/src/screens/financial-connections.tsx +101 -0
- package/src/screens/identity.tsx +96 -0
- package/src/screens/industries.ts +267 -0
- package/src/screens/onboarding.tsx +243 -0
- package/src/screens/portal.tsx +220 -0
- package/src/screens/public-details.tsx +105 -0
- package/src/semantics/after-payment.ts +118 -0
- package/src/semantics/apps-secrets.ts +58 -0
- package/src/semantics/balance.ts +209 -0
- package/src/semantics/billing.ts +216 -0
- package/src/semantics/charges.ts +220 -0
- package/src/semantics/checkout.ts +310 -0
- package/src/semantics/connect.ts +487 -0
- package/src/semantics/coupons.ts +97 -0
- package/src/semantics/credit-notes.ts +168 -0
- package/src/semantics/customers.ts +432 -0
- package/src/semantics/disputes.ts +62 -0
- package/src/semantics/entitlements.ts +94 -0
- package/src/semantics/ephemeral-keys.ts +34 -0
- package/src/semantics/files.ts +143 -0
- package/src/semantics/invoices.ts +545 -0
- package/src/semantics/issuing.ts +590 -0
- package/src/semantics/ledger.ts +253 -0
- package/src/semantics/payment-intents.ts +420 -0
- package/src/semantics/payment-links.ts +148 -0
- package/src/semantics/payment-methods.ts +145 -0
- package/src/semantics/plans.ts +131 -0
- package/src/semantics/platform.ts +220 -0
- package/src/semantics/products.ts +154 -0
- package/src/semantics/radar.ts +85 -0
- package/src/semantics/refunds.ts +218 -0
- package/src/semantics/renewals.ts +274 -0
- package/src/semantics/setup-intents.ts +87 -0
- package/src/semantics/shared.ts +226 -0
- package/src/semantics/subscription-schedules.ts +129 -0
- package/src/semantics/subscriptions.ts +610 -0
- package/src/semantics/tax.ts +220 -0
- package/src/semantics/terminal.ts +195 -0
- package/src/semantics/test-cards.ts +7 -0
- package/src/semantics/test-clocks.ts +77 -0
- package/src/semantics/tokens.ts +52 -0
- package/src/semantics/transfers.ts +174 -0
- package/src/semantics/treasury.ts +383 -0
- package/src/semantics/webhook-endpoints.ts +87 -0
- package/src/stripe-budget.ts +4 -4
- package/src/stripe-capabilities.ts +2258 -380
- package/src/stripe-conformance.ts +19 -7
- package/src/stripe-connector.ts +68 -40
- package/src/stripe-emit.ts +15 -8
- package/src/stripe-events.ts +102 -40
- package/src/stripe-js.ts +70 -0
- package/src/stripe-mirror-ui.ts +28 -298
- package/src/stripe-params.ts +44 -0
- package/src/stripe-perform-harness.ts +29 -0
- package/src/stripe-server.ts +318 -38
- package/src/stripe-shared.ts +297 -0
- package/src/stripe-twin.ts +434 -5325
- package/src/stripe-ui-conformance.ts +70 -107
- package/src/stripe-ui-structure.ts +124 -348
- package/src/stripe-version.ts +281 -0
- package/test-fixtures/stripe-known-deviations.json +8 -8
- package/test-fixtures/stripe-openapi-operations.json +1188 -2855
- package/test-fixtures/stripe-schemas.json +85 -12
- package/src/stripe-form.ts +0 -35
|
@@ -10,10 +10,12 @@
|
|
|
10
10
|
// positives. Fields the twin deliberately does not model are DECLARED in
|
|
11
11
|
// stripe-known-deviations.json with reasons; an undeclared missing-required field,
|
|
12
12
|
// a wrong type, an enum violation, or a fabricated field all fail CI.
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import
|
|
13
|
+
import { readFile } from 'node:fs/promises';
|
|
14
|
+
import { twinResources } from '@volter/world-core';
|
|
15
|
+
import { specConformance } from '@volter/world-tooling';
|
|
16
|
+
import type { TwinResource } from '@volter/world-core';
|
|
16
17
|
import { OBJECT_NAME } from './stripe-twin.ts';
|
|
18
|
+
import { INCLUDABLE_FIELDS, render } from './stripe-version.ts';
|
|
17
19
|
|
|
18
20
|
type JsonSchema = specConformance.JsonSchema;
|
|
19
21
|
type KnownDeviation = specConformance.KnownDeviation;
|
|
@@ -42,6 +44,8 @@ export type StripeConformanceReport = {
|
|
|
42
44
|
fieldsChecked: number;
|
|
43
45
|
violations: StripeViolation[];
|
|
44
46
|
knownIgnored: number;
|
|
47
|
+
/** fields checked per twin resource type: a type the check skipped (no schema) is absent */
|
|
48
|
+
fieldsByType: Record<string, number>;
|
|
45
49
|
};
|
|
46
50
|
|
|
47
51
|
const isTwinExtra = (k: string): boolean => k.startsWith('_');
|
|
@@ -50,21 +54,27 @@ const fixturePath = (name: string): string => new URL(`../test-fixtures/${name}`
|
|
|
50
54
|
// The emitted REST body == the twin's view(): strip internal fields, add object + id.
|
|
51
55
|
// Mirrors stripe-twin.ts view(): a vendor `type` field collides with the kernel
|
|
52
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.
|
|
53
61
|
function emitted(r: TwinResource): Record<string, unknown> {
|
|
54
62
|
const { type, updatedAt, _stripe_type, ...rest } = r as TwinResource & { _stripe_type?: unknown };
|
|
55
63
|
const out: Record<string, unknown> = { object: OBJECT_NAME[type] ?? type, ...rest, id: r.id };
|
|
56
64
|
if (_stripe_type !== undefined) out.type = _stripe_type;
|
|
57
|
-
|
|
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>;
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
/** Load the vendored per-object Stripe JSON Schemas (type+required+enum from OpenAPI). */
|
|
61
71
|
export async function loadStripeSchemas(): Promise<StripeSchemas> {
|
|
62
|
-
return JSON.parse(await
|
|
72
|
+
return JSON.parse(await readFile(fixturePath('stripe-schemas.json'), 'utf8')) as StripeSchemas;
|
|
63
73
|
}
|
|
64
74
|
|
|
65
75
|
/** Load the declared scope (Stripe fields the twin does not model, + reasons). */
|
|
66
76
|
export async function loadStripeKnownDeviations(): Promise<KnownDeviation[]> {
|
|
67
|
-
const doc = JSON.parse(await
|
|
77
|
+
const doc = JSON.parse(await readFile(fixturePath('stripe-known-deviations.json'), 'utf8')) as { deviations: KnownDeviation[] };
|
|
68
78
|
return doc.deviations;
|
|
69
79
|
}
|
|
70
80
|
|
|
@@ -78,6 +88,7 @@ export function checkStripeConformance(schemas: StripeSchemas, opts: { root?: st
|
|
|
78
88
|
const violations: StripeViolation[] = [];
|
|
79
89
|
let fieldsChecked = 0;
|
|
80
90
|
let knownIgnored = 0;
|
|
91
|
+
const fieldsByType: Record<string, number> = {};
|
|
81
92
|
for (const r of resources) {
|
|
82
93
|
const objectName = TYPE_TO_OBJECT[r.type];
|
|
83
94
|
if (!objectName) continue;
|
|
@@ -85,10 +96,11 @@ export function checkStripeConformance(schemas: StripeSchemas, opts: { root?: st
|
|
|
85
96
|
if (!schema) continue;
|
|
86
97
|
const rep = specConformance.checkSpecConformance(emitted(r), schema, { exemptKey: isTwinExtra, known: opts.known ?? [] });
|
|
87
98
|
fieldsChecked += rep.fieldsChecked;
|
|
99
|
+
fieldsByType[r.type] = (fieldsByType[r.type] ?? 0) + rep.fieldsChecked;
|
|
88
100
|
knownIgnored += rep.knownIgnored;
|
|
89
101
|
for (const v of rep.violations) violations.push({ ...v, object: objectName, id: r.id });
|
|
90
102
|
}
|
|
91
|
-
return { ok: violations.length === 0, resourcesChecked: resources.length, fieldsChecked, violations, knownIgnored };
|
|
103
|
+
return { ok: violations.length === 0, resourcesChecked: resources.length, fieldsChecked, violations, knownIgnored, fieldsByType };
|
|
92
104
|
}
|
|
93
105
|
|
|
94
106
|
export type StripeCoverageReport = specConformance.SpecCoverageReport & { object: string };
|
package/src/stripe-connector.ts
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
// and Stripe was missing entirely).
|
|
4
4
|
//
|
|
5
5
|
// PULL (real → twin): fetch real Stripe objects, map snake_case → SyncResource[],
|
|
6
|
-
// fold into the
|
|
6
|
+
// fold into the tree through the kernel's observe (its own diff, so a
|
|
7
7
|
// re-pull of identical state appends nothing).
|
|
8
8
|
// PUSH (twin → real): for every PENDING local action, call the real Stripe REST API
|
|
9
|
-
// and
|
|
9
|
+
// and the head confirms on success — which records the confirmed
|
|
10
10
|
// fields as an observed event and suppresses the local
|
|
11
11
|
// projection (the change is counted exactly once).
|
|
12
12
|
//
|
|
@@ -16,15 +16,15 @@
|
|
|
16
16
|
// - live runs pass `liveStripeExecute(apiKey)` (the user's own secret key).
|
|
17
17
|
// Same code path either way, so the connector is fully exercisable offline AND
|
|
18
18
|
// runnable against a real account.
|
|
19
|
-
import { assertBudgetGuardIntact,
|
|
20
|
-
import type { SyncResource, TwinAction } from '@volter/
|
|
21
|
-
import { StripeBudget, stripeCallWeight, type StripeBudgetOptions } from './stripe-budget.ts';
|
|
19
|
+
import { assertBudgetGuardIntact, observeResources } from '@volter/world-core';
|
|
20
|
+
import type { PerformContext, PushOutcome, RemoteExecute, SyncResource, TwinAction } from '@volter/world-core';
|
|
21
|
+
import { StripeBudget, StripeBudgetError, stripeCallWeight, type StripeBudgetOptions } from './stripe-budget.ts';
|
|
22
22
|
|
|
23
23
|
const SERVICE = 'stripe';
|
|
24
24
|
|
|
25
25
|
// Subject types that are twin-internal and are NEVER pushed to real Stripe: the recorded
|
|
26
26
|
// Stripe `event` envelopes (the local Events-API store) and idempotency bookkeeping.
|
|
27
|
-
const INTERNAL_SUBJECT_TYPES = new Set(['event', '_idempotency']);
|
|
27
|
+
export const INTERNAL_SUBJECT_TYPES = new Set(['event', '_idempotency']);
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* The injected real-Stripe boundary. `request` issues ONE Stripe REST call:
|
|
@@ -118,7 +118,14 @@ export function liveStripeExecute(
|
|
|
118
118
|
// Settles the reservation and, on a back-off signal, arms the cooldown. May itself throw (a
|
|
119
119
|
// `Retry-After` beyond the cap is not something to sleep off) — the cooldown is persisted
|
|
120
120
|
// first either way, so the refusal survives the throw.
|
|
121
|
-
|
|
121
|
+
// recordCall may THROW after arming the cooldown (a back-off beyond the cap). On a refused
|
|
122
|
+
// call that louder refusal wins; an answer Stripe ACCEPTED is kept, so a write that landed is
|
|
123
|
+
// never recorded as failed and performed again on retry.
|
|
124
|
+
try {
|
|
125
|
+
budget.recordCall(weight, resHeaders, { status: res.status, reservation });
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (!(error instanceof StripeBudgetError) || !res.ok) throw error;
|
|
128
|
+
}
|
|
122
129
|
return parsed;
|
|
123
130
|
};
|
|
124
131
|
}
|
|
@@ -243,8 +250,15 @@ export async function pullStripeSubscriptions(execute: StripeExecute, opts: { li
|
|
|
243
250
|
|
|
244
251
|
/**
|
|
245
252
|
* Pull from real Stripe (customers + subscriptions) and fold into the twin (mirror
|
|
246
|
-
* seeding).
|
|
253
|
+
* seeding). The fold makes a re-pull of identical state a no-op.
|
|
247
254
|
*/
|
|
255
|
+
/** One fold onto the head: protocol 2's observe, one batch, one instant. */
|
|
256
|
+
function fold(resources: SyncResource[], opts: { root?: string; occurredAt: string }) {
|
|
257
|
+
return observeResources(SERVICE, resources.map((r) => ({ type: r.type, id: r.id, fields: r.fields })), {
|
|
258
|
+
...(opts.root !== undefined ? { root: opts.root } : {}), at: opts.occurredAt, batch: `obs:${SERVICE}:${opts.occurredAt}`,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
248
262
|
export async function syncStripeFromReal(
|
|
249
263
|
execute: StripeExecute,
|
|
250
264
|
opts: { root?: string; occurredAt: string; limit?: number },
|
|
@@ -252,8 +266,8 @@ export async function syncStripeFromReal(
|
|
|
252
266
|
const customers = await pullStripeCustomers(execute, { ...(opts.limit ? { limit: opts.limit } : {}) });
|
|
253
267
|
const subscriptions = await pullStripeSubscriptions(execute, { ...(opts.limit ? { limit: opts.limit } : {}) });
|
|
254
268
|
const resources = [...customers, ...subscriptions];
|
|
255
|
-
const result =
|
|
256
|
-
return { observed: result.observed, deltasAppended: result.
|
|
269
|
+
const result = fold(resources, opts);
|
|
270
|
+
return { observed: result.observed, deltasAppended: result.appended };
|
|
257
271
|
}
|
|
258
272
|
|
|
259
273
|
// ── PUSH ────────────────────────────────────────────────────────────────────
|
|
@@ -332,6 +346,11 @@ export function stripeRequestForAction(action: Pick<TwinAction, 'operation' | 's
|
|
|
332
346
|
const PUSHABLE_VERBS = new Set(['create', 'update', 'cancel', ...SUBACTION_VERBS]);
|
|
333
347
|
|
|
334
348
|
/** Throw if `op` is not a write operation this connector can faithfully push. */
|
|
349
|
+
/** Whether this operation is one the connector can faithfully send to Stripe. */
|
|
350
|
+
export function isPushable(op: string): boolean {
|
|
351
|
+
const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
|
|
352
|
+
return PUSHABLE_VERBS.has(verb);
|
|
353
|
+
}
|
|
335
354
|
function assertPushable(op: string): void {
|
|
336
355
|
const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
|
|
337
356
|
if (!PUSHABLE_VERBS.has(verb)) {
|
|
@@ -356,31 +375,41 @@ export async function pushStripeAction(
|
|
|
356
375
|
return { externalId: typeof id === 'string' && id ? id : action.subject.id };
|
|
357
376
|
}
|
|
358
377
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
*
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
378
|
+
// ── PROTOCOL 2: the pack's half of the real state system ────────────────────────────────────
|
|
379
|
+
/** The pack's executor over the kernel's: the same Stripe REST call, carried by the head. Stripe's API is
|
|
380
|
+
* form-encoded, which is what `stripeExecute` already speaks — this only carries it. */
|
|
381
|
+
export function stripeExecuteOver(execute: RemoteExecute): StripeExecute {
|
|
382
|
+
return async (method, path, params) => {
|
|
383
|
+
const body = params === undefined ? undefined : encodeForm(params);
|
|
384
|
+
const res = await execute({
|
|
385
|
+
method, path,
|
|
386
|
+
headers: { accept: 'application/json', ...(body === undefined ? {} : { 'content-type': 'application/x-www-form-urlencoded' }) },
|
|
387
|
+
...(body === undefined ? {} : { body }),
|
|
388
|
+
});
|
|
389
|
+
if (res.body === '') return {};
|
|
390
|
+
try { return JSON.parse(res.body) as Record<string, unknown>; } catch { return { error: { type: 'api_error', message: res.body.slice(0, 200) } }; }
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
/** The refresh adapter: pull every modeled collection and the webhook endpoints through the executor. */
|
|
394
|
+
export async function syncStripeFromRemote(execute: RemoteExecute, opts: { root?: string; origin?: string; occurredAt?: string } = {}): Promise<{ observed: number; deltasAppended: number }> {
|
|
395
|
+
const at = opts.occurredAt ?? new Date().toISOString();
|
|
396
|
+
const wire = stripeExecuteOver(execute);
|
|
397
|
+
const resources = [
|
|
398
|
+
...await pullStripeAll(wire),
|
|
399
|
+
...await pullStripeWebhookEndpoints(wire),
|
|
400
|
+
];
|
|
401
|
+
const report = fold(resources, { ...(opts.root !== undefined ? { root: opts.root } : {}), occurredAt: at });
|
|
402
|
+
return { observed: report.observed, deltasAppended: report.appended };
|
|
403
|
+
}
|
|
404
|
+
/** The perform adapter: one entry crosses to Stripe, or settles with the reason it never could. The twin's
|
|
405
|
+
* own subject types — the recorded `event` envelopes the write path persists for the Events API, and the
|
|
406
|
+
* idempotency bookkeeping — are local state and must never be sent. */
|
|
407
|
+
export async function performStripeAction(execute: RemoteExecute, action: TwinAction, _ctx: PerformContext): Promise<PushOutcome> {
|
|
408
|
+
const op = action.operation ?? `${action.subject.type}.update`;
|
|
409
|
+
if (INTERNAL_SUBJECT_TYPES.has(action.subject.type) || !isPushable(op)) {
|
|
410
|
+
return { externalId: action.subject.id, data: { performed: false, reason: `${op} is the twin's own record — nothing at Stripe to write` } };
|
|
382
411
|
}
|
|
383
|
-
return {
|
|
412
|
+
return pushStripeAction(stripeExecuteOver(execute), { operation: op, subject: action.subject, fields: action.fields ?? {} });
|
|
384
413
|
}
|
|
385
414
|
|
|
386
415
|
// ── FULL SYNC (all collections + webhooks, bi-directional) ───────────────────
|
|
@@ -421,14 +450,13 @@ export async function pullStripeWebhookEndpoints(execute: StripeExecute, opts: {
|
|
|
421
450
|
export async function fullSyncStripe(
|
|
422
451
|
execute: StripeExecute,
|
|
423
452
|
opts: { root?: string; occurredAt: string; limit?: number },
|
|
424
|
-
): Promise<{
|
|
425
|
-
//
|
|
426
|
-
|
|
427
|
-
// 2. PULL all collections + webhooks back into the twin.
|
|
453
|
+
): Promise<{ observed: number; deltasAppended: number; collections: number }> {
|
|
454
|
+
// protocol 2: this is the PULL half only. The push half is the head's — it performs each entry through
|
|
455
|
+
// `performStripeAction` and confirms it — so a refresh no longer reconciles both directions at once.
|
|
428
456
|
const resources = [
|
|
429
457
|
...await pullStripeAll(execute, { ...(opts.limit ? { limit: opts.limit } : {}) }),
|
|
430
458
|
...await pullStripeWebhookEndpoints(execute, { ...(opts.limit ? { limit: opts.limit } : {}) }),
|
|
431
459
|
];
|
|
432
|
-
const pull =
|
|
433
|
-
return {
|
|
460
|
+
const pull = fold(resources, opts);
|
|
461
|
+
return { observed: pull.observed, deltasAppended: pull.appended, collections: PULL_COLLECTIONS.length + 1 };
|
|
434
462
|
}
|
package/src/stripe-emit.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The Stripe DELIVER verb — `world-stripe emit` (feature-sweep friction: hand-constructing
|
|
2
2
|
// checkout.session.completed / invoice.paid envelopes + HMAC signing to poke an app's webhook
|
|
3
|
-
// handler). This is the pack side of the kernel's emit seam (@volter/
|
|
3
|
+
// handler). This is the pack side of the kernel's emit seam (@volter/world-core emit.ts): the
|
|
4
4
|
// catalog of emittable event types, endpoint registrations read FROM TWIN STATE AT REST
|
|
5
5
|
// (webhook_endpoint rows minted by POST /v1/webhook_endpoints — the CLI runs in its own
|
|
6
6
|
// process, so the in-memory delivery registry in stripe-events.ts does not apply), and
|
|
@@ -11,9 +11,10 @@
|
|
|
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/
|
|
15
|
-
import { OBJECT_NAME, TWIN_API_VERSION, view } from './stripe-twin.ts';
|
|
14
|
+
import { projectResources, worldNow, type EmitEndpoint, type EmittableEvent, type SynthesizedDelivery, type TwinEmitter } from '@volter/world-core';
|
|
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
|
+
import { render } from './stripe-version.ts';
|
|
17
18
|
|
|
18
19
|
const SERVICE = 'stripe';
|
|
19
20
|
|
|
@@ -31,6 +32,7 @@ const EMITTABLE: Record<string, string> = {
|
|
|
31
32
|
'payment_intent.canceled': 'payment_intent',
|
|
32
33
|
// charges + disputes
|
|
33
34
|
'charge.succeeded': 'charge',
|
|
35
|
+
'charge.failed': 'charge',
|
|
34
36
|
'charge.captured': 'charge',
|
|
35
37
|
'charge.refunded': 'charge',
|
|
36
38
|
'charge.dispute.created': 'dispute',
|
|
@@ -86,7 +88,7 @@ function rows(type: string, root?: string): Array<Record<string, unknown>> {
|
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
function liveEndpointRows(root?: string): Array<Record<string, unknown>> {
|
|
89
|
-
return rows('webhook_endpoint', root).filter((w) => w.
|
|
91
|
+
return rows('webhook_endpoint', root).filter((w) => w.deleted !== true);
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
let emitSeq = 0;
|
|
@@ -107,13 +109,13 @@ export const stripeEmitter: TwinEmitter = {
|
|
|
107
109
|
},
|
|
108
110
|
|
|
109
111
|
subjects(subjectType: string, root?: string): string[] {
|
|
110
|
-
return rows(subjectType, root).filter((r) => r.
|
|
112
|
+
return rows(subjectType, root).filter((r) => r.deleted !== true).map((r) => String(r.id));
|
|
111
113
|
},
|
|
112
114
|
|
|
113
115
|
synthesize({ type, subjectId, endpoint, root, occurredAt }): SynthesizedDelivery {
|
|
114
116
|
const subjectType = EMITTABLE[type];
|
|
115
117
|
if (!subjectType) throw new Error(`emit: the stripe pack cannot synthesize "${type}".`);
|
|
116
|
-
const row = rows(subjectType, root).find((r) => String(r.id) === subjectId && r.
|
|
118
|
+
const row = rows(subjectType, root).find((r) => String(r.id) === subjectId && r.deleted !== true);
|
|
117
119
|
if (!row) {
|
|
118
120
|
const have = stripeEmitter.subjects(subjectType, root);
|
|
119
121
|
throw new Error(
|
|
@@ -125,7 +127,11 @@ export const stripeEmitter: TwinEmitter = {
|
|
|
125
127
|
const endpointRow = liveEndpointRows(root).find((w) => String(w.id) === endpoint.id || String(w.url) === endpoint.url);
|
|
126
128
|
const secret = typeof endpointRow?.secret === 'string' && endpointRow.secret ? endpointRow.secret : STRIPE_WEBHOOK_FALLBACK_SECRET;
|
|
127
129
|
|
|
128
|
-
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
|
+
// a connected account's object is its event's, as the write path scopes it (stripe-twin.ts afterStripeWrite): the
|
|
132
|
+
// account itself, or a row kept on its books (`_account`). "Each event for a connected account contains a top-level
|
|
133
|
+
// `account` property that identifies the connected account" (docs.stripe.com/connect/webhooks).
|
|
134
|
+
const account = typeof row._account === 'string' ? row._account : subjectType === 'account' && String(row.id) !== PLATFORM_ACCOUNT_ID ? String(row.id) : undefined;
|
|
129
135
|
// Same envelope persistStripeEvent stores for GET /v1/events; `evt_twin_emit_*` marks it
|
|
130
136
|
// operator-fired (never colliding with the write path's organic `evt_twin_<n>` ids).
|
|
131
137
|
const event: Record<string, unknown> = {
|
|
@@ -138,8 +144,9 @@ export const stripeEmitter: TwinEmitter = {
|
|
|
138
144
|
pending_webhooks: 1,
|
|
139
145
|
request: { id: null, idempotency_key: null },
|
|
140
146
|
type,
|
|
147
|
+
...(account ? { account } : {}),
|
|
141
148
|
};
|
|
142
|
-
const payload = JSON.stringify(event);
|
|
149
|
+
const payload = JSON.stringify(render(event, TWIN_API_VERSION));
|
|
143
150
|
const header = generateTestHeaderString({ payload, secret, ...(occurredAt ? { timestamp: created } : {}) });
|
|
144
151
|
return {
|
|
145
152
|
payload,
|
package/src/stripe-events.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { nodeBuiltin, worldNow } from '@volter/world-core';
|
|
2
|
+
import { appDestination, appFetch } from '@volter/world-core/app-route';
|
|
3
|
+
import { worldEgressRefusal } from '@volter/world-core/network-policy';
|
|
4
|
+
import vendorEvents from './generated/events.gen.json' with { type: 'json' };
|
|
5
|
+
import { render } from './stripe-version.ts';
|
|
1
6
|
// Stripe event/webhook emission (scorecard R17). Real Stripe fires `event`
|
|
2
7
|
// objects (and webhooks) on state changes — payment_intent.succeeded,
|
|
3
8
|
// customer.subscription.created, invoice.paid, … — so an app's webhook handler
|
|
@@ -10,6 +15,8 @@ export type StripeEvent = {
|
|
|
10
15
|
type: string;
|
|
11
16
|
created: number;
|
|
12
17
|
livemode: false;
|
|
18
|
+
/** the connected account a Connect event is from */
|
|
19
|
+
account?: string;
|
|
13
20
|
data: { object: Record<string, unknown> };
|
|
14
21
|
};
|
|
15
22
|
// A deliverer MAY return the endpoint's raw HTTP response. Ordinary (asynchronous) event
|
|
@@ -17,7 +24,9 @@ export type StripeEvent = {
|
|
|
17
24
|
// (issuing_authorization.request) reads it to honor the endpoint's approve/decline. A
|
|
18
25
|
// fire-and-forget deliverer returning void stays valid for the asynchronous path.
|
|
19
26
|
export type StripeWebhookEndpointResponse = { status: number; body: string };
|
|
20
|
-
export type StripeEventDelivery = (url: string, event: StripeEvent) => Promise<void | StripeWebhookEndpointResponse> | void | StripeWebhookEndpointResponse;
|
|
27
|
+
export type StripeEventDelivery = (url: string, event: StripeEvent, secret?: string) => Promise<void | StripeWebhookEndpointResponse> | void | StripeWebhookEndpointResponse;
|
|
28
|
+
/** A webhook endpoint a World holds in its tree: where to POST, what to sign with, what it subscribed to. */
|
|
29
|
+
export type StripeWebhookTarget = { url: string; secret?: string; enabledEvents?: string[]; /** a Connect endpoint (`connect: true`): events from connected accounts */ connect?: boolean };
|
|
21
30
|
|
|
22
31
|
// ── Webhook signature verification (Stripe's scheme) ────────────────────────────
|
|
23
32
|
// Real Stripe signs each webhook delivery with an HMAC-SHA256 over `${timestamp}.${payload}`
|
|
@@ -33,7 +42,7 @@ export type StripeEventDelivery = (url: string, event: StripeEvent) => Promise<v
|
|
|
33
42
|
type NodeCrypto = typeof import('node:crypto');
|
|
34
43
|
function nodeCrypto(): NodeCrypto {
|
|
35
44
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
36
|
-
return
|
|
45
|
+
return nodeBuiltin('node:crypto') as NodeCrypto;
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
/** HMAC-SHA256(secret, `${timestamp}.${payload}`) as lowercase hex — Stripe's v1 signature. */
|
|
@@ -43,7 +52,8 @@ export function computeStripeSignature(payload: string, secret: string, timestam
|
|
|
43
52
|
|
|
44
53
|
/** Build a `Stripe-Signature` header value for `payload` (mirrors generateTestHeaderString). */
|
|
45
54
|
export function generateTestHeaderString(opts: { payload: string; secret: string; timestamp?: number; scheme?: string }): string {
|
|
46
|
-
|
|
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);
|
|
47
57
|
const scheme = opts.scheme ?? 'v1';
|
|
48
58
|
const signature = computeStripeSignature(opts.payload, opts.secret, timestamp);
|
|
49
59
|
return `t=${timestamp},${scheme}=${signature}`;
|
|
@@ -93,7 +103,7 @@ export function constructEvent(payload: string, header: string, secret: string,
|
|
|
93
103
|
throw new StripeSignatureVerificationError('No signatures found matching the expected signature for payload.');
|
|
94
104
|
}
|
|
95
105
|
if (opts.tolerance !== undefined) {
|
|
96
|
-
const now = opts.now ?? Math.floor(Date.
|
|
106
|
+
const now = opts.now ?? Math.floor(Date.parse(worldNow()) / 1000);
|
|
97
107
|
if (now - timestamp > opts.tolerance) {
|
|
98
108
|
throw new StripeSignatureVerificationError('Timestamp outside the tolerance zone');
|
|
99
109
|
}
|
|
@@ -102,15 +112,13 @@ export function constructEvent(payload: string, header: string, secret: string,
|
|
|
102
112
|
}
|
|
103
113
|
|
|
104
114
|
const registry: string[] = [];
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
// the header is still a real, verifiable
|
|
115
|
+
// URLs a HOST registered directly (registerStripeWebhook), with the secret to sign with and the
|
|
116
|
+
// enabled_events to filter on. A World's own endpoints are not here: POST /v1/webhook_endpoints
|
|
117
|
+
// writes the WebhookEndpoint to the tree, and every emit reads the tree (the caller passes them as
|
|
118
|
+
// `endpoints`), so delivery survives a restart and belongs to one World. A URL registered here
|
|
119
|
+
// without a secret is signed with the fallback below, so the header is still a real, verifiable
|
|
120
|
+
// signature; one registered without a list receives every event, equivalent to ['*'].
|
|
110
121
|
const secrets = new Map<string, string>();
|
|
111
|
-
// Subscriptions by URL: the endpoint's enabled_events, carried through registration so the
|
|
112
|
-
// organic fan-out filters like the vendor. A URL registered WITHOUT a list (test seam)
|
|
113
|
-
// receives every event — equivalent to ['*'].
|
|
114
122
|
const subscriptions = new Map<string, string[]>();
|
|
115
123
|
export const STRIPE_WEBHOOK_FALLBACK_SECRET = 'whsec_twin_default';
|
|
116
124
|
export function registerStripeWebhook(url: string, secret?: string, enabledEvents?: string[]): void {
|
|
@@ -147,6 +155,8 @@ export function listStripeWebhooks(): string[] {
|
|
|
147
155
|
// twin write operation → Stripe event type (the ones an app's webhooks care about).
|
|
148
156
|
// Returns null when an operation has no event (e.g. a plain create/retrieve we
|
|
149
157
|
// don't model an event for).
|
|
158
|
+
const VENDOR_EVENT_TYPES = new Set((vendorEvents as { types: string[] }).types);
|
|
159
|
+
|
|
150
160
|
export function eventTypeFor(operation: string): string | null {
|
|
151
161
|
// Several twin write operations ARE already the Stripe event type (we record the
|
|
152
162
|
// op as `<resource>.<event>` so it maps 1:1): payment_intent.amount_capturable_updated,
|
|
@@ -156,6 +166,11 @@ export function eventTypeFor(operation: string): string | null {
|
|
|
156
166
|
'payment_intent.payment_failed', 'payment_intent.created', 'payment_intent.processing',
|
|
157
167
|
'payment_intent.requires_action',
|
|
158
168
|
'charge.succeeded', 'charge.captured', 'charge.refunded', 'charge.dispute.created',
|
|
169
|
+
// A refund fires TWO real Stripe events: `refund.created` carrying the Refund, and
|
|
170
|
+
// `charge.refunded` carrying the CHARGE with its new totals. The twin records the charge's
|
|
171
|
+
// own write as `charge.refunded`, so the event a consumer receives is the charge — which is
|
|
172
|
+
// the whole point of that event and the reason a reconciler subscribes to it.
|
|
173
|
+
'refund.created', 'refund.updated',
|
|
159
174
|
'customer.updated', 'customer.deleted', 'invoice.created', 'invoice.finalized',
|
|
160
175
|
'invoice.paid', 'invoice.payment_failed', 'invoice.voided', 'invoice.marked_uncollectible', 'invoice.sent',
|
|
161
176
|
'customer.subscription.updated', 'customer.subscription.paused', 'customer.subscription.resumed',
|
|
@@ -186,7 +201,7 @@ export function eventTypeFor(operation: string): string | null {
|
|
|
186
201
|
case 'invoice.finalize': return 'invoice.finalized';
|
|
187
202
|
case 'invoice.pay': return 'invoice.paid';
|
|
188
203
|
case 'invoice.void': return 'invoice.voided';
|
|
189
|
-
case 'refund.create': return '
|
|
204
|
+
case 'refund.create': return 'refund.created';
|
|
190
205
|
case 'dispute.create': return 'charge.dispute.created';
|
|
191
206
|
case 'payout.create': return 'payout.created';
|
|
192
207
|
case 'product.create': return 'product.created';
|
|
@@ -202,11 +217,27 @@ export function eventTypeFor(operation: string): string | null {
|
|
|
202
217
|
case 'issuing_card.create': return 'issuing_card.created';
|
|
203
218
|
case 'issuing_cardholder.create': return 'issuing_cardholder.created';
|
|
204
219
|
case 'issuing_transaction.create': return 'issuing_transaction.created';
|
|
205
|
-
default: return
|
|
220
|
+
default: return vendorEventType(operation);
|
|
206
221
|
}
|
|
207
222
|
}
|
|
208
223
|
|
|
209
|
-
|
|
224
|
+
/** Any other write records the event Stripe sends for it, when Stripe sends one: the operation itself, or its
|
|
225
|
+
* resource's create/update/delete as `<resource>.created|updated|deleted` (the types Stripe's spec enumerates). */
|
|
226
|
+
function vendorEventType(operation: string): string | null {
|
|
227
|
+
if (VENDOR_EVENT_TYPES.has(operation)) return operation;
|
|
228
|
+
const verb = /^(.+)\.(create|update|delete)$/.exec(operation);
|
|
229
|
+
return verb && VENDOR_EVENT_TYPES.has(`${verb[1]}.${verb[2]}d`) ? `${verb[1]}.${verb[2]}d` : null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** An event's id, DERIVED from the event itself — the operation, the resource it carries and the instant —
|
|
233
|
+
* never a process counter (protocol 2: two identical worlds deliver identically, and a replay of the same
|
|
234
|
+
* write raises the same event id rather than one that drifts with process uptime). */
|
|
235
|
+
function eventId(operation: string, resource: Record<string, unknown>, occurredAt: string, account?: string): string {
|
|
236
|
+
let h = 0x811c9dc5;
|
|
237
|
+
const seed = `${operation}|${String((resource as { id?: unknown }).id ?? '')}|${occurredAt}|${account ?? ''}`;
|
|
238
|
+
for (let i = 0; i < seed.length; i += 1) { h ^= seed.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; }
|
|
239
|
+
return `evt_twin_${h.toString(16).padStart(8, '0')}`;
|
|
240
|
+
}
|
|
210
241
|
// Live HTTP delivery signs exactly like real Stripe: HMAC-SHA256 over
|
|
211
242
|
// `${timestamp}.${payload}` keyed by the ENDPOINT'S OWN signing secret (the
|
|
212
243
|
// `whsec_twin_*` minted by POST /v1/webhook_endpoints and registered alongside the
|
|
@@ -226,14 +257,24 @@ let counter = 0;
|
|
|
226
257
|
// A delivery that still fails after the re-attempts is DROPPED, and says so: silent loss is
|
|
227
258
|
// the one outcome a twin must never have, because it makes every consumer look flaky.
|
|
228
259
|
const DELIVERY_ATTEMPTS = 3;
|
|
229
|
-
const httpDelivery: StripeEventDelivery = async (url, event) => {
|
|
230
|
-
|
|
231
|
-
const
|
|
260
|
+
const httpDelivery: StripeEventDelivery = async (url, event, endpointSecret) => {
|
|
261
|
+
// the event as its API version renders it (stripe-version.ts)
|
|
262
|
+
const payload = JSON.stringify(render(event, (event as { api_version?: string }).api_version));
|
|
263
|
+
const secret = endpointSecret ?? secrets.get(url) ?? STRIPE_WEBHOOK_FALLBACK_SECRET;
|
|
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);
|
|
267
|
+
if (refusal !== null) console.error(`[twin:stripe] webhook delivery DROPPED — ${event.type} ${event.id} -> ${url}: ${refusal}`);
|
|
268
|
+
return refusal !== null ? undefined : postEvent(url, event, payload, secret);
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/** The HTTP POST of a signed event to an endpoint the World lets it reach, re-attempted on a transport failure. */
|
|
272
|
+
async function postEvent(url: string, event: StripeEvent, payload: string, secret: string): Promise<void> {
|
|
232
273
|
let last = '';
|
|
233
274
|
for (let attempt = 1; attempt <= DELIVERY_ATTEMPTS; attempt += 1) {
|
|
234
275
|
try {
|
|
235
276
|
const header = generateTestHeaderString({ payload, secret });
|
|
236
|
-
await
|
|
277
|
+
await appFetch(url, { method: 'POST', headers: { 'content-type': 'application/json', 'stripe-signature': header }, body: payload });
|
|
237
278
|
return;
|
|
238
279
|
} catch (error) {
|
|
239
280
|
last = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
@@ -241,16 +282,17 @@ const httpDelivery: StripeEventDelivery = async (url, event) => {
|
|
|
241
282
|
}
|
|
242
283
|
}
|
|
243
284
|
console.error(`[twin:stripe] webhook delivery DROPPED after ${DELIVERY_ATTEMPTS} attempts — ${event.type} ${event.id} -> ${url}: ${last}`);
|
|
244
|
-
}
|
|
285
|
+
}
|
|
245
286
|
|
|
246
287
|
// Default deliverer override (a TEST SEAM). The twin's write path emits events without
|
|
247
288
|
// threading a per-call deliverer, so to drive event delivery fully OFFLINE/deterministically
|
|
248
289
|
// — no real sockets — a test installs a fake sink here. When unset, real HTTP POST is used
|
|
249
290
|
// (prod behavior). This keeps D5 honest: verify() exercises the twin's own emission, in-process.
|
|
250
|
-
|
|
291
|
+
// a slot, not module truth (protocol 2): WHERE a delivery goes, set once by the host that mounts the twin
|
|
292
|
+
const defaultDelivery: { current: StripeEventDelivery | null } = { current: null };
|
|
251
293
|
/** Install a default deliverer used by the write path when no per-call `deliver` is passed. */
|
|
252
294
|
export function setStripeEventDelivery(deliver: StripeEventDelivery | null): void {
|
|
253
|
-
defaultDelivery = deliver;
|
|
295
|
+
defaultDelivery.current = deliver;
|
|
254
296
|
}
|
|
255
297
|
|
|
256
298
|
// ── Real-time authorization (issuing_authorization.request) — the SYNCHRONOUS leg ────────
|
|
@@ -283,9 +325,18 @@ export type StripeAuthRequestOutcome =
|
|
|
283
325
|
// the 2s window. Signs with the enrolled endpoint's own whsec (passed by the caller, read
|
|
284
326
|
// from the endpoint's state row) — never a placeholder.
|
|
285
327
|
async function httpAuthRequestDelivery(url: string, event: StripeEvent, secret: string): Promise<StripeWebhookEndpointResponse> {
|
|
286
|
-
|
|
328
|
+
// the event as its API version renders it (stripe-version.ts)
|
|
329
|
+
const payload = JSON.stringify(render(event, (event as { api_version?: string }).api_version));
|
|
287
330
|
const header = generateTestHeaderString({ payload, secret });
|
|
288
|
-
|
|
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);
|
|
334
|
+
return refusal !== null ? Promise.reject(new Error(refusal)) : postAuthRequest(url, payload, header);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** The HTTP POST of a real-time authorization request, cut off at the window, and the endpoint's answer. */
|
|
338
|
+
async function postAuthRequest(url: string, payload: string, header: string): Promise<StripeWebhookEndpointResponse> {
|
|
339
|
+
const res = await appFetch(url, {
|
|
289
340
|
method: 'POST',
|
|
290
341
|
headers: { 'content-type': 'application/json', 'stripe-signature': header },
|
|
291
342
|
body: payload,
|
|
@@ -304,16 +355,20 @@ async function httpAuthRequestDelivery(url: string, event: StripeEvent, secret:
|
|
|
304
355
|
export async function requestAuthorizationDecision(url: string, event: StripeEvent, secret: string): Promise<StripeAuthRequestOutcome> {
|
|
305
356
|
let res: void | StripeWebhookEndpointResponse;
|
|
306
357
|
try {
|
|
307
|
-
res = defaultDelivery ? await defaultDelivery(url, event) : await httpAuthRequestDelivery(url, event, secret);
|
|
358
|
+
res = defaultDelivery.current ? await defaultDelivery.current(url, event) : await httpAuthRequestDelivery(url, event, secret);
|
|
308
359
|
} catch (e) {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
return { kind: 'timeout', message: 'Webhook endpoint did not respond within the real-time authorization window.' };
|
|
312
|
-
}
|
|
313
|
-
// No response ever reached us (connection refused, DNS, socket reset) — like an
|
|
314
|
-
// endpoint that never answered inside the window: a timeout, not an invalid response.
|
|
315
|
-
return { kind: 'timeout', message: 'Webhook endpoint was unreachable within the real-time authorization window.' };
|
|
360
|
+
// no answer inside the window: cut off (TimeoutError, AbortError), or never reached (refused, DNS, reset), alike
|
|
361
|
+
return (e instanceof Error && (e.name === 'TimeoutError' || e.name === 'AbortError')) ? TIMED_OUT : UNREACHED;
|
|
316
362
|
}
|
|
363
|
+
return readDecision(res);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const TIMED_OUT: StripeAuthRequestOutcome = { kind: 'timeout', message: 'Webhook endpoint did not respond within the real-time authorization window.' };
|
|
367
|
+
const UNREACHED: StripeAuthRequestOutcome = { kind: 'timeout', message: 'Webhook endpoint was unreachable within the real-time authorization window.' };
|
|
368
|
+
|
|
369
|
+
/** The endpoint's synchronous answer read as the decision: `approved` (and an optional `amount`), or an error for an
|
|
370
|
+
* answer Stripe cannot read. */
|
|
371
|
+
function readDecision(res: void | StripeWebhookEndpointResponse): StripeAuthRequestOutcome {
|
|
317
372
|
if (!res || typeof res !== 'object') {
|
|
318
373
|
return { kind: 'error', message: 'Webhook endpoint returned no response body.' };
|
|
319
374
|
}
|
|
@@ -348,24 +403,31 @@ export async function requestAuthorizationDecision(url: string, event: StripeEve
|
|
|
348
403
|
export async function emitStripeEvent(
|
|
349
404
|
operation: string,
|
|
350
405
|
resource: Record<string, unknown>,
|
|
351
|
-
opts: { occurredAt: string; deliver?: StripeEventDelivery } = { occurredAt: '1970-01-01T00:00:00.000Z' },
|
|
406
|
+
opts: { occurredAt: string; deliver?: StripeEventDelivery; endpoints?: StripeWebhookTarget[]; account?: string; /** the id the stored event takes (afterStripeWrite) */ id?: string } = { occurredAt: '1970-01-01T00:00:00.000Z' },
|
|
352
407
|
): Promise<StripeEvent[]> {
|
|
353
408
|
const type = eventTypeFor(operation);
|
|
354
|
-
|
|
355
|
-
const
|
|
409
|
+
// the World's own endpoints (its tree is the record), then any URL a host registered directly
|
|
410
|
+
const targets: StripeWebhookTarget[] = [...(opts.endpoints ?? [])];
|
|
411
|
+
for (const url of registry) if (!targets.some((t) => t.url === url)) targets.push({ url, ...(secrets.has(url) ? { secret: secrets.get(url)! } : {}), ...(subscriptions.has(url) ? { enabledEvents: subscriptions.get(url)! } : {}) });
|
|
412
|
+
if (!type || targets.length === 0) return [];
|
|
413
|
+
const deliver = opts.deliver ?? defaultDelivery.current ?? httpDelivery;
|
|
356
414
|
const event: StripeEvent = {
|
|
357
|
-
id:
|
|
415
|
+
id: opts.id ?? eventId(operation, resource, opts.occurredAt, opts.account),
|
|
358
416
|
object: 'event',
|
|
359
417
|
type,
|
|
360
418
|
created: Math.floor(Date.parse(opts.occurredAt) / 1000) || 0,
|
|
361
419
|
livemode: false,
|
|
362
420
|
data: { object: resource },
|
|
421
|
+
// "Each event for a connected account contains a top-level `account` property that identifies the connected account"
|
|
422
|
+
...(opts.account ? { account: opts.account } : {}),
|
|
363
423
|
};
|
|
364
424
|
const out: StripeEvent[] = [];
|
|
365
|
-
for (const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
425
|
+
for (const target of targets) {
|
|
426
|
+
if (target.enabledEvents && !stripeEventMatches(target.enabledEvents, type)) continue;
|
|
427
|
+
// an endpoint's scope (docs.stripe.com/connect/webhooks): `connect: true` receives the Connected accounts scope,
|
|
428
|
+
// `connect: false` Your account; a URL registered without a scope (a host's seam) receives both
|
|
429
|
+
if (target.connect !== undefined && target.connect !== Boolean(opts.account)) continue;
|
|
430
|
+
await deliver(target.url, event, target.secret);
|
|
369
431
|
out.push(event);
|
|
370
432
|
}
|
|
371
433
|
return out;
|