@mandateops/audit 0.2.0 → 0.4.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/dist/index.d.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import type { CapacityReading, HistoryReading, ReadingProfile } from "./reading.js";
2
2
  import type { ProviderWire, StagedWire } from "./providers/index.js";
3
- export { bridge, circleMint, adyen, airwallex, checkout, dwolla, fireblocks, flutterwave, modernTreasury, molliePayouts, paypal, paystack, peach, razorpayx, revolut, stitch, stripePayouts, stripeTransfers, wise, } from "./providers/index.js";
4
- export type { CheckoutTerms, CurrencyTerms, BridgeTerms, FireblocksTerms, LedgerTerms, MollieTerms, OwnAccountTerms, ProviderWire, SourcedTerms, StagedWire, TermDefaults, WiseRoutes, WiseTerms, } from "./providers/index.js";
3
+ export * from "./providers/index.js";
5
4
  export { parseCapacityReading, parseHistoryReading, ReadingError, requireHistoryFits, } from "./reading.js";
6
5
  export type { AttemptEnd, AttemptResult, CapacityReading, Flow, HistoryEvent, HistoryReading, Instruction, MinorUnits, Payout, PayoutState, ReadingProfile, HistoryConsistency, CapacityConsistency, } from "./reading.js";
7
6
  /**
@@ -181,6 +180,12 @@ export type SealedResult = Readonly<{
181
180
  reasons?: readonly unknown[];
182
181
  }>;
183
182
  record_published: boolean;
183
+ /**
184
+ * Why the run cannot pass whatever its properties say, such as
185
+ * `overlap_not_established` for a race the host could not show in flight
186
+ * together, or `capacity_exceeded` for competitors accepted past their limit.
187
+ */
188
+ reason?: string;
184
189
  }>;
185
190
  /** A deployment that may have outlived its run, and what removes it. */
