@mandateops/audit 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -181,6 +181,12 @@ export type SealedResult = Readonly<{
181
181
  reasons?: readonly unknown[];
182
182
  }>;
183
183
  record_published: boolean;
184
+ /**
185
+ * Why the run cannot pass whatever its properties say, such as
186
+ * `overlap_not_established` for a race the host could not show in flight
187
+ * together, or `capacity_exceeded` for competitors accepted past their limit.
188
+ */
189
+ reason?: string;
184
190
  }>;
185
191
  /** A deployment that may have outlived its run, and what removes it. */
186
192
  export type TeardownFailure = Readonly<{
@@ -207,12 +213,28 @@ type Credentials = Readonly<Record<ServiceRole, string>>;
207
213
  * The session protocol this SDK speaks. The host announces its own when
208
214
  * ready; the two must match, as they do for an SDK shipped with its CLI.
209
215
  */
210
- export declare const SESSION_PROTOCOL = 2;
216
+ export declare const SESSION_PROTOCOL = 3;
211
217
  /** Why a host of another protocol is refused, and how to fix it. */
212
218
  export declare function protocolMismatch(announced: number | undefined): string;
213
219
  /** A sealed result, checked before anything reads it. */
214
220
  export declare function parseResult(value: unknown): SealedResult;
215
221
  export type LossMode = "cut_connection" | "hold_response" | "crash_process";
222
+ /** What the host's gate saw of one race. */
223
+ export type RaceReport = Readonly<{
224
+ parties: number;
225
+ /** Every party arrived and all were in flight at the backend at once. */
226
+ overlap_established: boolean;
227
+ /** Why overlap was not established, when it was not. */
228
+ reason?: string;
229
+ }>;
230
+ /** Each party's own result, and whether the race really overlapped. */
231
+ export type RaceResult<T> = Readonly<{
232
+ settled: readonly PromiseSettledResult<T>[];
233
+ report: RaceReport;
234
+ }>;
235
+ /** Fewest and most parties one race holds; the host enforces the same. */
236
+ export declare const MIN_RACE_PARTIES = 2;
237
+ export declare const MAX_RACE_PARTIES = 8;
216
238
  export type Fixture = Readonly<{
217
239
  /** The deployed service's base URL. Optional convenience for `fetch`. */
218
240
  app: string;
@@ -220,6 +242,26 @@ export type Fixture = Readonly<{
220
242
  credentials: Credentials;
221
243
  provider: Readonly<{
222
244
  expectPayment: (expected: ExpectedPayment) => Promise<Authorised>;
245
+ /**
246
+ * Declares payments that compete for the one capacity limit, in place of
247
+ * `expectPayment`. Bind each one the backend accepted; the first bound is
248
+ * the payout judged, and the accepted ones together must stay within
249
+ * `shared.limitMinor`. `capacityLimitMinor` stays the limit of one
250
+ * payout's own capacity readings. A competition must be raced through `concurrency.race`.
251
+ */
252
+ expectCompetingPayments: (expected: readonly ExpectedPayment[], shared: Readonly<{
253
+ limitMinor: string;
254
+ }>) => Promise<readonly Authorised[]>;
255
+ }>;
256
+ concurrency: Readonly<{
257
+ /**
258
+ * Runs `parties` submissions at once through a host gate in front of the
259
+ * backend: each `submit` gets the gate's base URL in place of `app`. The
260
+ * gate releases the requests only when all have arrived, so they meet at
261
+ * the backend's reservation boundary together. A race the gate cannot
262
+ * show overlapped does not pass.
263
+ */
264
+ race: <T>(parties: number, submit: (url: string, party: number) => Promise<T>) => Promise<RaceResult<T>>;
223
265
  }>;
224
266
  faults: Readonly<{
225
267
  /**
package/dist/index.js CHANGED
@@ -75,7 +75,7 @@ const isRecord = (value) => typeof value === "object" && value !== null && !Arra
75
75
  * The session protocol this SDK speaks. The host announces its own when
76
76
  * ready; the two must match, as they do for an SDK shipped with its CLI.
77
77
  */
78
- export const SESSION_PROTOCOL = 2;
78
+ export const SESSION_PROTOCOL = 3;
79
79
  /** Why a host of another protocol is refused, and how to fix it. */
80
80
  export function protocolMismatch(announced) {
81
81
  const host = announced === undefined
@@ -135,7 +135,8 @@ export function parseResult(value) {
135
135
  Array.isArray(value["properties"]) &&
136
136
  value["properties"].every(isDecision) &&
137
137
  isVerdict(value["verdict"]) &&
138
- typeof value["record_published"] === "boolean";
138
+ typeof value["record_published"] === "boolean" &&
139
+ (value["reason"] === undefined || typeof value["reason"] === "string");
139
140
  if (!valid)
140
141
  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
142
  return value;
@@ -225,6 +226,13 @@ class Channel {
225
226
  return reply.body;
226
227
  }
227
228
  }
229
+ /** Fewest and most parties one race holds; the host enforces the same. */
230
+ export const MIN_RACE_PARTIES = 2;
231
+ export const MAX_RACE_PARTIES = 8;
232
+ const isRaceReport = (value) => isRecord(value) &&
233
+ typeof value["parties"] === "number" &&
234
+ typeof value["overlap_established"] === "boolean" &&
235
+ (value["reason"] === undefined || typeof value["reason"] === "string");
228
236
  /**
229
237
  * The whole run, for a test runner's own timeout: startup, the body, closing
230
238
  * work on either side of the longest wait, and teardown, which may take as
@@ -402,7 +410,7 @@ async function session(config, body, channel, backend, credentials) {
402
410
  }
403
411
  };
404
412
  const requireBound = (doing) => {
405
- if (profile && authorised && providerPayment === undefined) {
413
+ if (profile && (authorised || competing) && providerPayment === undefined) {
406
414
  throw new AuditUsageError(`${doing} under ${profile} needs bindProviderPayment first: adapters read by that identifier`);
407
415
  }
408
416
  };
@@ -413,6 +421,18 @@ async function session(config, body, channel, backend, credentials) {
413
421
  throw new AuditUsageError(`${doing}: ${FAULT_STAGE_MEANS[fault]}`);
414
422
  }
415
423
  };
424
+ const authorisationOf = (expected) => ({
425
+ instruction: wireInstruction(expected),
426
+ capacity_limit: minorUnits(config.capacityLimitMinor),
427
+ provider: {
428
+ account: config.system.providerTerms.account,
429
+ operation: config.system.providerTerms.operation,
430
+ idempotency_scope: config.system.providerTerms.idempotencyScope,
431
+ },
432
+ ...(expected.outcome ? { expected_outcome: expected.outcome } : {}),
433
+ });
434
+ /** Declared through `expectCompetingPayments`: nothing is judged until one is bound. */
435
+ let competing = false;
416
436
  const budgetSpent = new AbortController();
417
437
  const fixture = {
418
438
  signal: budgetSpent.signal,
@@ -422,16 +442,7 @@ async function session(config, body, channel, backend, credentials) {
422
442
  expectPayment: async (expected) => {
423
443
  await fromBody({
424
444
  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
- },
445
+ authorisation: authorisationOf(expected),
435
446
  });
436
447
  authorised = {
437
448
  expected,
@@ -442,6 +453,55 @@ async function session(config, body, channel, backend, credentials) {
442
453
  };
443
454
  return authorised;
444
455
  },
456
+ expectCompetingPayments: async (expected, shared) => {
457
+ if (expected.length < MIN_RACE_PARTIES ||
458
+ expected.length > MAX_RACE_PARTIES) {
459
+ throw new AuditUsageError(`expectCompetingPayments takes ${String(MIN_RACE_PARTIES)}-${String(MAX_RACE_PARTIES)} payments`);
460
+ }
461
+ if (expected.some((entrant) => entrant.outcome !== undefined)) {
462
+ throw new AuditUsageError("expectCompetingPayments is judged on backend readings: declare no outcome");
463
+ }
464
+ await fromBody({
465
+ type: "authorise_competition",
466
+ authorisations: expected.map((entrant) => authorisationOf(entrant)),
467
+ shared_limit: minorUnits(shared.limitMinor),
468
+ });
469
+ competing = true;
470
+ const entrants = expected.map((entrant, competitor) => ({
471
+ expected: entrant,
472
+ bindProviderPayment: async (payment) => {
473
+ await fromBody({
474
+ type: "bind_provider_payment",
475
+ payment,
476
+ competitor,
477
+ });
478
+ // The first one bound is the payout the host judges and adapters read.
479
+ if (providerPayment === undefined) {
480
+ providerPayment = payment;
481
+ authorised = entrants[competitor];
482
+ }
483
+ },
484
+ }));
485
+ return entrants;
486
+ },
487
+ },
488
+ concurrency: {
489
+ race: async (parties, submit) => {
490
+ if (!Number.isSafeInteger(parties) ||
491
+ parties < MIN_RACE_PARTIES ||
492
+ parties > MAX_RACE_PARTIES) {
493
+ throw new AuditUsageError(`race takes ${String(MIN_RACE_PARTIES)}-${String(MAX_RACE_PARTIES)} parties`);
494
+ }
495
+ const opened = await fromBody({ type: "open_gate", parties });
496
+ const url = isRecord(opened) ? opened["url"] : undefined;
497
+ if (typeof url !== "string")
498
+ throw new Error("audit host opened a gate without an address");
499
+ const settled = await Promise.allSettled(Array.from({ length: parties }, (_, party) => submit(url, party)));
500
+ const report = await fromBody({ type: "close_gate" });
501
+ if (!isRaceReport(report))
502
+ throw new Error("audit host closed a gate without a report");
503
+ return { settled, report };
504
+ },
445
505
  },
446
506
  faults: {
447
507
  loseResponseAfterCommit: async ({ payment, mode }) => {
@@ -8,7 +8,13 @@ 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;
13
19
  /**
14
20
  * `POST /v3/transfers`. Keyed on the unique `reference`, as v3 coverage of
@@ -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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandateops/audit",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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",