@visa/cli 4.1.0-rc.26 → 4.1.0-rc.261
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 +202 -46
- 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 +80 -0
- package/dist/checkout-engine/adapters/shopify.js +688 -0
- package/dist/checkout-engine/amount.d.ts +15 -0
- package/dist/checkout-engine/amount.js +72 -0
- package/dist/checkout-engine/browser-launch.d.ts +9 -4
- package/dist/checkout-engine/browser-launch.js +19 -4
- package/dist/checkout-engine/browserbase-browser.d.ts +24 -0
- package/dist/checkout-engine/browserbase-browser.js +186 -0
- package/dist/checkout-engine/cli-engine.d.ts +206 -32
- package/dist/checkout-engine/cli-engine.js +772 -216
- 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 +6 -0
- package/dist/checkout-engine/evidence.d.ts +1 -1
- package/dist/checkout-engine/executor.d.ts +61 -3
- package/dist/checkout-engine/executor.js +550 -144
- package/dist/checkout-engine/hosted-approval.d.ts +69 -9
- package/dist/checkout-engine/hosted-approval.js +211 -21
- package/dist/checkout-engine/index.d.ts +6 -2
- package/dist/checkout-engine/index.js +5 -1
- package/dist/checkout-engine/instrument.d.ts +6 -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 -11
- package/dist/checkout-engine/live-fill-approval.js +20 -34
- package/dist/checkout-engine/mandate/card-mandate.d.ts +6 -2
- package/dist/checkout-engine/mandate/card-mandate.js +10 -5
- package/dist/checkout-engine/mandate/mandate-ledger.d.ts +63 -23
- package/dist/checkout-engine/mandate/mandate-ledger.js +124 -17
- 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 +56 -2
- package/dist/checkout-engine/receipt.js +55 -16
- package/dist/checkout-engine/shopify-primary-domain.d.ts +25 -0
- package/dist/checkout-engine/shopify-primary-domain.js +96 -0
- 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 +15 -2
- package/dist/checkout-engine/unresolved-charges.d.ts +34 -0
- package/dist/checkout-engine/unresolved-charges.js +134 -0
- package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +26 -7
- package/dist/checkout-engine/vgs-gateway/server-mint-client.js +43 -3
- package/dist/checkout-engine/vgs-live-instrument.d.ts +11 -35
- package/dist/checkout-engine/vgs-live-instrument.js +14 -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 +98 -0
- package/dist/checkout-engine/web-bot-auth.js +218 -0
- package/dist/cli.js +899 -387
- package/dist/mcp-apps/ucp-checkout.html +280 -0
- package/dist/mcp-server/index.js +725 -254
- package/dist/merchant-ucp-mcp/index.js +6 -0
- package/dist/skills/pair-visa-agent/RUNTIMES.md +122 -79
- package/dist/skills/pair-visa-agent/SKILL.md +433 -318
- package/dist/skills/pair-visa-agent/scripts/__tests__/setup.test.mjs +407 -0
- package/dist/skills/pair-visa-agent/scripts/setup.mjs +310 -30
- package/dist/skills/visa-shopify-checkout/SKILL.md +122 -0
- package/dist/skills/visa-shopify-checkout/references/evidence-and-states.md +37 -0
- package/dist/skills/visa-ucp-shopping/SKILL.md +86 -0
- package/dist/subway-direct.mjs +1 -0
- package/install.ps1 +7 -6
- package/install.sh +3 -3
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +31 -28
- 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 -493
- package/dist/checkout-engine/vgs-gateway/fetch-credential.d.mts +0 -74
- package/dist/checkout-engine/vgs-gateway/fetch-credential.mjs +0 -248
|
@@ -34,17 +34,24 @@ export type HostedApprovalOptions = {
|
|
|
34
34
|
/** Prefills the approval page's enrollment-email field (optional). */
|
|
35
35
|
consumerEmail?: string;
|
|
36
36
|
/**
|
|
37
|
-
* BUDGET/mandate approval
|
|
38
|
-
*
|
|
39
|
-
*
|
|
37
|
+
* BUDGET/mandate approval — the only kind since #7348 retired the
|
|
38
|
+
* single-purchase plane: `target.transactionAmount` is the approved spend
|
|
39
|
+
* CEILING (not one charge), so the server mints a BUDGET mint token that
|
|
40
|
+
* later accepts many sub-ceiling draws tap-free.
|
|
40
41
|
*/
|
|
41
|
-
budget
|
|
42
|
-
/** Advisory max draws the ceiling intent may fulfil — carried onto the token. */
|
|
43
|
-
maxDraws?: number;
|
|
42
|
+
budget: true;
|
|
44
43
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
44
|
+
* Exact current request-key thumbprint receiving the budget; the
|
|
45
|
+
* authenticated page resolves it to the owner's stable agent before
|
|
46
|
+
* displaying or signing.
|
|
47
|
+
*/
|
|
48
|
+
agentJkt: string;
|
|
49
|
+
/** Max draws the ceiling intent may fulfil — carried onto the token. */
|
|
50
|
+
maxDraws: number;
|
|
51
|
+
/**
|
|
52
|
+
* Per-purchase cap (decimal string, <= the ceiling) — shown on the approval
|
|
53
|
+
* page as a worst-case term and carried onto the mint token so the displayed
|
|
54
|
+
* cap is the enforced cap.
|
|
48
55
|
*/
|
|
49
56
|
perTransaction?: string;
|
|
50
57
|
/**
|
|
@@ -132,4 +139,57 @@ export declare function assertApprovalBaseUrl(value: string): string;
|
|
|
132
139
|
*/
|
|
133
140
|
export declare function runHostedApproval(opts: HostedApprovalOptions): Promise<PurchaseAssurance & {
|
|
134
141
|
mintToken?: string;
|
|
142
|
+
validUntil?: number;
|
|
135
143
|
}>;
|
|
144
|
+
/**
|
|
145
|
+
* A panel-initiated mandate handoff, released by its single-use pickup code.
|
|
146
|
+
* The context here is SERVER truth (an owner-authenticated panel session parked
|
|
147
|
+
* it, and the code is the credential that releases it) — the runner had no part
|
|
148
|
+
* in registering it, so unlike {@link runHostedApproval} the fields are
|
|
149
|
+
* returned for the caller to act on rather than compared against a local run.
|
|
150
|
+
* What IS verified here is the binding: the entry must be a budget approval
|
|
151
|
+
* minted for exactly THIS runtime's request key and THIS device's card token.
|
|
152
|
+
*/
|
|
153
|
+
export type MandatePickupClaim = {
|
|
154
|
+
/** Null only when {@link assuranceExempt} — a code-verified card runs no ceremony. */
|
|
155
|
+
assuranceData: unknown;
|
|
156
|
+
assuranceExempt: boolean;
|
|
157
|
+
mintToken: string;
|
|
158
|
+
/** Unix seconds — the mandate lapse the owner approved in the panel. */
|
|
159
|
+
validUntil: number;
|
|
160
|
+
/** Decimal ceiling string exactly as approved (the context's `amount`). */
|
|
161
|
+
ceiling: string;
|
|
162
|
+
currency: string;
|
|
163
|
+
maxDraws: number;
|
|
164
|
+
perTransaction?: string;
|
|
165
|
+
merchant: {
|
|
166
|
+
name: string;
|
|
167
|
+
url: string;
|
|
168
|
+
countryCode: string;
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
export type ClaimMandatePickupOptions = {
|
|
172
|
+
/** The enrollment web deployment origin (apps/web). */
|
|
173
|
+
baseUrl: string;
|
|
174
|
+
/** Single-use pickup code (the approval verifier) the owner handed over. */
|
|
175
|
+
pickupCode: string;
|
|
176
|
+
/** This runtime's exact current request-key JKT — the only key the code may fund. */
|
|
177
|
+
agentJkt: string;
|
|
178
|
+
/**
|
|
179
|
+
* The agent handoff this runtime just redeemed, when the code came from one.
|
|
180
|
+
* A budget minted in the Add-agent dialog exists before the agent does, so it
|
|
181
|
+
* pins the handoff rather than a key; this is the value that pin is compared
|
|
182
|
+
* against. Absent for the panel flow, whose budgets pin a key instead.
|
|
183
|
+
*/
|
|
184
|
+
expectedHandoffId?: string;
|
|
185
|
+
/** The card token id this runtime's card authority points at. */
|
|
186
|
+
tokenId: string;
|
|
187
|
+
fetchImpl?: typeof fetch;
|
|
188
|
+
/** Injectable for tests — never wall-clock-sleep in a unit test. */
|
|
189
|
+
sleep?: (ms: number) => Promise<void>;
|
|
190
|
+
now?: () => number;
|
|
191
|
+
/** Bounded retries for transient 429/5xx; the claim itself is single-use. */
|
|
192
|
+
attempts?: number;
|
|
193
|
+
retryDelayMs?: number;
|
|
194
|
+
};
|
|
195
|
+
export declare function claimMandatePickup(opts: ClaimMandatePickupOptions): Promise<MandatePickupClaim>;
|
|
@@ -58,9 +58,14 @@ const defaultSleep = (ms) => new Promise((r) => {
|
|
|
58
58
|
function defaultOpenUrl(url) {
|
|
59
59
|
// Best-effort convenience only — the URL is always surfaced via `log`/
|
|
60
60
|
// `onApprovalUrl`, so this must never throw or hang if there is no browser.
|
|
61
|
-
// `CHECKOUT_SKIP_BROWSER_OPEN=1` disables it
|
|
62
|
-
// launching a browser on the WRONG machine is pointless or noisy).
|
|
63
|
-
if (process.env.CHECKOUT_SKIP_BROWSER_OPEN === '1'
|
|
61
|
+
// `CHECKOUT_SKIP_BROWSER_OPEN=1` or `VISA_SUPPRESS_BROWSER=true|1` disables it
|
|
62
|
+
// (headless/agent hosts where launching a browser on the WRONG machine is pointless or noisy).
|
|
63
|
+
if (process.env.CHECKOUT_SKIP_BROWSER_OPEN === '1' ||
|
|
64
|
+
process.env.CHECKOUT_SKIP_BROWSER_OPEN === 'true' ||
|
|
65
|
+
process.env.VISA_SUPPRESS_BROWSER === '1' ||
|
|
66
|
+
process.env.VISA_SUPPRESS_BROWSER === 'true' ||
|
|
67
|
+
process.env.VISA_CLI_NO_BROWSER === '1' ||
|
|
68
|
+
process.env.VISA_CLI_NO_BROWSER === 'true')
|
|
64
69
|
return;
|
|
65
70
|
// Platform-appropriate opener; unknown platforms just skip (the URL is logged).
|
|
66
71
|
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start ""' : 'xdg-open';
|
|
@@ -128,7 +133,7 @@ function stripTrailingSlashes(value) {
|
|
|
128
133
|
* rather than falling back to a client-held secret.
|
|
129
134
|
*/
|
|
130
135
|
export async function runHostedApproval(opts) {
|
|
131
|
-
const { baseUrl, tokenId, target, consumerEmail,
|
|
136
|
+
const { baseUrl, tokenId, target, consumerEmail, agentJkt, maxDraws, perTransaction, intent,
|
|
132
137
|
// Default to stderr, NOT a no-op: the approval URL is the one thing a remote /
|
|
133
138
|
// SSH / headless-terminal human needs to proceed, and swallowing it (the old
|
|
134
139
|
// `() => {}` default) left it emitted nowhere readable. Tests inject their own.
|
|
@@ -137,6 +142,10 @@ export async function runHostedApproval(opts) {
|
|
|
137
142
|
// passkey) needs more than the interactive 4-minute window; allow an env
|
|
138
143
|
// override without threading a flag through every caller.
|
|
139
144
|
timeoutMs = resolveApprovalTimeoutMs(), } = opts;
|
|
145
|
+
const canonicalJkt = typeof agentJkt === 'string' && /^[A-Za-z0-9_-]{43}$/.test(agentJkt);
|
|
146
|
+
if (!canonicalJkt) {
|
|
147
|
+
throw new Error('a budget approval requires the exact current agent request-key JKT');
|
|
148
|
+
}
|
|
140
149
|
const base = stripTrailingSlashes(assertApprovalBaseUrl(baseUrl));
|
|
141
150
|
// The server's currency map is uppercase ISO 4217; the loader normalizes the
|
|
142
151
|
// target once, but normalize here too so a direct caller with a lowercase
|
|
@@ -148,7 +157,12 @@ export async function runHostedApproval(opts) {
|
|
|
148
157
|
const verifier = randomBytes(32).toString('base64url');
|
|
149
158
|
const challenge = createHash('sha256').update(verifier, 'utf8').digest('base64url');
|
|
150
159
|
const deadline = now() + timeoutMs;
|
|
151
|
-
|
|
160
|
+
// Verification-neutral: what the page asks the human for is a per-card
|
|
161
|
+
// property (passkey ceremony, issuer one-time code, or nothing beyond the
|
|
162
|
+
// signed-in approval) that this runner never learns. Naming a passkey here
|
|
163
|
+
// told operators on code-verified cards to wait for a device prompt that was
|
|
164
|
+
// never coming, and then blamed the timeout on the step they never saw.
|
|
165
|
+
const timeoutError = () => new Error(`no approval within ${Math.round(timeoutMs / 1000)}s — ` +
|
|
152
166
|
'the checkout was cancelled; run again to retry');
|
|
153
167
|
// Bound EVERY request by the remaining deadline: race the fetch against the
|
|
154
168
|
// (injectable) sleep and abort the request when the deadline wins, so a
|
|
@@ -182,9 +196,10 @@ export async function runHostedApproval(opts) {
|
|
|
182
196
|
amount: target.transactionAmount,
|
|
183
197
|
currency,
|
|
184
198
|
...(consumerEmail ? { consumerEmail } : {}),
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
199
|
+
budget: true,
|
|
200
|
+
agentJkt,
|
|
201
|
+
maxDraws,
|
|
202
|
+
...(perTransaction !== undefined ? { perTransaction } : {}),
|
|
188
203
|
...(intentSanitized !== undefined ? { intent: intentSanitized } : {}),
|
|
189
204
|
},
|
|
190
205
|
}),
|
|
@@ -201,6 +216,7 @@ export async function runHostedApproval(opts) {
|
|
|
201
216
|
// effort browser open for the interactive case.
|
|
202
217
|
onApprovalUrl?.(approveUrl);
|
|
203
218
|
log(`approve the purchase in your browser: ${approveUrl}`);
|
|
219
|
+
log(`budget recipient request key: ${agentJkt.slice(0, 8)}…${agentJkt.slice(-6)}`);
|
|
204
220
|
openUrl(approveUrl);
|
|
205
221
|
let lastHeartbeat = now();
|
|
206
222
|
// Keep the event loop alive for the whole poll wait. `defaultSleep` unref()'s
|
|
@@ -225,7 +241,13 @@ export async function runHostedApproval(opts) {
|
|
|
225
241
|
if (doc?.status === 'declined')
|
|
226
242
|
throw new HostedApprovalDeclinedError();
|
|
227
243
|
if (doc?.status === 'completed') {
|
|
228
|
-
|
|
244
|
+
// A passkey-exempt token ('otp' cardholder ID&V / 'none') runs no
|
|
245
|
+
// ceremony and produces no assurance — the approval server marks the
|
|
246
|
+
// claim explicitly (derived from the owner's verified card record at
|
|
247
|
+
// complete-time). Only that marker excuses a null assuranceData; a
|
|
248
|
+
// silently dropped payload still fails exactly as before.
|
|
249
|
+
const assuranceExempt = doc.cardholderVerification === 'otp' || doc.cardholderVerification === 'none';
|
|
250
|
+
if ((doc.assuranceData === undefined || doc.assuranceData === null) && !assuranceExempt) {
|
|
229
251
|
throw new Error('hosted approval completed but carried no assuranceData');
|
|
230
252
|
}
|
|
231
253
|
// The context the approval was COMPLETED against must be exactly this
|
|
@@ -248,26 +270,29 @@ export async function runHostedApproval(opts) {
|
|
|
248
270
|
}
|
|
249
271
|
}
|
|
250
272
|
// A budget approval must complete AS a budget approval — a relay that
|
|
251
|
-
// dropped the flag
|
|
252
|
-
//
|
|
253
|
-
// string
|
|
254
|
-
if (
|
|
273
|
+
// dropped the flag did not complete OUR registration. Compare the
|
|
274
|
+
// boolean explicitly (it is not a string, so it lives outside the
|
|
275
|
+
// string-map loop above).
|
|
276
|
+
if (doc.context?.budget !== true) {
|
|
255
277
|
throw new Error('hosted approval context mismatch on budget — the approval was not for this ' +
|
|
256
278
|
'exact purchase; run the checkout again for a fresh link');
|
|
257
279
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
280
|
+
if (doc.context?.agentJkt !== agentJkt) {
|
|
281
|
+
throw new Error('hosted approval context mismatch on agentJkt — the approval was not for this ' +
|
|
282
|
+
'exact request key; run the checkout again for a fresh link');
|
|
283
|
+
}
|
|
284
|
+
// The draw ceiling must survive the relay intact too — a relay that
|
|
285
|
+
// dropped or altered maxDraws would mint a budget token whose draw
|
|
286
|
+
// count no longer matches what the operator approved. Compare exactly,
|
|
262
287
|
// the omitted/undefined case included, so a silently dropped value is
|
|
263
288
|
// caught the same as a mutated one.
|
|
264
|
-
if (
|
|
289
|
+
if (doc.context?.maxDraws !== maxDraws) {
|
|
265
290
|
throw new Error('hosted approval context mismatch on maxDraws — the approval was not for this ' +
|
|
266
291
|
'exact purchase; run the checkout again for a fresh link');
|
|
267
292
|
}
|
|
268
293
|
// Same drop-or-mutate rule for the per-purchase cap: the term the
|
|
269
294
|
// operator read must be the term the token enforces.
|
|
270
|
-
if (
|
|
295
|
+
if (doc.context?.perTransaction !== perTransaction) {
|
|
271
296
|
throw new Error('hosted approval context mismatch on perTransaction — the approval was not for ' +
|
|
272
297
|
'this exact purchase; run the checkout again for a fresh link');
|
|
273
298
|
}
|
|
@@ -277,12 +302,20 @@ export async function runHostedApproval(opts) {
|
|
|
277
302
|
throw new Error('hosted approval context mismatch on intent — the approval was not for this ' +
|
|
278
303
|
'exact purchase; run the checkout again for a fresh link');
|
|
279
304
|
}
|
|
280
|
-
|
|
305
|
+
if (!Number.isSafeInteger(doc.validUntil) ||
|
|
306
|
+
doc.validUntil <= Math.floor(now() / 1000)) {
|
|
307
|
+
throw new Error('hosted budget approval completed without a valid future expiry');
|
|
308
|
+
}
|
|
309
|
+
log(assuranceExempt
|
|
310
|
+
? 'approval received from the hosted page (code-verified card, no passkey).'
|
|
311
|
+
: 'approval received from the hosted page (passkey-verified card).');
|
|
281
312
|
return {
|
|
282
|
-
...assuranceFromCeremony(target, doc.assuranceData),
|
|
313
|
+
...assuranceFromCeremony(target, doc.assuranceData ?? null),
|
|
314
|
+
...(assuranceExempt ? { assuranceExempt: true } : {}),
|
|
283
315
|
...(typeof doc.mintToken === 'string' && doc.mintToken
|
|
284
316
|
? { mintToken: doc.mintToken }
|
|
285
317
|
: {}),
|
|
318
|
+
validUntil: doc.validUntil,
|
|
286
319
|
};
|
|
287
320
|
}
|
|
288
321
|
// status 'pending' — the operator is still signing in / tapping.
|
|
@@ -309,3 +342,160 @@ export async function runHostedApproval(opts) {
|
|
|
309
342
|
clearInterval(keepAlive);
|
|
310
343
|
}
|
|
311
344
|
}
|
|
345
|
+
const PICKUP_CODE_SHAPE = /^[A-Za-z0-9_-]{43}$/;
|
|
346
|
+
const DECIMAL_AMOUNT_SHAPE = /^[0-9]+(\.[0-9]{1,2})?$/;
|
|
347
|
+
function pickupMismatch(field, detail) {
|
|
348
|
+
return new Error(`mandate pickup code mismatch on ${field} — ${detail}. Ask the owner to start a fresh ` +
|
|
349
|
+
'mandate in the panel for this agent');
|
|
350
|
+
}
|
|
351
|
+
export async function claimMandatePickup(opts) {
|
|
352
|
+
const { baseUrl, pickupCode, agentJkt, expectedHandoffId, tokenId, fetchImpl = fetch, sleep = defaultSleep, now = Date.now, attempts = 3, retryDelayMs = 1_000, } = opts;
|
|
353
|
+
// Both producers (runHostedApproval and the panel initiate route) mint the
|
|
354
|
+
// verifier as randomBytes(32).base64url = 43 chars; refuse anything else
|
|
355
|
+
// before it travels.
|
|
356
|
+
if (!PICKUP_CODE_SHAPE.test(pickupCode)) {
|
|
357
|
+
throw new Error('that does not look like a mandate pickup code (43 URL-safe characters) — ' +
|
|
358
|
+
'paste the code exactly as the panel displayed it');
|
|
359
|
+
}
|
|
360
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(agentJkt)) {
|
|
361
|
+
throw new Error('claiming a mandate pickup requires the exact current agent request-key JKT');
|
|
362
|
+
}
|
|
363
|
+
if (!tokenId.trim()) {
|
|
364
|
+
throw new Error('claiming a mandate pickup requires the provisioned card token id');
|
|
365
|
+
}
|
|
366
|
+
const base = stripTrailingSlashes(assertApprovalBaseUrl(baseUrl));
|
|
367
|
+
let res = null;
|
|
368
|
+
for (let attempt = 1;; attempt++) {
|
|
369
|
+
res = await fetchImpl(`${base}/api/vgs/agent-approval/claim`, {
|
|
370
|
+
method: 'POST',
|
|
371
|
+
headers: { 'content-type': 'application/json' },
|
|
372
|
+
body: JSON.stringify({ verifier: pickupCode }),
|
|
373
|
+
});
|
|
374
|
+
// 4xx other than 404/429 is a terminal shape/validation answer; 404 is the
|
|
375
|
+
// single-use/TTL answer. Only rate-limit and 5xx are worth a bounded retry —
|
|
376
|
+
// the entry is durable until claimed or expired.
|
|
377
|
+
if (res.ok || res.status === 404 || (res.status < 500 && res.status !== 429))
|
|
378
|
+
break;
|
|
379
|
+
if (attempt >= attempts)
|
|
380
|
+
break;
|
|
381
|
+
await sleep(retryDelayMs);
|
|
382
|
+
}
|
|
383
|
+
if (res.status === 404) {
|
|
384
|
+
throw new Error('this pickup code has expired or was already used — codes are single-use and live ' +
|
|
385
|
+
'about 10 minutes. Ask the owner to start a fresh mandate in the panel');
|
|
386
|
+
}
|
|
387
|
+
if (!res.ok) {
|
|
388
|
+
const body = (await res.json().catch(() => ({})));
|
|
389
|
+
throw new Error(`could not redeem the pickup code (${res.status})` + (body.error ? `: ${body.error}` : ''));
|
|
390
|
+
}
|
|
391
|
+
const doc = (await res.json().catch(() => null));
|
|
392
|
+
if (doc?.status === 'declined')
|
|
393
|
+
throw new HostedApprovalDeclinedError();
|
|
394
|
+
if (doc?.status !== 'completed') {
|
|
395
|
+
// A panel-issued pickup code is parked already-completed (the owner approved
|
|
396
|
+
// in the panel before the code existed). `pending` means this verifier came
|
|
397
|
+
// from an in-flight runner ceremony, not a mandate handoff — redeeming it
|
|
398
|
+
// here would race the runner that owns it.
|
|
399
|
+
throw new Error('this code is not a completed mandate handoff — it looks like an in-progress approval. ' +
|
|
400
|
+
'Use the pickup code the panel displayed after the owner approved the mandate');
|
|
401
|
+
}
|
|
402
|
+
const ctx = doc.context ?? {};
|
|
403
|
+
if (ctx.budget !== true) {
|
|
404
|
+
throw pickupMismatch('budget', 'the approval is a one-purchase approval, not a spend budget');
|
|
405
|
+
}
|
|
406
|
+
// A budget names exactly ONE agent, by one of two pins.
|
|
407
|
+
//
|
|
408
|
+
// - `agentJkt` — the panel flow: the owner picked an agent that already
|
|
409
|
+
// existed, so the approval names its key and this runtime compares keys.
|
|
410
|
+
// - `handoffId` — the Add-agent dialog: the budget was minted before any
|
|
411
|
+
// agent existed, so it names the handoff the agent would go on to redeem.
|
|
412
|
+
// This runtime redeemed one moments ago and knows which, so the comparison
|
|
413
|
+
// is just as concrete — it is simply a different identifier.
|
|
414
|
+
//
|
|
415
|
+
// EXACTLY one, enforced before either is compared. Neither pin is an unbound
|
|
416
|
+
// budget, and "no pin" must never be the quiet way past a binding check.
|
|
417
|
+
// BOTH pins is worse: it names two agents, and checking whichever one happens
|
|
418
|
+
// to match would let the weaker claim decide. The signer refuses to mint such
|
|
419
|
+
// a context and the approval store refuses to hold one, so this is the third
|
|
420
|
+
// and last place to say it — the one that runs on material already in hand.
|
|
421
|
+
const hasHandoffPin = typeof ctx.handoffId === 'string';
|
|
422
|
+
const hasAgentPin = typeof ctx.agentJkt === 'string';
|
|
423
|
+
if (hasHandoffPin === hasAgentPin) {
|
|
424
|
+
throw pickupMismatch('agentJkt', hasHandoffPin
|
|
425
|
+
? 'this budget names two different agents at once — it binds to a handoff or to a key, never both'
|
|
426
|
+
: 'this budget names no agent at all, so nothing can prove it belongs to this runtime');
|
|
427
|
+
}
|
|
428
|
+
if (hasHandoffPin) {
|
|
429
|
+
if (!expectedHandoffId || ctx.handoffId !== expectedHandoffId) {
|
|
430
|
+
throw pickupMismatch('handoffId', 'this budget belongs to a different agent handoff than the one this runtime just claimed');
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
else if (ctx.agentJkt !== agentJkt) {
|
|
434
|
+
throw pickupMismatch('agentJkt', 'the owner approved this mandate for a different agent key than this runtime holds');
|
|
435
|
+
}
|
|
436
|
+
if (ctx.tokenId !== tokenId) {
|
|
437
|
+
throw pickupMismatch('tokenId', "the mandate was approved against a different enrolled card than this runtime's card " +
|
|
438
|
+
'authority points at (the owner may have re-enrolled a card — re-run grant-card first)');
|
|
439
|
+
}
|
|
440
|
+
const ceiling = ctx.amount;
|
|
441
|
+
if (typeof ceiling !== 'string' || !DECIMAL_AMOUNT_SHAPE.test(ceiling) || Number(ceiling) <= 0) {
|
|
442
|
+
throw pickupMismatch('amount', 'the approved ceiling is missing or malformed');
|
|
443
|
+
}
|
|
444
|
+
const currency = ctx.currency;
|
|
445
|
+
if (typeof currency !== 'string' || !/^[A-Z]{3}$/.test(currency)) {
|
|
446
|
+
throw pickupMismatch('currency', 'the approved currency is missing or malformed');
|
|
447
|
+
}
|
|
448
|
+
const maxDraws = ctx.maxDraws;
|
|
449
|
+
if (!Number.isSafeInteger(maxDraws) || maxDraws < 1) {
|
|
450
|
+
throw pickupMismatch('maxDraws', 'the approved purchase count is missing or malformed');
|
|
451
|
+
}
|
|
452
|
+
const perTransaction = ctx.perTransaction;
|
|
453
|
+
if (perTransaction !== undefined) {
|
|
454
|
+
if (typeof perTransaction !== 'string' ||
|
|
455
|
+
!DECIMAL_AMOUNT_SHAPE.test(perTransaction) ||
|
|
456
|
+
Number(perTransaction) <= 0 ||
|
|
457
|
+
Number(perTransaction) > Number(ceiling)) {
|
|
458
|
+
throw pickupMismatch('perTransaction', 'the approved per-purchase cap is malformed');
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const merchantName = ctx.merchantName;
|
|
462
|
+
const merchantUrl = ctx.merchantUrl;
|
|
463
|
+
const merchantCountryCode = ctx.merchantCountryCode;
|
|
464
|
+
if (typeof merchantName !== 'string' ||
|
|
465
|
+
!merchantName.trim() ||
|
|
466
|
+
typeof merchantUrl !== 'string' ||
|
|
467
|
+
!merchantUrl.trim() ||
|
|
468
|
+
typeof merchantCountryCode !== 'string' ||
|
|
469
|
+
!merchantCountryCode.trim()) {
|
|
470
|
+
throw pickupMismatch('merchant', 'the approved budget target is missing or malformed');
|
|
471
|
+
}
|
|
472
|
+
if (typeof doc.mintToken !== 'string' || !doc.mintToken) {
|
|
473
|
+
throw new Error('the mandate handoff carried no mint token — the deployment that parked it ran the ' +
|
|
474
|
+
'dev-auth stub, which cannot mint draw authority. Re-initiate against a real deployment');
|
|
475
|
+
}
|
|
476
|
+
if (!Number.isSafeInteger(doc.validUntil) ||
|
|
477
|
+
doc.validUntil <= Math.floor(now() / 1000)) {
|
|
478
|
+
throw new Error('the mandate handoff carried no valid future expiry — ask the owner to re-initiate');
|
|
479
|
+
}
|
|
480
|
+
// Same exemption contract as runHostedApproval: only the server's explicit
|
|
481
|
+
// code-verified-card marker excuses a missing assurance payload.
|
|
482
|
+
const assuranceExempt = doc.cardholderVerification === 'otp' || doc.cardholderVerification === 'none';
|
|
483
|
+
if ((doc.assuranceData === undefined || doc.assuranceData === null) && !assuranceExempt) {
|
|
484
|
+
throw new Error('the mandate handoff completed but carried no assuranceData');
|
|
485
|
+
}
|
|
486
|
+
return {
|
|
487
|
+
assuranceData: doc.assuranceData ?? null,
|
|
488
|
+
assuranceExempt,
|
|
489
|
+
mintToken: doc.mintToken,
|
|
490
|
+
validUntil: doc.validUntil,
|
|
491
|
+
ceiling,
|
|
492
|
+
currency,
|
|
493
|
+
maxDraws: maxDraws,
|
|
494
|
+
...(perTransaction !== undefined ? { perTransaction: perTransaction } : {}),
|
|
495
|
+
merchant: {
|
|
496
|
+
name: merchantName,
|
|
497
|
+
url: merchantUrl,
|
|
498
|
+
countryCode: merchantCountryCode,
|
|
499
|
+
},
|
|
500
|
+
};
|
|
501
|
+
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
export { createCliCheckoutEngine, type CliReviewInput, type CliReviewFacts, type CliPayInput, type CliReceiptFacts, type CliStartMandateInput, type CliMandateFacts, type CliEngineDeps, } from './cli-engine.js';
|
|
1
|
+
export { createCliCheckoutEngine, type CliReviewInput, type CliReviewFacts, type CliPayInput, type CliReceiptFacts, type CliStartMandateInput, type CliMandateFacts, type CliEngineDeps, type ReceiptWriteObservation, 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, CheckoutRoute, } 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';
|
|
4
7
|
export { HostedApprovalDeclinedError, sanitizeApprovalIntent, APPROVAL_INTENT_MAX_CHARS, } from './hosted-approval.js';
|
|
5
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';
|
|
6
9
|
export { MandateLedger, remainingMinor, defaultLedgerPath, CARD_MANDATE_LEDGER_VERSION, type CardMandateRecord, type CardMandateLedgerFile, type CardMandateDraw, type CardMandateReservation, } from './mandate/mandate-ledger.js';
|
|
10
|
+
export { WEB_BOT_AUTH_MAX_TTL_SECONDS, WEB_BOT_AUTH_DEFAULT_TTL_SECONDS, WEB_BOT_AUTH_SIGNATURE_LABEL, WEB_BOT_AUTH_TAG, WEB_BOT_AUTH_COVERED_COMPONENTS, buildWebBotAuthHeaders, buildWebBotAuthHeadersAsync, webBotAuthHeadersOrNone, webBotAuthHeadersOrNoneAsync, resolveWebBotAuthConfig, type WebBotAuthPrivateJwk, type WebBotAuthSigningKey, type WebBotAuthConfig, type WebBotAuthHeaders, type BuildHeadersParams, } from './web-bot-auth.js';
|
|
@@ -1,8 +1,12 @@
|
|
|
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';
|
|
6
9
|
export { HostedApprovalDeclinedError, sanitizeApprovalIntent, APPROVAL_INTENT_MAX_CHARS, } from './hosted-approval.js';
|
|
7
10
|
export { createCardMandate, drawFromMandate, MandateDrawDeclinedError, DEFAULT_MANDATE_MAX_DRAWS, } from './mandate/card-mandate.js';
|
|
8
11
|
export { MandateLedger, remainingMinor, defaultLedgerPath, CARD_MANDATE_LEDGER_VERSION, } from './mandate/mandate-ledger.js';
|
|
12
|
+
export { WEB_BOT_AUTH_MAX_TTL_SECONDS, WEB_BOT_AUTH_DEFAULT_TTL_SECONDS, WEB_BOT_AUTH_SIGNATURE_LABEL, WEB_BOT_AUTH_TAG, WEB_BOT_AUTH_COVERED_COMPONENTS, buildWebBotAuthHeaders, buildWebBotAuthHeadersAsync, webBotAuthHeadersOrNone, webBotAuthHeadersOrNoneAsync, resolveWebBotAuthConfig, } from './web-bot-auth.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;
|
|
@@ -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,21 +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
8
|
/** Distinct phrases per mode so dry-run muscle memory can never authorize a payment. */
|
|
20
9
|
export declare function approvalPhrase(mode: CheckoutMode, reviewId: string): string;
|
|
21
10
|
/** Integer-only minor→display formatting (two-decimal currencies, the same assumption as decimalToMinor). */
|
|
@@ -39,5 +28,10 @@ export declare function isRunSuccess(mode: CheckoutMode, outcome: CheckoutOutcom
|
|
|
39
28
|
* so no charge can exist until it is completed — the double-charge warning
|
|
40
29
|
* would misdirect the operator, and the runner prints the challenge-specific
|
|
41
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.
|
|
42
36
|
*/
|
|
43
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,29 +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
30
|
/** Distinct phrases per mode so dry-run muscle memory can never authorize a payment. */
|
|
50
31
|
export function approvalPhrase(mode, reviewId) {
|
|
51
|
-
return `${mode === 'submit' ? 'PAY' : '
|
|
32
|
+
return `${mode === 'submit' ? 'PAY' : 'CHECK'} ${reviewId}`;
|
|
52
33
|
}
|
|
53
34
|
/** Integer-only minor→display formatting (two-decimal currencies, the same assumption as decimalToMinor). */
|
|
54
35
|
export function formatAmountMinor(amountMinor, currency) {
|
|
@@ -62,11 +43,11 @@ export function approvalQuestion(mode, review, phrase) {
|
|
|
62
43
|
`Type ${JSON.stringify(phrase)} to disclose the credential AND submit the order: `);
|
|
63
44
|
}
|
|
64
45
|
return (`Inspect the visible checkout. Type ${JSON.stringify(phrase)} ` +
|
|
65
|
-
`to
|
|
46
|
+
`to validate the reviewed page without minting, filling, or submitting a credential: `);
|
|
66
47
|
}
|
|
67
48
|
/** The one outcome each mode may exit 0 with. */
|
|
68
49
|
export function isRunSuccess(mode, outcome) {
|
|
69
|
-
return outcome === (mode === 'submit' ? 'confirmed' : '
|
|
50
|
+
return outcome === (mode === 'submit' ? 'confirmed' : 'reviewed-dry-run');
|
|
70
51
|
}
|
|
71
52
|
/**
|
|
72
53
|
* True when the pay control was actually clicked but the run ended with an
|
|
@@ -79,6 +60,11 @@ export function isRunSuccess(mode, outcome) {
|
|
|
79
60
|
* so no charge can exist until it is completed — the double-charge warning
|
|
80
61
|
* would misdirect the operator, and the runner prints the challenge-specific
|
|
81
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.
|
|
82
68
|
*/
|
|
83
69
|
export function submitClickedWithoutConfirmation(result) {
|
|
84
70
|
if (result.outcome === 'confirmed' ||
|