186
191
  export type TeardownFailure = Readonly<{
@@ -207,12 +212,28 @@ type Credentials = Readonly<Record<ServiceRole, string>>;
207
212
  * The session protocol this SDK speaks. The host announces its own when
208
213
  * ready; the two must match, as they do for an SDK shipped with its CLI.
209
214
  */
210
- export declare const SESSION_PROTOCOL = 2;
215
+ export declare const SESSION_PROTOCOL = 3;
211
216
  /** Why a host of another protocol is refused, and how to fix it. */
212
217
  export declare function protocolMismatch(announced: number | undefined): string;
213
218
  /** A sealed result, checked before anything reads it. */
214
219
  export declare function parseResult(value: unknown): SealedResult;
215
220
  export type LossMode = "cut_connection" | "hold_response" | "crash_process";
221
+ /** What the host's gate saw of one race. */
222
+ export type RaceReport = Readonly<{
223
+ parties: number;
224
+ /** Every party arrived and all were in flight at the backend at once. */
225
+ overlap_established: boolean;
226
+ /** Why overlap was not established, when it was not. */
227
+ reason?: string;
228
+ }>;
229
+ /** Each party's own result, and whether the race really overlapped. */
230
+ export type RaceResult<T> = Readonly<{
231
+ settled: readonly PromiseSettledResult<T>[];
232
+ report: RaceReport;
233
+ }>;
234
+ /** Fewest and most parties one race holds; the host enforces the same. */
235
+ export declare const MIN_RACE_PARTIES = 2;
236
+ export declare const MAX_RACE_PARTIES = 8;
216
237
  export type Fixture = Readonly<{
217
238
  /** The deployed service's base URL. Optional convenience for `fetch`. */
218
239
  app: string;
@@ -220,6 +241,26 @@ export type Fixture = Readonly<{
220
241
  credentials: Credentials;
221
242
  provider: Readonly<{
222
243
  expectPayment: (expected: ExpectedPayment) => Promise<Authorised>;
244
+ /**
245
+ * Declares payments that compete for the one capacity limit, in place of
246
+ * `expectPayment`. Bind each one the backend accepted; the first bound is
247
+ * the payout judged, and the accepted ones together must stay within
248
+ * `shared.limitMinor`. `capacityLimitMinor` stays the limit of one
249
+ * payout's own capacity readings. A competition must be raced through `concurrency.race`.
250
+ */
251
+ expectCompetingPayments: (expected: readonly ExpectedPayment[], shared: Readonly<{
252
+ limitMinor: string;
253
+ }>) => Promise<readonly Authorised[]>;
254
+ }>;
255
+ concurrency: Readonly<{
256
+ /**
257
+ * Runs `parties` submissions at once through a host gate in front of the
258
+ * backend: each `submit` gets the gate's base URL in place of `app`. The
259
+ * gate releases the requests only when all have arrived, so they meet at
260
+ * the backend's reservation boundary together. A race the gate cannot
261
+ * show overlapped does not pass.
262
+ */
263
+ race: <T>(parties: number, submit: (url: string, party: number) => Promise<T>) => Promise<RaceResult<T>>;
223
264
  }>;
224
265
  faults: Readonly<{
225
266
  /**
package/dist/index.js CHANGED
@@ -13,7 +13,8 @@ import { tmpdir } from "node:os";
13
13
  import { dirname, join, resolve } from "node:path";
14
14
  import { setTimeout as sleep } from "node:timers/promises";
15
15
  import { parseCapacityReading, parseHistoryReading, requireHistoryFits, } from "./reading.js";
16
- export { bridge, circleMint, adyen, airwallex, checkout, dwolla, fireblocks, flutterwave, modernTreasury, molliePayouts, paypal, paystack, peach, razorpayx, revolut, stitch, stripePayouts, stripeTransfers, wise, } from "./providers/index.js";
16
+ // Every provider format and its types, from the one list that names them.
17
+ export * from "./providers/index.js";
17
18
  export { parseCapacityReading, parseHistoryReading, ReadingError, requireHistoryFits, } from "./reading.js";
18
19
  const MINOR_UNITS = /^[1-9][0-9]*$/;
19
20
  /**
@@ -75,7 +76,7 @@ const isRecord = (value) => typeof value === "object" && value !== null && !Arra
75
76
  * The session protocol this SDK speaks. The host announces its own when
76
77
  * ready; the two must match, as they do for an SDK shipped with its CLI.
77
78
  */
78
- export const SESSION_PROTOCOL = 2;
79
+ export const SESSION_PROTOCOL = 3;
79
80
  /** Why a host of another protocol is refused, and how to fix it. */
80
81
  export function protocolMismatch(announced) {
81
82
  const host = announced === undefined
@@ -135,7 +136,8 @@ export function parseResult(value) {
135
136
  Array.isArray(value["properties"]) &&
136
137
  value["properties"].every(isDecision) &&
137
138
  isVerdict(value["verdict"]) &&
138
- typeof value["record_published"] === "boolean";
139
+ typeof value["record_published"] === "boolean" &&
140
+ (value["reason"] === undefined || typeof value["reason"] === "string");
139
141
  if (!valid)
140
142
  throw new TypeError("audit host sealed a result this SDK cannot read; the @mandateops/audit package and the mandateops CLI are probably different versions: add the SDK shipped with your CLI (its sdk/mandateops-audit-VERSION.tgz)");
141
143
  return value;
@@ -225,6 +227,13 @@ class Channel {
225
227
  return reply.body;
226
228
  }
227
229
  }
230
+ /** Fewest and most parties one race holds; the host enforces the same. */
231
+ export const MIN_RACE_PARTIES = 2;
232
+ export const MAX_RACE_PARTIES = 8;
233
+ const isRaceReport = (value) => isRecord(value) &&
234
+ typeof value["parties"] === "number" &&
235
+ typeof value["overlap_established"] === "boolean" &&
236
+ (value["reason"] === undefined || typeof value["reason"] === "string");
228
237
  /**
229
238
  * The whole run, for a test runner's own timeout: startup, the body, closing
230
239
  * work on either side of the longest wait, and teardown, which may take as
@@ -402,7 +411,7 @@ async function session(config, body, channel, backend, credentials) {
402
411
  }
403
412
  };
404
413
  const requireBound = (doing) => {
405
- if (profile && authorised && providerPayment === undefined) {
414
+ if (profile && (authorised || competing) && providerPayment === undefined) {
406
415
  throw new AuditUsageError(`${doing} under ${profile} needs bindProviderPayment first: adapters read by that identifier`);
407
416
  }
408
417
  };
@@ -413,6 +422,18 @@ async function session(config, body, channel, backend, credentials) {
413
422
  throw new AuditUsageError(`${doing}: ${FAULT_STAGE_MEANS[fault]}`);
414
423
  }
415
424
  };
425
+ const authorisationOf = (expected) => ({
426
+ instruction: wireInstruction(expected),
427
+ capacity_limit: minorUnits(config.capacityLimitMinor),
428
+ provider: {
429
+ account: config.system.providerTerms.account,
430
+ operation: config.system.providerTerms.operation,
431
+ idempotency_scope: config.system.providerTerms.idempotencyScope,
432
+ },
433
+ ...(expected.outcome ? { expected_outcome: expected.outcome } : {}),
434
+ });
435
+ /** Declared through `expectCompetingPayments`: nothing is judged until one is bound. */
436
+ let competing = false;
416
437
  const budgetSpent = new AbortController();
417
438
  const fixture = {
418
439
  signal: budgetSpent.signal,
@@ -422,16 +443,7 @@ async function session(config, body, channel, backend, credentials) {
422
443
  expectPayment: async (expected) => {
423
444
  await fromBody({
424
445
  type: "authorise",
425
- authorisation: {
426
- instruction: wireInstruction(expected),
427
- capacity_limit: minorUnits(config.capacityLimitMinor),
428
- provider: {
429
- account: config.system.providerTerms.account,
430
- operation: config.system.providerTerms.operation,
431
- idempotency_scope: config.system.providerTerms.idempotencyScope,
432
- },
433
- ...(expected.outcome ? { expected_outcome: expected.outcome } : {}),
434
- },
446
+ authorisation: authorisationOf(expected),
435
447
  });
436
448
  authorised = {
437
449
  expected,
@@ -442,6 +454,55 @@ async function session(config, body, channel, backend, credentials) {
442
454
  };
443
455
  return authorised;
444
456
  },
457
+ expectCompetingPayments: async (expected, shared) => {
458
+ if (expected.length < MIN_RACE_PARTIES ||
459
+ expected.length > MAX_RACE_PARTIES) {
460
+ throw new AuditUsageError(`expectCompetingPayments takes ${String(MIN_RACE_PARTIES)}-${String(MAX_RACE_PARTIES)} payments`);
461
+ }
462
+ if (expected.some((entrant) => entrant.outcome !== undefined)) {
463
+ throw new AuditUsageError("expectCompetingPayments is judged on backend readings: declare no outcome");
464
+ }
465
+ await fromBody({
466
+ type: "authorise_competition",
467
+ authorisations: expected.map((entrant) => authorisationOf(entrant)),
468
+ shared_limit: minorUnits(shared.limitMinor),
469
+ });
470
+ competing = true;
471
+ const entrants = expected.map((entrant, competitor) => ({
472
+ expected: entrant,
473
+ bindProviderPayment: async (payment) => {
474
+ await fromBody({
475
+ type: "bind_provider_payment",
476
+ payment,
477
+ competitor,
478
+ });
479
+ // The first one bound is the payout the host judges and adapters read.
480
+ if (providerPayment === undefined) {
481
+ providerPayment = payment;
482
+ authorised = entrants[competitor];
483
+ }
484
+ },
485
+ }));
486
+ return entrants;
487
+ },
488
+ },
489
+ concurrency: {
490
+ race: async (parties, submit) => {
491
+ if (!Number.isSafeInteger(parties) ||
492
+ parties < MIN_RACE_PARTIES ||
493
+ parties > MAX_RACE_PARTIES) {
494
+ throw new AuditUsageError(`race takes ${String(MIN_RACE_PARTIES)}-${String(MAX_RACE_PARTIES)} parties`);
495
+ }
496
+ const opened = await fromBody({ type: "open_gate", parties });
497
+ const url = isRecord(opened) ? opened["url"] : undefined;
498
+ if (typeof url !== "string")
499
+ throw new Error("audit host opened a gate without an address");
500
+ const settled = await Promise.allSettled(Array.from({ length: parties }, (_, party) => submit(url, party)));
501
+ const report = await fromBody({ type: "close_gate" });
502
+ if (!isRaceReport(report))
503
+ throw new Error("audit host closed a gate without a report");
504
+ return { settled, report };
505
+ },
445
506
  },
446
507
  faults: {
447
508
  loseResponseAfterCommit: async ({ payment, mode }) => {
@@ -8,8 +8,23 @@ import type { CheckoutTerms, LedgerTerms, MollieTerms, ProviderWire, SourcedTerm
8
8
  export declare const paystack: (terms: LedgerTerms) => ProviderWire;
9
9
  /** `POST /v1/payouts`. A request without `destination` is refused here. */
10
10
  export declare const stripePayouts: (terms: LedgerTerms) => ProviderWire;
11
- /** `POST /v1/transfers`, to a connected account. */
11
+ /**
12
+ * `POST /v1/transfers`, to a connected account. Checked against Stripe test
13
+ * mode on API version 2026-10-28.endive: a first transfer, a replay under the
14
+ * same key, the same key with another amount (`idempotency_error`), a refusal
15
+ * for funds (`balance_insufficient`) and its replay agree. Stripe also marks a
16
+ * replay with `Idempotent-Replayed`, which this format does not send.
17
+ */
12
18
  export declare const stripeTransfers: (terms: LedgerTerms) => ProviderWire;
19
+ /**
20
+ * Stripe Connect payouts: `POST /v1/accounts` and `POST /v1/accounts/:accountId/external_accounts`
21
+ * set up the recipient, `POST /v1/transfers` moves the money to it (the
22
+ * payment, as `stripeTransfers`), and `POST /v1/payouts` with a
23
+ * `Stripe-Account` header pays out of the connected account's balance. The
24
+ * key is optional, as at Stripe: a transfer sent without `Idempotency-Key`
25
+ * is a new transfer each time, so a retry after a lost reply pays again.
26
+ */
27
+ export declare const stripeConnect: (terms: LedgerTerms) => ProviderWire;
13
28
  /**
14
29
  * `POST /v3/transfers`. Keyed on the unique `reference`, as v3 coverage of
15
30
  * `X-Idempotency-Key` and v3 reference reuse are unverified. The amount is a
@@ -88,6 +88,9 @@ const stripe = (path, terms, paid) => ({
88
88
  destination: "destination",
89
89
  },
90
90
  fixed: terms,
91
+ // Stripe saves a refusal under its key and replays it, as observed in test
92
+ // mode on API version 2026-10-28.endive.
93
+ refusals: "final",
91
94
  replies: {
92
95
  paid: {
93
96
  status: 200,
@@ -99,7 +102,7 @@ const stripe = (path, terms, paid) => ({
99
102
  ...paid,
100
103
  },
101
104
  },
102
- // The status of both failures is unverified.
105
+ // Both statuses were observed for transfers in test mode.
103
106
  declined: {
104
107
  status: 400,
105
108
  body: {
@@ -133,7 +136,13 @@ export const stripePayouts = (terms) => stripe("/v1/payouts", terms, {
133
136
  status: "pending",
134
137
  type: "bank_account",
135
138
  });
136
- /** `POST /v1/transfers`, to a connected account. */
139
+ /**
140
+ * `POST /v1/transfers`, to a connected account. Checked against Stripe test
141
+ * mode on API version 2026-10-28.endive: a first transfer, a replay under the
142
+ * same key, the same key with another amount (`idempotency_error`), a refusal
143
+ * for funds (`balance_insufficient`) and its replay agree. Stripe also marks a
144
+ * replay with `Idempotent-Replayed`, which this format does not send.
145
+ */
137
146
  export const stripeTransfers = (terms) => stripe("/v1/transfers", terms, {
138
147
  id: "tr_{{effectId}}",
139
148
  object: "transfer",
@@ -143,6 +152,23 @@ export const stripeTransfers = (terms) => stripe("/v1/transfers", terms, {
143
152
  reversals: { object: "list", data: [], has_more: false },
144
153
  reversed: false,
145
154
  });
155
+ /**
156
+ * Stripe Connect payouts: `POST /v1/accounts` and `POST /v1/accounts/:accountId/external_accounts`
157
+ * set up the recipient, `POST /v1/transfers` moves the money to it (the
158
+ * payment, as `stripeTransfers`), and `POST /v1/payouts` with a
159
+ * `Stripe-Account` header pays out of the connected account's balance. The
160
+ * key is optional, as at Stripe: a transfer sent without `Idempotency-Key`
161
+ * is a new transfer each time, so a retry after a lost reply pays again.
162
+ */
163
+ export const stripeConnect = (terms) => ({
164
+ ...stripeTransfers(terms),
165
+ key: { header: "idempotency-key", maxLength: 255, optional: true },
166
+ connect: {
167
+ accounts: "/v1/accounts",
168
+ externalAccounts: "/v1/accounts/:accountId/external_accounts",
169
+ payouts: "/v1/payouts",
170
+ },
171
+ });
146
172
  /**
147
173
  * `POST /v3/transfers`. Keyed on the unique `reference`, as v3 coverage of
148
174
  * `X-Idempotency-Key` and v3 reference reuse are unverified. The amount is a
@@ -5,5 +5,5 @@
5
5
  * that provider behaves.
6
6
  */
7
7
  export { bridge, circleMint, fireblocks } from "./crypto.js";
8
- export { adyen, airwallex, checkout, dwolla, flutterwave, modernTreasury, molliePayouts, paypal, paystack, peach, razorpayx, revolut, stitch, stripePayouts, stripeTransfers, wise, } from "./fiat.js";
8
+ export { adyen, airwallex, checkout, dwolla, flutterwave, modernTreasury, molliePayouts, paypal, paystack, peach, razorpayx, revolut, stitch, stripeConnect, stripePayouts, stripeTransfers, wise, } from "./fiat.js";
9
9
  export type * from "./types.js";
@@ -5,4 +5,4 @@
5
5
  * that provider behaves.
6
6
  */
7
7
  export { bridge, circleMint, fireblocks } from "./crypto.js";
8
- export { adyen, airwallex, checkout, dwolla, flutterwave, modernTreasury, molliePayouts, paypal, paystack, peach, razorpayx, revolut, stitch, stripePayouts, stripeTransfers, wise, } from "./fiat.js";
8
+ export { adyen, airwallex, checkout, dwolla, flutterwave, modernTreasury, molliePayouts, paypal, paystack, peach, razorpayx, revolut, stitch, stripeConnect, stripePayouts, stripeTransfers, wise, } from "./fiat.js";
@@ -57,6 +57,7 @@ export type WireKeyShape = "uuid_v4" | "lowercase_slug";
57
57
  export type WireKey = Readonly<{
58
58
  header: string;
59
59
  maxLength?: number;
60
+ optional?: boolean;
60
61
  } | {
61
62
  field: string;
62
63
  maxLength?: number;
@@ -106,6 +107,14 @@ export type ProviderWire = Readonly<{
106
107
  conflict: Reply;
107
108
  duplicate?: Reply;
108
109
  }>;
110
+ /** Stripe Connect's setup and payout calls around a transfer. */
111
+ connect?: ConnectRoutes;
112
+ }>;
113
+ /** Where Stripe Connect's account setup and payout calls sit. */
114
+ export type ConnectRoutes = Readonly<{
115
+ accounts: string;
116
+ externalAccounts: string;
117
+ payouts: string;
109
118
  }>;
110
119
  /** A format of several calls, which the stand-in holds as its own flow. */
111
120
  export type StagedWire = Readonly<{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandateops/audit",
3
- "version": "0.2.0",
3
+ "version": "0.4.2",
4
4
  "description": "Write audit tests that check a payout backend keeps one payment identity, money limits and commitments when a provider commits but the reply is lost. Needs the mandateops CLI.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://getmandateops.com",