@visa/cli 4.1.0-rc.21 → 4.1.0-rc.211
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 +200 -226
- package/dist/checkout-engine/adapters/generic.d.ts +69 -0
- package/dist/checkout-engine/adapters/generic.js +383 -58
- package/dist/checkout-engine/adapters/index.d.ts +4 -1
- package/dist/checkout-engine/adapters/index.js +10 -3
- package/dist/checkout-engine/adapters/shopify.d.ts +55 -0
- package/dist/checkout-engine/adapters/shopify.js +514 -0
- package/dist/checkout-engine/amount.d.ts +15 -0
- package/dist/checkout-engine/amount.js +72 -0
- package/dist/checkout-engine/cli-engine.d.ts +264 -4
- package/dist/checkout-engine/cli-engine.js +803 -43
- package/dist/checkout-engine/confirmed-merchants.d.ts +31 -0
- package/dist/checkout-engine/confirmed-merchants.js +165 -0
- package/dist/checkout-engine/detect.d.ts +1 -1
- package/dist/checkout-engine/detect.js +26 -0
- package/dist/checkout-engine/evidence.d.ts +4 -1
- package/dist/checkout-engine/evidence.js +51 -6
- package/dist/checkout-engine/executor.d.ts +47 -4
- package/dist/checkout-engine/executor.js +418 -131
- package/dist/checkout-engine/hosted-approval.d.ts +124 -7
- package/dist/checkout-engine/hosted-approval.js +381 -54
- package/dist/checkout-engine/index.d.ts +8 -2
- package/dist/checkout-engine/index.js +7 -1
- package/dist/checkout-engine/instrument.d.ts +7 -0
- package/dist/checkout-engine/instrument.js +4 -0
- package/dist/checkout-engine/known-merchants.d.ts +10 -0
- package/dist/checkout-engine/known-merchants.js +38 -0
- package/dist/checkout-engine/live-fill-approval.d.ts +5 -20
- package/dist/checkout-engine/live-fill-approval.js +20 -51
- package/dist/checkout-engine/mandate/card-mandate.d.ts +121 -0
- package/dist/checkout-engine/mandate/card-mandate.js +226 -0
- package/dist/checkout-engine/mandate/mandate-ledger.d.ts +174 -0
- package/dist/checkout-engine/mandate/mandate-ledger.js +410 -0
- package/dist/checkout-engine/outcome.d.ts +2 -2
- package/dist/checkout-engine/outcome.js +36 -1
- package/dist/checkout-engine/owner-only-file.d.ts +9 -0
- package/dist/checkout-engine/owner-only-file.js +20 -1
- package/dist/checkout-engine/receipt-dir.d.ts +6 -0
- package/dist/checkout-engine/receipt-dir.js +8 -0
- package/dist/checkout-engine/receipt.d.ts +42 -2
- package/dist/checkout-engine/receipt.js +43 -14
- package/dist/checkout-engine/trace-handles.d.ts +8 -0
- package/dist/checkout-engine/trace-handles.js +12 -0
- package/dist/checkout-engine/types.d.ts +28 -2
- package/dist/checkout-engine/unresolved-charges.d.ts +34 -0
- package/dist/checkout-engine/unresolved-charges.js +125 -0
- package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +53 -1
- package/dist/checkout-engine/vgs-gateway/server-mint-client.js +78 -10
- package/dist/checkout-engine/vgs-live-instrument.d.ts +38 -35
- package/dist/checkout-engine/vgs-live-instrument.js +51 -74
- package/dist/checkout-engine/vic-confirmation.d.ts +18 -0
- package/dist/checkout-engine/vic-confirmation.js +9 -3
- package/dist/checkout-engine/web-bot-auth.d.ts +92 -0
- package/dist/checkout-engine/web-bot-auth.js +159 -0
- package/dist/cli.js +772 -496
- package/dist/mcp-apps/ucp-checkout.html +280 -0
- package/dist/mcp-server/index.js +637 -175
- package/dist/skills/pair-visa-agent/RUNTIMES.md +93 -0
- package/dist/skills/pair-visa-agent/SKILL.md +479 -221
- package/dist/subway-direct.mjs +1 -0
- package/install.ps1 +5 -43
- package/install.sh +5 -37
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +33 -27
- package/server.json +4 -4
- package/dist/checkout-engine/inline-target.d.ts +0 -13
- package/dist/checkout-engine/inline-target.js +0 -37
- package/dist/checkout-engine/pay-args.d.ts +0 -14
- package/dist/checkout-engine/pay-args.js +0 -44
- package/dist/checkout-engine/pay.d.ts +0 -1
- package/dist/checkout-engine/pay.js +0 -13
- package/dist/checkout-engine/repo-env.d.ts +0 -11
- package/dist/checkout-engine/repo-env.js +0 -23
- package/dist/checkout-engine/run-live-fill.d.ts +0 -1
- package/dist/checkout-engine/run-live-fill.js +0 -443
- package/dist/checkout-engine/vgs-gateway/fetch-credential.d.mts +0 -74
- package/dist/checkout-engine/vgs-gateway/fetch-credential.mjs +0 -248
|
@@ -1,3 +1,9 @@
|
|
|
1
|
-
export { createCliCheckoutEngine, type CliReviewInput, type CliReviewFacts, type CliPayInput, type CliReceiptFacts, } from './cli-engine.js';
|
|
1
|
+
export { createCliCheckoutEngine, type CliReviewInput, type CliReviewFacts, type CliPayInput, type CliReceiptFacts, type CliStartMandateInput, type CliMandateFacts, type CliEngineDeps, CheckoutReviewRefusedError, } from './cli-engine.js';
|
|
2
2
|
export { prepareCheckout, submitApprovedCheckout, runCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
|
|
3
|
-
export type { CheckoutResult, CheckoutReview, CheckoutOutcome } from './executor.js';
|
|
3
|
+
export type { CheckoutResult, CheckoutReview, CheckoutOutcome, CheckoutFailureCode, } from './executor.js';
|
|
4
|
+
export { readConfirmedMerchants, type ConfirmedMerchant, type ConfirmedCharge, } from './confirmed-merchants.js';
|
|
5
|
+
export { KNOWN_MERCHANT_IDENTITIES, type MerchantIdentity } from './known-merchants.js';
|
|
6
|
+
export { RECEIPT_DIR } from './receipt-dir.js';
|
|
7
|
+
export { HostedApprovalDeclinedError, sanitizeApprovalIntent, APPROVAL_INTENT_MAX_CHARS, } from './hosted-approval.js';
|
|
8
|
+
export { createCardMandate, drawFromMandate, MandateDrawDeclinedError, DEFAULT_MANDATE_MAX_DRAWS, type CreateCardMandateInput, type CreateCardMandateDeps, type CardMandateFacts, type DrawFromMandateInput, type DrawFromMandateDeps, type DrawResult, type CardMandateMerchant, } from './mandate/card-mandate.js';
|
|
9
|
+
export { MandateLedger, remainingMinor, defaultLedgerPath, CARD_MANDATE_LEDGER_VERSION, type CardMandateRecord, type CardMandateLedgerFile, type CardMandateDraw, type CardMandateReservation, } from './mandate/mandate-ledger.js';
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
// Public API of @visa/checkout-engine. The pay_merchant tool in @visa/cli
|
|
2
2
|
// consumes createCliCheckoutEngine() through a structural seam; the core engine
|
|
3
3
|
// primitives are re-exported for direct/embedded use.
|
|
4
|
-
export { createCliCheckoutEngine, } from './cli-engine.js';
|
|
4
|
+
export { createCliCheckoutEngine, CheckoutReviewRefusedError, } from './cli-engine.js';
|
|
5
5
|
export { prepareCheckout, submitApprovedCheckout, runCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
|
|
6
|
+
export { readConfirmedMerchants, } from './confirmed-merchants.js';
|
|
7
|
+
export { KNOWN_MERCHANT_IDENTITIES } from './known-merchants.js';
|
|
8
|
+
export { RECEIPT_DIR } from './receipt-dir.js';
|
|
9
|
+
export { HostedApprovalDeclinedError, sanitizeApprovalIntent, APPROVAL_INTENT_MAX_CHARS, } from './hosted-approval.js';
|
|
10
|
+
export { createCardMandate, drawFromMandate, MandateDrawDeclinedError, DEFAULT_MANDATE_MAX_DRAWS, } from './mandate/card-mandate.js';
|
|
11
|
+
export { MandateLedger, remainingMinor, defaultLedgerPath, CARD_MANDATE_LEDGER_VERSION, } from './mandate/mandate-ledger.js';
|
|
@@ -6,6 +6,12 @@ export type CardCredential = {
|
|
|
6
6
|
cardholderName: string;
|
|
7
7
|
/** Dynamic payment credential expiry, distinct from the card expiration. */
|
|
8
8
|
credentialExpiresAt?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Non-sensitive, bounded request-correlation handles returned by the mint
|
|
11
|
+
* route. These are evidence pointers, not card credential material.
|
|
12
|
+
*/
|
|
13
|
+
vgsTraceId?: string;
|
|
14
|
+
networkCorrelationId?: string;
|
|
9
15
|
};
|
|
10
16
|
export type InstrumentContext = {
|
|
11
17
|
merchantHost: string;
|
|
@@ -24,6 +30,7 @@ export declare const OFFICIAL_TEST_PANS: {
|
|
|
24
30
|
readonly visaSlowConfirm: "4000000000000069";
|
|
25
31
|
readonly visaUnknownOutcome: "4000000000000044";
|
|
26
32
|
readonly visaChallenge: "4000000000000010";
|
|
33
|
+
readonly visaEmailOtp: "4000000000000077";
|
|
27
34
|
};
|
|
28
35
|
export type TestCardOptions = {
|
|
29
36
|
pan?: string;
|
|
@@ -22,6 +22,10 @@ export const OFFICIAL_TEST_PANS = {
|
|
|
22
22
|
// page embedding an ACS challenge iframe that never completes — exercises
|
|
23
23
|
// the observer's action-required (issuer challenge) path.
|
|
24
24
|
visaChallenge: '4000000000000010',
|
|
25
|
+
// Ending 0077: the fixture acquirer emails a one-time code to the agent
|
|
26
|
+
// inbox and shows a 'check your email for a code' page with a one-time-code
|
|
27
|
+
// field — exercises the executor's agent-resolvable email-OTP subroutine.
|
|
28
|
+
visaEmailOtp: '4000000000000077',
|
|
25
29
|
};
|
|
26
30
|
function futureExpiry() {
|
|
27
31
|
const now = new Date();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type MerchantIdentity = {
|
|
2
|
+
/** Human-recognizable merchant name. */
|
|
3
|
+
name: string;
|
|
4
|
+
/** What the merchant is, e.g. "charity (animal rescue)". */
|
|
5
|
+
category: string;
|
|
6
|
+
/** The merchant's own site, when known — not the checkout URL. */
|
|
7
|
+
website?: string;
|
|
8
|
+
};
|
|
9
|
+
/** Checkout URL → who is actually behind it. Confirmed live 2026-07/2026-08. */
|
|
10
|
+
export declare const KNOWN_MERCHANT_IDENTITIES: Readonly<Record<string, MerchantIdentity>>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Curated identities for checkout URLs the registry cannot name on its own.
|
|
2
|
+
//
|
|
3
|
+
// Hosted payment links are anonymous by construction: the page host is the
|
|
4
|
+
// PSP's (donate.stripe.com, buy.stripe.com), the path is an opaque token, and
|
|
5
|
+
// the receipt records nothing else. So a registry row for a completed payment
|
|
6
|
+
// link answers "where can I use my card?" with a string no human recognizes.
|
|
7
|
+
// This map carries the once-per-link human identification, keyed by the exact
|
|
8
|
+
// checkout URL the receipts group on.
|
|
9
|
+
//
|
|
10
|
+
// Curation rule: an entry is added only after a checkout at that URL completed
|
|
11
|
+
// for real (engine outcome 'confirmed' + VIC APPROVED — the same admission rule
|
|
12
|
+
// confirmed-merchants.ts enforces) and the merchant behind the link was
|
|
13
|
+
// identified by a person. The map never creates registry rows; it only names
|
|
14
|
+
// rows the device's own receipts already earned. A URL missing here still
|
|
15
|
+
// lists — it just shows as its host.
|
|
16
|
+
/** Checkout URL → who is actually behind it. Confirmed live 2026-07/2026-08. */
|
|
17
|
+
export const KNOWN_MERCHANT_IDENTITIES = {
|
|
18
|
+
'https://donate.stripe.com/3cscPcaqY20H8ta4gh': {
|
|
19
|
+
name: 'NC Seaside Animal Rescue',
|
|
20
|
+
category: 'charity (animal rescue, NC nonprofit)',
|
|
21
|
+
website: 'https://www.ncseasideanimalrescue.org/make-a-donation',
|
|
22
|
+
},
|
|
23
|
+
'https://donate.stripe.com/14k3ei9TYgwFclq145': {
|
|
24
|
+
name: 'FreeCAD',
|
|
25
|
+
category: 'open-source project donation (CAD software)',
|
|
26
|
+
website: 'https://www.freecad.org',
|
|
27
|
+
},
|
|
28
|
+
'https://buy.stripe.com/dR6eWGaK41MX2YgaEF': {
|
|
29
|
+
name: 'scribepod',
|
|
30
|
+
category: 'indie creator tip jar (AI podcast project)',
|
|
31
|
+
website: 'https://github.com/yacineMTB/scribepod',
|
|
32
|
+
},
|
|
33
|
+
'https://donate.stripe.com/7sIcNj9Gs4WO6d29AA': {
|
|
34
|
+
name: 'n0bleKing GRAFFITI',
|
|
35
|
+
category: 'indie creator tip jar (pixel art and fonts)',
|
|
36
|
+
website: 'https://nobleking-graffiti.itch.io',
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -1,30 +1,10 @@
|
|
|
1
1
|
import type { CheckoutMode, CheckoutOutcome, CheckoutResult } from './executor.js';
|
|
2
|
-
export declare const SUBMIT_ENV_FLAG = "CHECKOUT_AGENT_ALLOW_SUBMIT";
|
|
3
2
|
export declare const MODE_ENV_FLAG = "CHECKOUT_AGENT_MODE";
|
|
4
3
|
/**
|
|
5
4
|
* `--mode` values map 1:1 onto CheckoutMode; an absent flag falls back to the
|
|
6
5
|
* CHECKOUT_AGENT_MODE environment default, then to the safe dry-run default.
|
|
7
6
|
*/
|
|
8
7
|
export declare function parseCheckoutMode(value: string | undefined, env?: Record<string, string | undefined>): CheckoutMode;
|
|
9
|
-
/**
|
|
10
|
-
* Fail fast (before any file read or browser launch) when submit lacks an
|
|
11
|
-
* enablement. The env arm exists to stop AUTOMATION from reaching a live
|
|
12
|
-
* charge by accident — so it is required only when stdin is not a real TTY
|
|
13
|
-
* (scripts, agents, pipes). An interactive human needs no standing config:
|
|
14
|
-
* their per-run consent is the typed PAY phrase plus the passkey tap, both of
|
|
15
|
-
* which a non-interactive caller cannot fake (the phrase names the exact
|
|
16
|
-
* review id; the passkey is a FIDO ceremony on the verify site).
|
|
17
|
-
*/
|
|
18
|
-
export declare function assertSubmitAllowed(mode: CheckoutMode, env?: Record<string, string | undefined>, isInteractive?: boolean): void;
|
|
19
|
-
export declare const LEGACY_REFERENCE_ENV_FLAG = "CHECKOUT_AGENT_ALLOW_LEGACY_REFERENCE";
|
|
20
|
-
/**
|
|
21
|
-
* The legacy --reference-file path replays a PRE-CREATED intent, so the
|
|
22
|
-
* purchase-assurance gate (#5709) cannot vet the ceremony that backed it —
|
|
23
|
-
* scope/freshness problems surface only as the opaque cryptogram failure the
|
|
24
|
-
* gate exists to prevent. Default-off escape hatch, same shape as the submit
|
|
25
|
-
* gate: fail fast before any file is read.
|
|
26
|
-
*/
|
|
27
|
-
export declare function assertLegacyReferenceAllowed(usingReferenceFile: boolean, env?: Record<string, string | undefined>): void;
|
|
28
8
|
/** Distinct phrases per mode so dry-run muscle memory can never authorize a payment. */
|
|
29
9
|
export declare function approvalPhrase(mode: CheckoutMode, reviewId: string): string;
|
|
30
10
|
/** Integer-only minor→display formatting (two-decimal currencies, the same assumption as decimalToMinor). */
|
|
@@ -48,5 +28,10 @@ export declare function isRunSuccess(mode: CheckoutMode, outcome: CheckoutOutcom
|
|
|
48
28
|
* so no charge can exist until it is completed — the double-charge warning
|
|
49
29
|
* would misdirect the operator, and the runner prints the challenge-specific
|
|
50
30
|
* notice instead.
|
|
31
|
+
*
|
|
32
|
+
* The executor now NAMES this state in the outcome itself ('unverified'), so
|
|
33
|
+
* the two agree by construction. This predicate stays the broader guard: it
|
|
34
|
+
* also catches a post-click throw or any future outcome that leaves the same
|
|
35
|
+
* question open, and it is what the receipt's reconciliation list reads.
|
|
51
36
|
*/
|
|
52
37
|
export declare function submitClickedWithoutConfirmation(result: CheckoutResult): boolean;
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
// Deliberate-enablement seam for run-live-fill's submit mode. A real submit
|
|
2
2
|
// moves real money, so it requires:
|
|
3
|
-
// 1.
|
|
4
|
-
// the explicit CHECKOUT_AGENT_ALLOW_SUBMIT=1 environment arm,
|
|
5
|
-
// 2. the invocation opts in (--mode submit, or the CHECKOUT_AGENT_MODE
|
|
3
|
+
// 1. the invocation opts in (--mode submit, or the CHECKOUT_AGENT_MODE
|
|
6
4
|
// environment default for operators who always run one mode),
|
|
7
|
-
//
|
|
5
|
+
// 2. the operator types the PAY phrase against the exact review id (and a
|
|
8
6
|
// hosted run additionally requires the passkey tap on the verify site).
|
|
9
|
-
// The
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
7
|
+
// The PAY phrase differs from dry-run's FILL phrase and names the exact review
|
|
8
|
+
// id, so a muscle-memory rerun of the dry-run procedure cannot drift into
|
|
9
|
+
// charging a card. Fresh checkouts need ZERO persisted config.
|
|
10
|
+
//
|
|
11
|
+
// There was a third requirement — a CHECKOUT_AGENT_ALLOW_SUBMIT=1 environment
|
|
12
|
+
// arm, exempted for interactive TTYs — meant to stop AUTOMATION reaching a live
|
|
13
|
+
// charge. It never ran: nothing in the product ever called `assertSubmitAllowed`.
|
|
14
|
+
// The same flag DID gate `pay_merchant`, where being default-off with no
|
|
15
|
+
// in-product way to set it meant a real install could not complete a purchase at
|
|
16
|
+
// all. Both are gone; the phrase and the mandate caps are the gates.
|
|
16
17
|
export const MODE_ENV_FLAG = 'CHECKOUT_AGENT_MODE';
|
|
17
18
|
/**
|
|
18
19
|
* `--mode` values map 1:1 onto CheckoutMode; an absent flag falls back to the
|
|
@@ -26,46 +27,9 @@ export function parseCheckoutMode(value, env = process.env) {
|
|
|
26
27
|
return 'submit';
|
|
27
28
|
throw new Error(`--mode (or ${MODE_ENV_FLAG}) must be dry-run or submit (got ${JSON.stringify(effective)})`);
|
|
28
29
|
}
|
|
29
|
-
/**
|
|
30
|
-
* Fail fast (before any file read or browser launch) when submit lacks an
|
|
31
|
-
* enablement. The env arm exists to stop AUTOMATION from reaching a live
|
|
32
|
-
* charge by accident — so it is required only when stdin is not a real TTY
|
|
33
|
-
* (scripts, agents, pipes). An interactive human needs no standing config:
|
|
34
|
-
* their per-run consent is the typed PAY phrase plus the passkey tap, both of
|
|
35
|
-
* which a non-interactive caller cannot fake (the phrase names the exact
|
|
36
|
-
* review id; the passkey is a FIDO ceremony on the verify site).
|
|
37
|
-
*/
|
|
38
|
-
export function assertSubmitAllowed(mode, env = process.env, isInteractive = process.stdin.isTTY === true) {
|
|
39
|
-
if (mode !== 'submit')
|
|
40
|
-
return;
|
|
41
|
-
if (env[SUBMIT_ENV_FLAG] === '1')
|
|
42
|
-
return;
|
|
43
|
-
if (isInteractive)
|
|
44
|
-
return;
|
|
45
|
-
throw new Error(`--mode submit refused: this is not an interactive terminal, and scripted submits ` +
|
|
46
|
-
`require ${SUBMIT_ENV_FLAG}=1. The gate is default-off so automation cannot ` +
|
|
47
|
-
'reach a live charge by accident.');
|
|
48
|
-
}
|
|
49
|
-
export const LEGACY_REFERENCE_ENV_FLAG = 'CHECKOUT_AGENT_ALLOW_LEGACY_REFERENCE';
|
|
50
|
-
/**
|
|
51
|
-
* The legacy --reference-file path replays a PRE-CREATED intent, so the
|
|
52
|
-
* purchase-assurance gate (#5709) cannot vet the ceremony that backed it —
|
|
53
|
-
* scope/freshness problems surface only as the opaque cryptogram failure the
|
|
54
|
-
* gate exists to prevent. Default-off escape hatch, same shape as the submit
|
|
55
|
-
* gate: fail fast before any file is read.
|
|
56
|
-
*/
|
|
57
|
-
export function assertLegacyReferenceAllowed(usingReferenceFile, env = process.env) {
|
|
58
|
-
if (!usingReferenceFile)
|
|
59
|
-
return;
|
|
60
|
-
if (env[LEGACY_REFERENCE_ENV_FLAG] !== '1') {
|
|
61
|
-
throw new Error(`--reference-file refused: this legacy path bypasses purchase-assurance validation ` +
|
|
62
|
-
`(#5709) — prefer --checkout-file with --purchase-assurance-file. Set ` +
|
|
63
|
-
`${LEGACY_REFERENCE_ENV_FLAG}=1 to proceed anyway.`);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
30
|
/** Distinct phrases per mode so dry-run muscle memory can never authorize a payment. */
|
|
67
31
|
export function approvalPhrase(mode, reviewId) {
|
|
68
|
-
return `${mode === 'submit' ? 'PAY' : '
|
|
32
|
+
return `${mode === 'submit' ? 'PAY' : 'CHECK'} ${reviewId}`;
|
|
69
33
|
}
|
|
70
34
|
/** Integer-only minor→display formatting (two-decimal currencies, the same assumption as decimalToMinor). */
|
|
71
35
|
export function formatAmountMinor(amountMinor, currency) {
|
|
@@ -79,11 +43,11 @@ export function approvalQuestion(mode, review, phrase) {
|
|
|
79
43
|
`Type ${JSON.stringify(phrase)} to disclose the credential AND submit the order: `);
|
|
80
44
|
}
|
|
81
45
|
return (`Inspect the visible checkout. Type ${JSON.stringify(phrase)} ` +
|
|
82
|
-
`to
|
|
46
|
+
`to validate the reviewed page without minting, filling, or submitting a credential: `);
|
|
83
47
|
}
|
|
84
48
|
/** The one outcome each mode may exit 0 with. */
|
|
85
49
|
export function isRunSuccess(mode, outcome) {
|
|
86
|
-
return outcome === (mode === 'submit' ? 'confirmed' : '
|
|
50
|
+
return outcome === (mode === 'submit' ? 'confirmed' : 'reviewed-dry-run');
|
|
87
51
|
}
|
|
88
52
|
/**
|
|
89
53
|
* True when the pay control was actually clicked but the run ended with an
|
|
@@ -96,6 +60,11 @@ export function isRunSuccess(mode, outcome) {
|
|
|
96
60
|
* so no charge can exist until it is completed — the double-charge warning
|
|
97
61
|
* would misdirect the operator, and the runner prints the challenge-specific
|
|
98
62
|
* notice instead.
|
|
63
|
+
*
|
|
64
|
+
* The executor now NAMES this state in the outcome itself ('unverified'), so
|
|
65
|
+
* the two agree by construction. This predicate stays the broader guard: it
|
|
66
|
+
* also catches a post-click throw or any future outcome that leaves the same
|
|
67
|
+
* question open, and it is what the receipt's reconciliation list reads.
|
|
99
68
|
*/
|
|
100
69
|
export function submitClickedWithoutConfirmation(result) {
|
|
101
70
|
if (result.outcome === 'confirmed' ||
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { type VgsCheckoutTarget, type VgsPaymentCredential } from '../vgs-live-instrument.js';
|
|
2
|
+
import { MandateLedger } from './mandate-ledger.js';
|
|
3
|
+
/**
|
|
4
|
+
* Default number of draws a ceiling intent may fulfil (VGS mandate quantity).
|
|
5
|
+
* This is a purchase-COUNT cap, NOT the exposure bound: VGS enforces the
|
|
6
|
+
* declineThreshold as a CUMULATIVE cap across draws (live-verified 2026-07-23),
|
|
7
|
+
* so the total the network will authorize is the ceiling REGARDLESS of how many
|
|
8
|
+
* draws it is spread across — maxDraws only limits the number of separate
|
|
9
|
+
* purchases, it does not multiply the exposure. Kept SMALL as a sensible default
|
|
10
|
+
* purchase count (the owner-side client ledger also caps cumulative spend at ONE
|
|
11
|
+
* ceiling). This is the single default; callers that don't set maxDraws inherit
|
|
12
|
+
* it (do not re-duplicate it upstream).
|
|
13
|
+
*/
|
|
14
|
+
export declare const DEFAULT_MANDATE_MAX_DRAWS = 3;
|
|
15
|
+
export type CardMandateMerchant = {
|
|
16
|
+
name: string;
|
|
17
|
+
url: string;
|
|
18
|
+
countryCode: string;
|
|
19
|
+
};
|
|
20
|
+
export type CreateCardMandateInput = {
|
|
21
|
+
/** Exact current runtime request key selected before human approval. */
|
|
22
|
+
agentJkt: string;
|
|
23
|
+
tokenId: string;
|
|
24
|
+
assuranceData: unknown;
|
|
25
|
+
ceilingMinor: number;
|
|
26
|
+
merchant: CardMandateMerchant;
|
|
27
|
+
currencyCode: string;
|
|
28
|
+
expiresAt: string;
|
|
29
|
+
/** Max draws the ceiling intent may fulfil; defaults to DEFAULT_MANDATE_MAX_DRAWS. */
|
|
30
|
+
maxDraws?: number;
|
|
31
|
+
/** Cross-host retail budget; network category eligibility still applies. */
|
|
32
|
+
crossMerchant?: boolean;
|
|
33
|
+
};
|
|
34
|
+
export type CreateIntentFn = (input: {
|
|
35
|
+
tokenId: string;
|
|
36
|
+
assuranceData: unknown;
|
|
37
|
+
transaction: VgsCheckoutTarget;
|
|
38
|
+
mandate: {
|
|
39
|
+
declineThresholdAmount: string;
|
|
40
|
+
quantity: number;
|
|
41
|
+
effectiveUntil: string;
|
|
42
|
+
consumerPrompt: string;
|
|
43
|
+
};
|
|
44
|
+
}) => Promise<{
|
|
45
|
+
intentId: string;
|
|
46
|
+
status: string | null;
|
|
47
|
+
}>;
|
|
48
|
+
export type CreateCardMandateDeps = {
|
|
49
|
+
/** Mint the ceiling intent — wire serverCreateIntent(base, mintToken, …). */
|
|
50
|
+
createIntent: CreateIntentFn;
|
|
51
|
+
ledger: MandateLedger;
|
|
52
|
+
/** verify-web origin the intent is minted against (persisted for later draws). */
|
|
53
|
+
approvalBaseUrl: string;
|
|
54
|
+
/** @deprecated Accepted from older callers but deliberately never persisted. */
|
|
55
|
+
mintToken?: string;
|
|
56
|
+
now?: () => Date;
|
|
57
|
+
};
|
|
58
|
+
export type CardMandateFacts = {
|
|
59
|
+
mandateId: string;
|
|
60
|
+
ceilingMinor: number;
|
|
61
|
+
remainingMinor: number;
|
|
62
|
+
currencyCode: string;
|
|
63
|
+
merchant: CardMandateMerchant;
|
|
64
|
+
expiresAt: string;
|
|
65
|
+
/** Network-enforced maximum purchase count disclosed at approval. */
|
|
66
|
+
maxDraws: number;
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Create a card mandate: approve a spend CEILING once (the passkey assurance
|
|
70
|
+
* passed here was scoped to the ceiling + merchant), mint one VGS intent with
|
|
71
|
+
* that ceiling as its decline threshold, and record the cumulative-budget
|
|
72
|
+
* ledger entry. Integer minor units only.
|
|
73
|
+
*/
|
|
74
|
+
export declare function createCardMandate(input: CreateCardMandateInput, deps: CreateCardMandateDeps): Promise<CardMandateFacts>;
|
|
75
|
+
export type FetchCryptogramFn = (input: {
|
|
76
|
+
tokenId: string;
|
|
77
|
+
intentId: string;
|
|
78
|
+
transaction: VgsCheckoutTarget;
|
|
79
|
+
}) => Promise<VgsPaymentCredential>;
|
|
80
|
+
export type DrawFromMandateInput = {
|
|
81
|
+
mandateId: string;
|
|
82
|
+
amountMinor: number;
|
|
83
|
+
/** The exact per-draw checkout target (merchant + amount + currency). */
|
|
84
|
+
transaction: VgsCheckoutTarget;
|
|
85
|
+
};
|
|
86
|
+
export type DrawFromMandateDeps = {
|
|
87
|
+
/** Draw a cryptogram against the mandate intent — wire serverFetchCryptogram. */
|
|
88
|
+
fetchCryptogram: FetchCryptogramFn;
|
|
89
|
+
ledger: MandateLedger;
|
|
90
|
+
/** Override the payable-credential gate (default validateCredential). */
|
|
91
|
+
validate?: (credential: VgsPaymentCredential) => void;
|
|
92
|
+
now?: () => Date;
|
|
93
|
+
};
|
|
94
|
+
export type DrawResult = {
|
|
95
|
+
payment: VgsPaymentCredential;
|
|
96
|
+
intentId: string;
|
|
97
|
+
remainingMinor: number;
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Thrown ONLY for the post-reservation network/validation rejection class — the
|
|
101
|
+
* cryptogram fetch failed or returned a non-payable credential (the DRAW_REMEDY
|
|
102
|
+
* path). This is the sole draw failure that should DISABLE the mandate: it means
|
|
103
|
+
* the acquirer would not honor a ceiling-scoped assurance for a sub-amount draw.
|
|
104
|
+
*
|
|
105
|
+
* Pre-network failures — a fail-closed `reserve()` (concurrent over-budget race
|
|
106
|
+
* or expiry) or a `commit()` file-I/O error — throw a plain Error instead, so
|
|
107
|
+
* the caller (cli-engine) can rethrow them WITHOUT marking a HEALTHY mandate
|
|
108
|
+
* unhonored. Discriminating on this type is what keeps a transient race from
|
|
109
|
+
* permanently disabling a good mandate (michaelyang1 M1).
|
|
110
|
+
*/
|
|
111
|
+
export declare class MandateDrawDeclinedError extends Error {
|
|
112
|
+
readonly cause?: unknown;
|
|
113
|
+
constructor(message: string, cause?: unknown);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Draw a cryptogram against an existing card mandate with NO fresh tap. The
|
|
117
|
+
* reservation is atomic and the budget is decremented ONLY after a payable
|
|
118
|
+
* cryptogram returns; any rejection releases the reservation so remaining is
|
|
119
|
+
* unchanged. Validates merchant + currency + amount against the mandate first.
|
|
120
|
+
*/
|
|
121
|
+
export declare function drawFromMandate(input: DrawFromMandateInput, deps: DrawFromMandateDeps): Promise<DrawResult>;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// Card mandate (budget) layer — the split the north-star card flow needs:
|
|
2
|
+
//
|
|
3
|
+
// BUDGET = start_card_mandate: approve a CEILING once with the passkey. This
|
|
4
|
+
// mints ONE VGS intent whose decline threshold is the ceiling (not
|
|
5
|
+
// amount+10) and whose quantity permits many draws. The passkey
|
|
6
|
+
// assurance is scoped to the ceiling + merchant, not one charge.
|
|
7
|
+
// DRAW = pay_merchant (tap-free): pull a cryptogram against that intent for
|
|
8
|
+
// an in-budget purchase, WITHOUT a fresh tap, and decrement the
|
|
9
|
+
// cumulative budget in the owner-only ledger.
|
|
10
|
+
//
|
|
11
|
+
// This mirrors x402's start_session -> wallet_pay shape on the card rail.
|
|
12
|
+
//
|
|
13
|
+
// PROVEN (live probe 2026-07-23): VGS honors a ceiling-scoped assurance across
|
|
14
|
+
// multiple sub-amount cryptogram draws against one intent, AND enforces the
|
|
15
|
+
// decline threshold as a CUMULATIVE cap — draws summing to the ceiling are
|
|
16
|
+
// payable, the next draw over the cumulative ceiling is declined. So the total
|
|
17
|
+
// the network authorizes is the ceiling, not maxDraws × ceiling. drawFromMandate
|
|
18
|
+
// stays FAIL-CLEAN as defense-in-depth: the budget is decremented only after a
|
|
19
|
+
// payable cryptogram returns; a network rejection leaves the budget untouched.
|
|
20
|
+
// The caller (cli-engine) then marks the mandate unhonored so the NEXT
|
|
21
|
+
// pay_merchant skips it and surfaces the no-covering refusal (#7348) — the same call is
|
|
22
|
+
// not retried in-flight. Never fail-open.
|
|
23
|
+
import { decimalToMinor, minorToDecimal, validateCredential, } from '../vgs-live-instrument.js';
|
|
24
|
+
import { CARD_MANDATE_LEDGER_VERSION, remainingMinor, } from './mandate-ledger.js';
|
|
25
|
+
/**
|
|
26
|
+
* Default number of draws a ceiling intent may fulfil (VGS mandate quantity).
|
|
27
|
+
* This is a purchase-COUNT cap, NOT the exposure bound: VGS enforces the
|
|
28
|
+
* declineThreshold as a CUMULATIVE cap across draws (live-verified 2026-07-23),
|
|
29
|
+
* so the total the network will authorize is the ceiling REGARDLESS of how many
|
|
30
|
+
* draws it is spread across — maxDraws only limits the number of separate
|
|
31
|
+
* purchases, it does not multiply the exposure. Kept SMALL as a sensible default
|
|
32
|
+
* purchase count (the owner-side client ledger also caps cumulative spend at ONE
|
|
33
|
+
* ceiling). This is the single default; callers that don't set maxDraws inherit
|
|
34
|
+
* it (do not re-duplicate it upstream).
|
|
35
|
+
*/
|
|
36
|
+
export const DEFAULT_MANDATE_MAX_DRAWS = 3;
|
|
37
|
+
function assertPositiveInteger(value, label) {
|
|
38
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
39
|
+
throw new Error(`${label} must be a positive integer (minor units)`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function merchantHost(url) {
|
|
43
|
+
try {
|
|
44
|
+
return new URL(url).hostname;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new Error(`mandate merchant URL is invalid: ${url}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Create a card mandate: approve a spend CEILING once (the passkey assurance
|
|
52
|
+
* passed here was scoped to the ceiling + merchant), mint one VGS intent with
|
|
53
|
+
* that ceiling as its decline threshold, and record the cumulative-budget
|
|
54
|
+
* ledger entry. Integer minor units only.
|
|
55
|
+
*/
|
|
56
|
+
export async function createCardMandate(input, deps) {
|
|
57
|
+
assertPositiveInteger(input.ceilingMinor, 'mandate ceiling');
|
|
58
|
+
const currencyCode = String(input.currencyCode).toUpperCase();
|
|
59
|
+
if (!/^[A-Z]{3}$/.test(currencyCode)) {
|
|
60
|
+
throw new Error('mandate currency must be a 3-letter ISO 4217 code');
|
|
61
|
+
}
|
|
62
|
+
if (!input.tokenId?.trim())
|
|
63
|
+
throw new Error('mandate requires a tokenId');
|
|
64
|
+
if (!/^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(input.agentJkt)) {
|
|
65
|
+
throw new Error('mandate requires a canonical agent request-key JKT');
|
|
66
|
+
}
|
|
67
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
68
|
+
const expiryMs = Date.parse(input.expiresAt);
|
|
69
|
+
if (!Number.isFinite(expiryMs))
|
|
70
|
+
throw new Error('mandate expiresAt must be ISO 8601');
|
|
71
|
+
if (expiryMs <= now.getTime())
|
|
72
|
+
throw new Error('mandate expiresAt is already in the past');
|
|
73
|
+
const host = merchantHost(input.merchant.url);
|
|
74
|
+
const maxDraws = input.maxDraws ?? DEFAULT_MANDATE_MAX_DRAWS;
|
|
75
|
+
if (!Number.isSafeInteger(maxDraws) || maxDraws < 1) {
|
|
76
|
+
throw new Error('mandate maxDraws must be a positive integer');
|
|
77
|
+
}
|
|
78
|
+
// Pass the currency so a non-2-decimal currency is REFUSED here rather than
|
|
79
|
+
// silently rendered with a wrong wire amount (minorToDecimal assumes /100).
|
|
80
|
+
const ceilingDecimal = minorToDecimal(input.ceilingMinor, currencyCode);
|
|
81
|
+
// The passkey prompt must state the REAL grant. VGS enforces the
|
|
82
|
+
// declineThreshold as a CUMULATIVE cap across draws (live-verified 2026-07-23),
|
|
83
|
+
// so the total this single approval can authorize is the CEILING — not
|
|
84
|
+
// maxDraws × ceiling. maxDraws only caps how many separate purchases the intent
|
|
85
|
+
// may fulfil. The prompt therefore names the ceiling as the total and maxDraws
|
|
86
|
+
// as the purchase-count limit (H1: consent must match the true grant).
|
|
87
|
+
// The intent transaction is scoped to the CEILING itself: the decline
|
|
88
|
+
// threshold IS the ceiling (not the historical ceil(amount)+10), and quantity
|
|
89
|
+
// permits multiple draws.
|
|
90
|
+
const ceilingTarget = {
|
|
91
|
+
merchantName: input.merchant.name,
|
|
92
|
+
merchantUrl: input.merchant.url,
|
|
93
|
+
merchantCountryCode: input.merchant.countryCode.toUpperCase(),
|
|
94
|
+
transactionAmount: ceilingDecimal,
|
|
95
|
+
transactionCurrencyCode: currencyCode,
|
|
96
|
+
};
|
|
97
|
+
const { intentId } = await deps.createIntent({
|
|
98
|
+
tokenId: input.tokenId,
|
|
99
|
+
assuranceData: input.assuranceData,
|
|
100
|
+
transaction: ceilingTarget,
|
|
101
|
+
mandate: {
|
|
102
|
+
declineThresholdAmount: ceilingDecimal,
|
|
103
|
+
quantity: maxDraws,
|
|
104
|
+
effectiveUntil: new Date(expiryMs).toISOString(),
|
|
105
|
+
consumerPrompt: `Approve up to ${ceilingDecimal} ${currencyCode} total (across up to ` +
|
|
106
|
+
`${maxDraws} purchase${maxDraws === 1 ? '' : 's'}) at ${input.merchant.name} ` +
|
|
107
|
+
`for my Visa agent`,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
if (!intentId?.trim())
|
|
111
|
+
throw new Error('mandate intent creation returned no intentId');
|
|
112
|
+
const record = {
|
|
113
|
+
version: CARD_MANDATE_LEDGER_VERSION,
|
|
114
|
+
mandateId: intentId,
|
|
115
|
+
agentJkt: input.agentJkt,
|
|
116
|
+
tokenId: input.tokenId,
|
|
117
|
+
ceilingMinor: input.ceilingMinor,
|
|
118
|
+
spentMinor: 0,
|
|
119
|
+
reservations: [],
|
|
120
|
+
currencyCode,
|
|
121
|
+
merchantName: input.merchant.name,
|
|
122
|
+
merchantUrl: input.merchant.url,
|
|
123
|
+
merchantHost: host,
|
|
124
|
+
merchantCountryCode: input.merchant.countryCode.toUpperCase(),
|
|
125
|
+
approvalBaseUrl: deps.approvalBaseUrl,
|
|
126
|
+
expiresAt: new Date(expiryMs).toISOString(),
|
|
127
|
+
maxDraws,
|
|
128
|
+
createdAt: now.toISOString(),
|
|
129
|
+
draws: [],
|
|
130
|
+
...(input.crossMerchant ? { crossMerchant: true } : {}),
|
|
131
|
+
};
|
|
132
|
+
await deps.ledger.create(record, now);
|
|
133
|
+
return {
|
|
134
|
+
mandateId: record.mandateId,
|
|
135
|
+
ceilingMinor: record.ceilingMinor,
|
|
136
|
+
remainingMinor: remainingMinor(record),
|
|
137
|
+
currencyCode,
|
|
138
|
+
merchant: input.merchant,
|
|
139
|
+
expiresAt: record.expiresAt,
|
|
140
|
+
maxDraws,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const DRAW_REMEDY = 'the card-mandate draw did not complete — a ceiling-scoped assurance may not be honored ' +
|
|
144
|
+
'for this sub-amount draw. The budget is untouched. If this was a transient network error ' +
|
|
145
|
+
'(gateway 5xx / try again), retry — the mandate stays active; a persistent decline disables ' +
|
|
146
|
+
'the mandate; a v4 card grant then requires a new mandate before another checkout';
|
|
147
|
+
/**
|
|
148
|
+
* Thrown ONLY for the post-reservation network/validation rejection class — the
|
|
149
|
+
* cryptogram fetch failed or returned a non-payable credential (the DRAW_REMEDY
|
|
150
|
+
* path). This is the sole draw failure that should DISABLE the mandate: it means
|
|
151
|
+
* the acquirer would not honor a ceiling-scoped assurance for a sub-amount draw.
|
|
152
|
+
*
|
|
153
|
+
* Pre-network failures — a fail-closed `reserve()` (concurrent over-budget race
|
|
154
|
+
* or expiry) or a `commit()` file-I/O error — throw a plain Error instead, so
|
|
155
|
+
* the caller (cli-engine) can rethrow them WITHOUT marking a HEALTHY mandate
|
|
156
|
+
* unhonored. Discriminating on this type is what keeps a transient race from
|
|
157
|
+
* permanently disabling a good mandate (michaelyang1 M1).
|
|
158
|
+
*/
|
|
159
|
+
export class MandateDrawDeclinedError extends Error {
|
|
160
|
+
cause;
|
|
161
|
+
constructor(message, cause) {
|
|
162
|
+
super(message);
|
|
163
|
+
this.name = 'MandateDrawDeclinedError';
|
|
164
|
+
this.cause = cause;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Draw a cryptogram against an existing card mandate with NO fresh tap. The
|
|
169
|
+
* reservation is atomic and the budget is decremented ONLY after a payable
|
|
170
|
+
* cryptogram returns; any rejection releases the reservation so remaining is
|
|
171
|
+
* unchanged. Validates merchant + currency + amount against the mandate first.
|
|
172
|
+
*/
|
|
173
|
+
export async function drawFromMandate(input, deps) {
|
|
174
|
+
assertPositiveInteger(input.amountMinor, 'draw amount');
|
|
175
|
+
const validate = deps.validate ?? validateCredential;
|
|
176
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
177
|
+
const record = await deps.ledger.get(input.mandateId);
|
|
178
|
+
if (!record)
|
|
179
|
+
throw new Error(`no such mandate ${input.mandateId}`);
|
|
180
|
+
// The wire amount and the accounting amount MUST agree — a mismatch would
|
|
181
|
+
// draw one figure while reserving another. Fail before any reservation.
|
|
182
|
+
const wireMinor = decimalToMinor(input.transaction.transactionAmount);
|
|
183
|
+
if (wireMinor === null || wireMinor !== input.amountMinor) {
|
|
184
|
+
throw new Error(`draw amount ${input.amountMinor} disagrees with transaction amount ` +
|
|
185
|
+
`${JSON.stringify(input.transaction.transactionAmount)} (minor units)`);
|
|
186
|
+
}
|
|
187
|
+
// Currency must always match; merchant must match UNLESS this is a budget
|
|
188
|
+
// (crossMerchant) mandate the owner approved for eligible retail merchants. The network
|
|
189
|
+
// honors a ceiling-scoped cryptogram cross-merchant, so a budget mandate is
|
|
190
|
+
// deliberately not host-restricted — the ceiling + per-transaction limit still
|
|
191
|
+
// bound it.
|
|
192
|
+
const targetHost = merchantHost(input.transaction.merchantUrl);
|
|
193
|
+
if (!record.crossMerchant && targetHost !== record.merchantHost) {
|
|
194
|
+
throw new Error(`draw merchant ${targetHost} does not match mandate merchant ${record.merchantHost}`);
|
|
195
|
+
}
|
|
196
|
+
if (String(input.transaction.transactionCurrencyCode).toUpperCase() !==
|
|
197
|
+
record.currencyCode.toUpperCase()) {
|
|
198
|
+
throw new Error(`draw currency ${input.transaction.transactionCurrencyCode} does not match mandate ` +
|
|
199
|
+
`currency ${record.currencyCode}`);
|
|
200
|
+
}
|
|
201
|
+
// Atomic reserve — throws (fail-closed) if expired or over remaining budget.
|
|
202
|
+
const reservationId = await deps.ledger.reserve(input.mandateId, input.amountMinor, now);
|
|
203
|
+
let payment;
|
|
204
|
+
try {
|
|
205
|
+
payment = await deps.fetchCryptogram({
|
|
206
|
+
tokenId: record.tokenId,
|
|
207
|
+
intentId: record.mandateId,
|
|
208
|
+
transaction: input.transaction,
|
|
209
|
+
});
|
|
210
|
+
// A 2xx that is not actually a payable DAVV must NOT decrement the budget.
|
|
211
|
+
validate(payment);
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
await deps.ledger.release(input.mandateId, reservationId, now);
|
|
215
|
+
// Typed so the caller disables the mandate ONLY for this post-reservation
|
|
216
|
+
// network/validation decline — never for a pre-network reserve()/commit()
|
|
217
|
+
// failure, which throws a plain Error above/below this try.
|
|
218
|
+
throw new MandateDrawDeclinedError(`${DRAW_REMEDY}: ${err.message}`, err);
|
|
219
|
+
}
|
|
220
|
+
const committed = await deps.ledger.commit(input.mandateId, reservationId, { intentId: record.mandateId }, now);
|
|
221
|
+
return {
|
|
222
|
+
payment,
|
|
223
|
+
intentId: record.mandateId,
|
|
224
|
+
remainingMinor: remainingMinor(committed),
|
|
225
|
+
};
|
|
226
|
+
}
|