@mandateops/audit 0.1.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 +43 -1
- package/dist/index.js +73 -13
- package/dist/providers/fiat.d.ts +7 -1
- package/dist/providers/fiat.js +11 -2
- package/package.json +1 -1
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 =
|
|
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 =
|
|
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 }) => {
|
package/dist/providers/fiat.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
package/dist/providers/fiat.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
/**
|
|
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.
|
|
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",
|