@visa/cli 4.1.0-rc.261 → 4.1.0-rc.263
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/checkout-engine/adapters/shopify.d.ts +20 -2
- package/dist/checkout-engine/adapters/shopify.js +67 -11
- package/dist/checkout-engine/amount.d.ts +2 -0
- package/dist/checkout-engine/cli-engine.d.ts +42 -1
- package/dist/checkout-engine/cli-engine.js +212 -30
- package/dist/checkout-engine/executor.d.ts +31 -1
- package/dist/checkout-engine/executor.js +168 -43
- package/dist/checkout-engine/index.d.ts +4 -2
- package/dist/checkout-engine/index.js +3 -2
- package/dist/checkout-engine/mandate.d.ts +8 -0
- package/dist/checkout-engine/mandate.js +44 -9
- package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +37 -0
- package/dist/checkout-engine/vgs-gateway/server-mint-client.js +139 -25
- package/dist/cli.js +414 -414
- package/dist/mcp-server/index.js +321 -321
- package/dist/merchant-ucp-mcp/index.js +3 -3
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -2,7 +2,7 @@ import type { Page } from 'playwright-core';
|
|
|
2
2
|
import { type PageAmountRead } from '../amount.js';
|
|
3
3
|
import { type DetectResult, type FieldMap } from '../detect.js';
|
|
4
4
|
import type { CardCredential } from '../instrument.js';
|
|
5
|
-
import type { Contact, FillResult, FilledField } from '../types.js';
|
|
5
|
+
import type { Contact, FillResult, FilledField, PostalAddress } from '../types.js';
|
|
6
6
|
import { type CheckoutAdapter } from './generic.js';
|
|
7
7
|
type ShopifySummary = {
|
|
8
8
|
subtotalMinor: number | null;
|
|
@@ -12,10 +12,27 @@ type ShopifySummary = {
|
|
|
12
12
|
totalMinor: number | null;
|
|
13
13
|
currency: string | null;
|
|
14
14
|
verified: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Component rows that never rendered and were counted as zero to reconcile
|
|
17
|
+
* the total (#8669). A summary that leans on an implied row is weaker than a
|
|
18
|
+
* fully rendered one: the row may simply not have been painted yet.
|
|
19
|
+
*/
|
|
20
|
+
impliedZeroRows: Array<'tax' | 'shipping'>;
|
|
15
21
|
};
|
|
16
22
|
export declare function parseShopifySummary(text: string): ShopifySummary;
|
|
17
23
|
export declare function readShopifyAmount(page: Page, requireVerified: boolean): Promise<PageAmountRead>;
|
|
18
|
-
|
|
24
|
+
/** Consecutive identical reads (200 ms apart) an implied-zero summary must hold. */
|
|
25
|
+
export declare const IMPLIED_ZERO_STABLE_READS = 8;
|
|
26
|
+
export type StableShopifyAmountOptions = {
|
|
27
|
+
/**
|
|
28
|
+
* A merchant-authoritative total (minor units) the page must equal before an
|
|
29
|
+
* implied-zero summary is trusted: for a trusted UCP handoff this is the
|
|
30
|
+
* server-settled checkout total. Without it, the page must hold the same
|
|
31
|
+
* implied-zero summary for {@link IMPLIED_ZERO_STABLE_READS} reads.
|
|
32
|
+
*/
|
|
33
|
+
expectedMinor?: number | null;
|
|
34
|
+
};
|
|
35
|
+
export declare function readStableShopifyAmount(page: Page, timeoutMs?: number, options?: StableShopifyAmountOptions): Promise<PageAmountRead>;
|
|
19
36
|
export declare function isShopifyCheckoutPage(page: Page): Promise<boolean>;
|
|
20
37
|
export type ShopifyCheckoutSurface = {
|
|
21
38
|
signal: string;
|
|
@@ -56,6 +73,7 @@ export declare function assertShopifyGuestCheckout(page: Page): Promise<ShopifyG
|
|
|
56
73
|
* presentation-only: swapping it keeps the same checkout session and token.
|
|
57
74
|
*/
|
|
58
75
|
export declare function shopifyEnglishCheckoutUrl(current: string): string | null;
|
|
76
|
+
export declare function requiredAddressRoles(address: PostalAddress, prefix?: string): string[];
|
|
59
77
|
export declare function missingContactRoles(filled: FilledField[], expected: string[]): string[];
|
|
60
78
|
/**
|
|
61
79
|
* The contact surface to prefill. Shipping fields when the checkout has them —
|
|
@@ -6,10 +6,22 @@ import { minorFromDecimal, pageCurrency } from '../amount.js';
|
|
|
6
6
|
import { detectFields, } from '../detect.js';
|
|
7
7
|
import { fillContactFieldMap, fillFieldMap } from './generic.js';
|
|
8
8
|
const SUMMARY_LABELS = /^(Subtotal|Shipping|Estimated taxes|Taxes|Tax|Discounts?|Total)(?:\s*:?\s+((?:[A-Z]{3}\s+)?-?[$€£]?\s*\d[\d.,]*|Free))?$/i;
|
|
9
|
+
// A row whose value has not been computed yet. Shopify phrases it several
|
|
10
|
+
// ways; every one of them means "not a number", never "zero".
|
|
11
|
+
const PENDING_VALUE = /calculated at (?:the )?next step|enter (?:a |your )?shipping address|calculating|getting rates|pending/i;
|
|
9
12
|
function valueAfterLabel(lines, index, inline) {
|
|
10
13
|
if (inline)
|
|
11
14
|
return inline;
|
|
12
|
-
|
|
15
|
+
for (const line of lines.slice(index + 1, index + 4)) {
|
|
16
|
+
// Never borrow a later row's number: the scan stops at the next label and
|
|
17
|
+
// a pending phrase ends it with no value (#8669: "Shipping / Calculated at
|
|
18
|
+
// next step / Total / $1.50" used to read shipping as $1.50).
|
|
19
|
+
if (SUMMARY_LABELS.test(line) || PENDING_VALUE.test(line))
|
|
20
|
+
return '';
|
|
21
|
+
if (/\d|free/i.test(line))
|
|
22
|
+
return line;
|
|
23
|
+
}
|
|
24
|
+
return '';
|
|
13
25
|
}
|
|
14
26
|
export function parseShopifySummary(text) {
|
|
15
27
|
const lines = text
|
|
@@ -53,12 +65,33 @@ export function parseShopifySummary(text) {
|
|
|
53
65
|
// checkout, value or not, and discount already defaults to 0 — only the
|
|
54
66
|
// additive components force reconciliation.
|
|
55
67
|
const componentLabelPresent = [...values.keys()].some((key) => key !== 'total' && key !== 'discount' && key !== 'discounts');
|
|
68
|
+
// Shopify omits a component row entirely when its value is zero: a tax-free
|
|
69
|
+
// destination renders no "Taxes" line and a free-shipping order can render no
|
|
70
|
+
// "Shipping" line (observed live 2026-09-02, #8669: a settled Colorado
|
|
71
|
+
// checkout showed Subtotal + Shipping = Total with no tax row and refused as
|
|
72
|
+
// unreadable). A row that is absent may only ever count as zero when the
|
|
73
|
+
// rows that ARE present reconcile exactly to the total; a pending
|
|
74
|
+
// "Calculated at next step" still parses as null and still refuses.
|
|
75
|
+
const rowAbsent = (...keys) => keys.every((key) => !values.has(key));
|
|
76
|
+
const taxAbsent = rowAbsent('estimated taxes', 'taxes', 'tax');
|
|
77
|
+
const shippingAbsent = rowAbsent('shipping');
|
|
78
|
+
const reconciles = (shipping, tax) => subtotalMinor != null &&
|
|
79
|
+
shipping != null &&
|
|
80
|
+
tax != null &&
|
|
81
|
+
totalMinor != null &&
|
|
82
|
+
subtotalMinor + shipping + tax - discountMinor === totalMinor;
|
|
83
|
+
// A row that rendered with a pending or unreadable value is present-and-null
|
|
84
|
+
// and always refuses; only a row that never rendered at all may imply zero.
|
|
85
|
+
const fullyReconciled = reconciles(shippingMinor, taxMinor);
|
|
86
|
+
const impliedZeroRows = [];
|
|
87
|
+
if (componentLabelPresent && !fullyReconciled) {
|
|
88
|
+
if (taxAbsent && reconciles(shippingMinor, 0))
|
|
89
|
+
impliedZeroRows.push('tax');
|
|
90
|
+
else if (shippingAbsent && reconciles(0, taxMinor))
|
|
91
|
+
impliedZeroRows.push('shipping');
|
|
92
|
+
}
|
|
56
93
|
const verified = componentLabelPresent
|
|
57
|
-
?
|
|
58
|
-
shippingMinor != null &&
|
|
59
|
-
taxMinor != null &&
|
|
60
|
-
totalMinor != null &&
|
|
61
|
-
subtotalMinor + shippingMinor + taxMinor - discountMinor === totalMinor
|
|
94
|
+
? fullyReconciled || impliedZeroRows.length > 0
|
|
62
95
|
: totalMinor != null;
|
|
63
96
|
return {
|
|
64
97
|
subtotalMinor,
|
|
@@ -68,6 +101,7 @@ export function parseShopifySummary(text) {
|
|
|
68
101
|
totalMinor,
|
|
69
102
|
currency: pageCurrency(totalText ?? ''),
|
|
70
103
|
verified,
|
|
104
|
+
impliedZeroRows,
|
|
71
105
|
};
|
|
72
106
|
}
|
|
73
107
|
export async function readShopifyAmount(page, requireVerified) {
|
|
@@ -89,24 +123,46 @@ export async function readShopifyAmount(page, requireVerified) {
|
|
|
89
123
|
amountMinor: summary.totalMinor,
|
|
90
124
|
currency: summary.currency,
|
|
91
125
|
source: 'shopify-summary',
|
|
126
|
+
...(summary.impliedZeroRows.length > 0 ? { impliedZeroRows: summary.impliedZeroRows } : {}),
|
|
92
127
|
};
|
|
93
128
|
}
|
|
94
|
-
|
|
129
|
+
/** Consecutive identical reads (200 ms apart) an implied-zero summary must hold. */
|
|
130
|
+
export const IMPLIED_ZERO_STABLE_READS = 8;
|
|
131
|
+
export async function readStableShopifyAmount(page, timeoutMs = 6_000, options = {}) {
|
|
95
132
|
const startedAt = Date.now();
|
|
96
133
|
let previous = '';
|
|
97
134
|
let stableReads = 0;
|
|
135
|
+
let sawImpliedZeroMismatch = false;
|
|
98
136
|
while (Date.now() - startedAt < timeoutMs) {
|
|
99
137
|
const latest = await readShopifyAmount(page, true);
|
|
100
138
|
const fingerprint = JSON.stringify(latest);
|
|
101
139
|
stableReads = fingerprint === previous ? stableReads + 1 : 0;
|
|
102
|
-
if (latest.kind === 'ok'
|
|
103
|
-
|
|
140
|
+
if (latest.kind === 'ok') {
|
|
141
|
+
const implied = (latest.impliedZeroRows?.length ?? 0) > 0;
|
|
142
|
+
if (!implied) {
|
|
143
|
+
if (stableReads >= 1)
|
|
144
|
+
return latest;
|
|
145
|
+
}
|
|
146
|
+
else if (options.expectedMinor != null) {
|
|
147
|
+
// A missing tax or shipping row may be a pre-tax render (#8669). The
|
|
148
|
+
// merchant-authoritative total is the settle signal: accept the implied
|
|
149
|
+
// summary only once the page total equals it.
|
|
150
|
+
if (latest.amountMinor === options.expectedMinor && stableReads >= 1)
|
|
151
|
+
return latest;
|
|
152
|
+
sawImpliedZeroMismatch = true;
|
|
153
|
+
}
|
|
154
|
+
else if (stableReads >= IMPLIED_ZERO_STABLE_READS) {
|
|
155
|
+
return latest;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
104
158
|
previous = fingerprint;
|
|
105
159
|
await page.waitForTimeout(200);
|
|
106
160
|
}
|
|
107
161
|
return {
|
|
108
162
|
kind: 'unreadable',
|
|
109
|
-
reason:
|
|
163
|
+
reason: sawImpliedZeroMismatch
|
|
164
|
+
? 'Shopify summary omitted a tax or shipping row and its total never matched the merchant-settled total'
|
|
165
|
+
: 'Shopify tax and total did not settle before the review deadline',
|
|
110
166
|
};
|
|
111
167
|
}
|
|
112
168
|
export async function isShopifyCheckoutPage(page) {
|
|
@@ -565,7 +621,7 @@ function labelBillingEvidence(filled) {
|
|
|
565
621
|
role: BILLING_ROLE_NAMES[field.role] ?? `billing:${field.role}`,
|
|
566
622
|
}));
|
|
567
623
|
}
|
|
568
|
-
function requiredAddressRoles(address, prefix = '') {
|
|
624
|
+
export function requiredAddressRoles(address, prefix = '') {
|
|
569
625
|
const role = (name) => `${prefix}${prefix ? name[0].toUpperCase() + name.slice(1) : name}`;
|
|
570
626
|
const regionRequired = ['US', 'CA', 'AU'].includes(address.country?.toUpperCase() ?? '');
|
|
571
627
|
return [
|
|
@@ -11,5 +11,7 @@ export type PageAmountRead = {
|
|
|
11
11
|
amountMinor: number;
|
|
12
12
|
currency: string | null;
|
|
13
13
|
source: 'page-attr' | 'page-text' | 'shopify-summary';
|
|
14
|
+
/** Shopify rows counted as zero because they never rendered (#8669). */
|
|
15
|
+
impliedZeroRows?: Array<'tax' | 'shipping'>;
|
|
14
16
|
};
|
|
15
17
|
export declare function readGenericPageAmount(page: Page): Promise<PageAmountRead>;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { type Browser } from 'playwright-core';
|
|
2
2
|
import { prepareCheckout as realPrepareCheckout, submitApprovedCheckout as realSubmitApprovedCheckout, type CheckoutMode, type CheckoutOutcome, type CheckoutFailureCode, type CheckoutResult, type PreparedCheckoutSessionStore } from './executor.js';
|
|
3
|
+
import type { MandateRefusalCode } from './mandate.js';
|
|
3
4
|
import { claimMandatePickup as realClaimMandatePickup, runHostedApproval as realRunHostedApproval } from './hosted-approval.js';
|
|
4
5
|
import { type VgsCheckoutTarget } from './vgs-live-instrument.js';
|
|
5
|
-
import { serverFetchCryptogram, serverPostConfirmation } from './vgs-gateway/server-mint-client.js';
|
|
6
|
+
import { ServerIntentError, serverFetchCryptogram, serverPostConfirmation } from './vgs-gateway/server-mint-client.js';
|
|
6
7
|
import { type CardMandateFacts } from './mandate/card-mandate.js';
|
|
7
8
|
import { MandateLedger } from './mandate/mandate-ledger.js';
|
|
8
9
|
import { writeReceipt as realWriteReceipt } from './receipt.js';
|
|
@@ -74,6 +75,8 @@ export declare class CheckoutReviewRefusedError extends Error {
|
|
|
74
75
|
readonly code = "CHECKOUT_REVIEW_REFUSED";
|
|
75
76
|
readonly checkoutOutcome: CheckoutOutcome;
|
|
76
77
|
readonly failureCode?: CheckoutFailureCode;
|
|
78
|
+
/** Bounded mandate/trusted-identity reason (#8669); absent for other outcomes. */
|
|
79
|
+
readonly refusalCode?: MandateRefusalCode;
|
|
77
80
|
readonly requiresAdapter: string[];
|
|
78
81
|
readonly detectedRoles: string[];
|
|
79
82
|
readonly receiptWrite: ReceiptWriteObservation;
|
|
@@ -194,6 +197,43 @@ export type CliMandateFacts = CardMandateFacts & {
|
|
|
194
197
|
*/
|
|
195
198
|
registerFailureReason?: string;
|
|
196
199
|
};
|
|
200
|
+
/**
|
|
201
|
+
* The owner approved a budget but the intent bootstrap did not complete
|
|
202
|
+
* (#8470). `resumable` means the same approval can be resumed without a second
|
|
203
|
+
* passkey ceremony: the server's bootstrap state is at-most-once per signed
|
|
204
|
+
* token, so a resume can only recover an intent that already exists or
|
|
205
|
+
* dispatch once when nothing was ever dispatched — never create a sibling.
|
|
206
|
+
*/
|
|
207
|
+
export type CardMandateActivationFacts = {
|
|
208
|
+
phase: 'intent';
|
|
209
|
+
/** `uncertain`: the provider may have created the intent. `not_created`: proven not. */
|
|
210
|
+
outcome: 'uncertain' | 'not_created';
|
|
211
|
+
resumable: boolean;
|
|
212
|
+
status: number | null;
|
|
213
|
+
errorCode: string | null;
|
|
214
|
+
requestId: string | null;
|
|
215
|
+
bootstrapState: string;
|
|
216
|
+
/** Opaque, process-bound handle for {@link CliCheckoutEngine.resumeCardMandate}. */
|
|
217
|
+
resumeToken?: string;
|
|
218
|
+
/** When the approval's bootstrap credential stops being usable. */
|
|
219
|
+
resumeExpiresAt?: string;
|
|
220
|
+
};
|
|
221
|
+
export declare class CardMandateActivationError extends Error {
|
|
222
|
+
readonly facts: CardMandateActivationFacts;
|
|
223
|
+
readonly code = "CARD_MANDATE_ACTIVATION_INCOMPLETE";
|
|
224
|
+
constructor(message: string, facts: CardMandateActivationFacts);
|
|
225
|
+
}
|
|
226
|
+
export type CliResumeMandateInput = {
|
|
227
|
+
resumeToken: string;
|
|
228
|
+
};
|
|
229
|
+
/**
|
|
230
|
+
* Sort a budget-intent route failure into resume semantics. Exported for the
|
|
231
|
+
* regression net; the truth table is the product contract of #8470.
|
|
232
|
+
*/
|
|
233
|
+
export declare function classifyServerIntentFailure(err: ServerIntentError): {
|
|
234
|
+
outcome: 'uncertain' | 'not_created';
|
|
235
|
+
resumable: boolean;
|
|
236
|
+
};
|
|
197
237
|
type Session = {
|
|
198
238
|
browser: Browser;
|
|
199
239
|
/** Exact caller URL repeated at pay time; may contain a UCP capability. */
|
|
@@ -353,6 +393,7 @@ export type ReceiptWriteObservation = {
|
|
|
353
393
|
};
|
|
354
394
|
export declare function createCliCheckoutEngine(deps?: CliEngineDeps): {
|
|
355
395
|
startCardMandate(input: CliStartMandateInput): Promise<CliMandateFacts>;
|
|
396
|
+
resumeCardMandate(input: CliResumeMandateInput): Promise<CliMandateFacts>;
|
|
356
397
|
claimCardMandate(input: CliClaimMandateInput): Promise<CliMandateFacts>;
|
|
357
398
|
review(input: CliReviewInput): Promise<CliReviewFacts>;
|
|
358
399
|
/**
|
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
// Every browser/network primitive is injectable (CliEngineDeps) so the session/
|
|
12
12
|
// timer/store lifecycle is unit-testable without launching Chromium.
|
|
13
13
|
import { readFile } from 'node:fs/promises';
|
|
14
|
-
import { randomUUID } from 'node:crypto';
|
|
14
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
15
15
|
import { launchCheckoutBrowser } from './browser-launch.js';
|
|
16
16
|
import { RECEIPT_DIR } from './receipt-dir.js';
|
|
17
17
|
import { prepareCheckout as realPrepareCheckout, submitApprovedCheckout as realSubmitApprovedCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
|
|
18
18
|
import { claimMandatePickup as realClaimMandatePickup, runHostedApproval as realRunHostedApproval, } from './hosted-approval.js';
|
|
19
19
|
import { VgsLiveInstrument, decimalToMinor, minorToDecimal, } from './vgs-live-instrument.js';
|
|
20
|
-
import { serverCreateIntent, serverFetchCryptogram, serverPostConfirmation, } from './vgs-gateway/server-mint-client.js';
|
|
20
|
+
import { serverCreateIntent, serverReadIntentBootstrap, ServerIntentError, serverFetchCryptogram, serverPostConfirmation, } from './vgs-gateway/server-mint-client.js';
|
|
21
21
|
import { createCardMandate, DEFAULT_MANDATE_MAX_DRAWS, drawFromMandate, MandateDrawDeclinedError, } from './mandate/card-mandate.js';
|
|
22
22
|
import { MandateLedger } from './mandate/mandate-ledger.js';
|
|
23
23
|
import { buildReceipt, writeReceipt as realWriteReceipt, } from './receipt.js';
|
|
@@ -161,6 +161,8 @@ export class CheckoutReviewRefusedError extends Error {
|
|
|
161
161
|
code = 'CHECKOUT_REVIEW_REFUSED';
|
|
162
162
|
checkoutOutcome;
|
|
163
163
|
failureCode;
|
|
164
|
+
/** Bounded mandate/trusted-identity reason (#8669); absent for other outcomes. */
|
|
165
|
+
refusalCode;
|
|
164
166
|
requiresAdapter;
|
|
165
167
|
detectedRoles;
|
|
166
168
|
receiptWrite;
|
|
@@ -172,6 +174,7 @@ export class CheckoutReviewRefusedError extends Error {
|
|
|
172
174
|
this.name = 'CheckoutReviewRefusedError';
|
|
173
175
|
this.checkoutOutcome = result.outcome;
|
|
174
176
|
this.failureCode = result.failureCode;
|
|
177
|
+
this.refusalCode = result.refusalCode;
|
|
175
178
|
this.requiresAdapter = [...result.requiresAdapter];
|
|
176
179
|
this.detectedRoles = Object.keys(result.fields);
|
|
177
180
|
this.receiptWrite = receiptWrite;
|
|
@@ -207,6 +210,62 @@ function failedPay(detail) {
|
|
|
207
210
|
credentialDisclosed: false,
|
|
208
211
|
};
|
|
209
212
|
}
|
|
213
|
+
export class CardMandateActivationError extends Error {
|
|
214
|
+
facts;
|
|
215
|
+
code = 'CARD_MANDATE_ACTIVATION_INCOMPLETE';
|
|
216
|
+
constructor(message, facts) {
|
|
217
|
+
super(message);
|
|
218
|
+
this.facts = facts;
|
|
219
|
+
this.name = 'CardMandateActivationError';
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Sort a budget-intent route failure into resume semantics. Exported for the
|
|
224
|
+
* regression net; the truth table is the product contract of #8470.
|
|
225
|
+
*/
|
|
226
|
+
export function classifyServerIntentFailure(err) {
|
|
227
|
+
const { status, errorCode, retryable, bootstrapState, outcome } = err.facts;
|
|
228
|
+
if (bootstrapState === 'created')
|
|
229
|
+
return { outcome: 'not_created', resumable: true };
|
|
230
|
+
if (bootstrapState === 'ambiguous' || errorCode === 'budget_intent_creation_ambiguous') {
|
|
231
|
+
return { outcome: 'uncertain', resumable: true };
|
|
232
|
+
}
|
|
233
|
+
if (bootstrapState === 'pending' || errorCode === 'budget_intent_creation_pending') {
|
|
234
|
+
return { outcome: 'uncertain', resumable: true };
|
|
235
|
+
}
|
|
236
|
+
// An explicit uncertain or unknown outcome wins over retry advice: a
|
|
237
|
+
// retryable failure of the STATUS read says nothing about whether the
|
|
238
|
+
// original dispatch created the intent, so it must not read as not_created.
|
|
239
|
+
// A terminal refusal of the read itself (the approval credential is no
|
|
240
|
+
// longer accepted, or is not a budget token) is still uncertain about the
|
|
241
|
+
// intent but cannot be resumed under that credential.
|
|
242
|
+
if (outcome === 'uncertain' || outcome === 'unknown') {
|
|
243
|
+
const terminalRead = status === 401 || status === 403 || status === 404;
|
|
244
|
+
return { outcome: 'uncertain', resumable: !terminalRead };
|
|
245
|
+
}
|
|
246
|
+
if (retryable)
|
|
247
|
+
return { outcome: 'not_created', resumable: true };
|
|
248
|
+
if (status === 0 || status >= 500) {
|
|
249
|
+
return { outcome: 'uncertain', resumable: true };
|
|
250
|
+
}
|
|
251
|
+
// A completed 4xx refusal (claims, binding, conflict, already registered)
|
|
252
|
+
// proves no intent exists and no resume can change the answer.
|
|
253
|
+
return { outcome: 'not_created', resumable: false };
|
|
254
|
+
}
|
|
255
|
+
function mintTokenExpiryMs(mintToken, fallbackMs) {
|
|
256
|
+
try {
|
|
257
|
+
const payload = mintToken.split('.')[1];
|
|
258
|
+
if (!payload)
|
|
259
|
+
return fallbackMs;
|
|
260
|
+
const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
261
|
+
return typeof claims.exp === 'number' && Number.isFinite(claims.exp)
|
|
262
|
+
? claims.exp * 1000
|
|
263
|
+
: fallbackMs;
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return fallbackMs;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
210
269
|
function boundedReceiptWriteErrorCode(reason) {
|
|
211
270
|
return reason.match(/\b(?:EACCES|EEXIST|ENOSPC|ENOTDIR|EPERM|EROFS)\b/)?.[0] ?? 'UNKNOWN';
|
|
212
271
|
}
|
|
@@ -222,6 +281,45 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
222
281
|
const sessions = deps.sessions ?? defaultSessions;
|
|
223
282
|
const payAttempts = deps.payAttempts ?? defaultPayAttempts;
|
|
224
283
|
const ttlMs = deps.ttlMs ?? PREPARED_TTL_MS;
|
|
284
|
+
// #8470: process-bound resume handles for a budget whose intent bootstrap
|
|
285
|
+
// did not complete after owner approval. Holds the one-use bootstrap
|
|
286
|
+
// credential in memory only, for at most its own lifetime.
|
|
287
|
+
const pendingActivations = new Map();
|
|
288
|
+
function dropActivation(token) {
|
|
289
|
+
const pending = pendingActivations.get(token);
|
|
290
|
+
if (!pending)
|
|
291
|
+
return;
|
|
292
|
+
clearTimeout(pending.cleanupTimer);
|
|
293
|
+
pendingActivations.delete(token);
|
|
294
|
+
}
|
|
295
|
+
function parkActivation(pending) {
|
|
296
|
+
const token = `act_${randomBytes(18).toString('base64url')}`;
|
|
297
|
+
const cleanupTimer = setTimeout(() => dropActivation(token), Math.max(0, pending.expiresAtMs - now().getTime()));
|
|
298
|
+
cleanupTimer.unref?.();
|
|
299
|
+
pendingActivations.set(token, { ...pending, cleanupTimer });
|
|
300
|
+
return token;
|
|
301
|
+
}
|
|
302
|
+
function activationFailure(err, pending, existingToken) {
|
|
303
|
+
if (!(err instanceof ServerIntentError))
|
|
304
|
+
throw err;
|
|
305
|
+
const classified = classifyServerIntentFailure(err);
|
|
306
|
+
const resumeToken = classified.resumable
|
|
307
|
+
? (existingToken ?? parkActivation(pending))
|
|
308
|
+
: undefined;
|
|
309
|
+
if (!classified.resumable && existingToken)
|
|
310
|
+
dropActivation(existingToken);
|
|
311
|
+
throw new CardMandateActivationError(err.message, {
|
|
312
|
+
phase: 'intent',
|
|
313
|
+
outcome: classified.outcome,
|
|
314
|
+
resumable: classified.resumable,
|
|
315
|
+
status: err.facts.status,
|
|
316
|
+
errorCode: err.facts.errorCode,
|
|
317
|
+
requestId: err.facts.requestId,
|
|
318
|
+
bootstrapState: err.facts.bootstrapState,
|
|
319
|
+
...(resumeToken ? { resumeToken } : {}),
|
|
320
|
+
...(resumeToken ? { resumeExpiresAt: new Date(pending.expiresAtMs).toISOString() } : {}),
|
|
321
|
+
});
|
|
322
|
+
}
|
|
225
323
|
const launchBrowser = deps.launchBrowser ?? (() => launchCheckoutBrowser());
|
|
226
324
|
const prepareCheckout = deps.prepareCheckout ?? realPrepareCheckout;
|
|
227
325
|
const submitApprovedCheckout = deps.submitApprovedCheckout ?? realSubmitApprovedCheckout;
|
|
@@ -394,6 +492,45 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
394
492
|
...(reg.reason !== undefined ? { registerFailureReason: reg.reason } : {}),
|
|
395
493
|
};
|
|
396
494
|
}
|
|
495
|
+
// Shared by mandate-start and resume: mint (or adopt) the ceiling intent,
|
|
496
|
+
// persist the owner-only ledger entry, and register server-side.
|
|
497
|
+
async function activateBudget(pending, createIntent) {
|
|
498
|
+
const facts = await createCardMandate({
|
|
499
|
+
agentJkt: pending.registerCap.agentJkt,
|
|
500
|
+
tokenId: pending.tokenId,
|
|
501
|
+
assuranceData: pending.assuranceData,
|
|
502
|
+
ceilingMinor: pending.ceilingMinor,
|
|
503
|
+
merchant: pending.merchant,
|
|
504
|
+
currencyCode: pending.currency,
|
|
505
|
+
expiresAt: pending.expiresAt,
|
|
506
|
+
maxDraws: DEFAULT_MANDATE_MAX_DRAWS,
|
|
507
|
+
crossMerchant: true,
|
|
508
|
+
}, {
|
|
509
|
+
createIntent,
|
|
510
|
+
ledger,
|
|
511
|
+
approvalBaseUrl: pending.approvalBaseUrl,
|
|
512
|
+
now,
|
|
513
|
+
});
|
|
514
|
+
// Seed the one server-authoritative cumulative store keyed by the VGS
|
|
515
|
+
// intent ID. A later draw requires its PoP verdict; the budget token never
|
|
516
|
+
// falls back as payable authority.
|
|
517
|
+
const registered = await registerMandateOrDisable({
|
|
518
|
+
registerCap: pending.registerCap,
|
|
519
|
+
mandateId: facts.mandateId,
|
|
520
|
+
mintToken: pending.mintToken,
|
|
521
|
+
ceiling: pending.ceiling,
|
|
522
|
+
currency: pending.currency,
|
|
523
|
+
});
|
|
524
|
+
return {
|
|
525
|
+
...facts,
|
|
526
|
+
...approvedCeilingFacts(facts, registered.approvedCeilingMinor),
|
|
527
|
+
merchantHost: new URL(pending.merchant.url).hostname,
|
|
528
|
+
registerFailed: registered.registerFailed,
|
|
529
|
+
...(registered.registerFailureReason !== undefined
|
|
530
|
+
? { registerFailureReason: registered.registerFailureReason }
|
|
531
|
+
: {}),
|
|
532
|
+
};
|
|
533
|
+
}
|
|
397
534
|
return {
|
|
398
535
|
// BUDGET step: one passkey approves a CEILING; a VGS intent is minted with
|
|
399
536
|
// that ceiling as its decline threshold and the owner-only ledger records
|
|
@@ -469,41 +606,86 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
469
606
|
throw new Error('the approval server issued no valid budget expiry');
|
|
470
607
|
}
|
|
471
608
|
const expiresAt = new Date(assurance.validUntil * 1000).toISOString();
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
tokenId: credential.tokenId,
|
|
609
|
+
const pending = {
|
|
610
|
+
mintToken,
|
|
475
611
|
assuranceData: assurance.assuranceData,
|
|
612
|
+
ceiling: input.ceiling,
|
|
476
613
|
ceilingMinor,
|
|
614
|
+
currency: input.currency,
|
|
477
615
|
merchant,
|
|
478
|
-
currencyCode: input.currency,
|
|
479
616
|
expiresAt,
|
|
480
|
-
|
|
481
|
-
crossMerchant: true,
|
|
482
|
-
}, {
|
|
483
|
-
createIntent: (i) => serverCreateIntent(input.approvalBaseUrl, mintToken, i),
|
|
484
|
-
ledger,
|
|
617
|
+
tokenId: credential.tokenId,
|
|
485
618
|
approvalBaseUrl: input.approvalBaseUrl,
|
|
486
|
-
now,
|
|
487
|
-
});
|
|
488
|
-
// Seed the one server-authoritative cumulative store keyed by the VGS
|
|
489
|
-
// intent ID. A later draw requires its PoP verdict; the budget token never
|
|
490
|
-
// falls back as payable authority.
|
|
491
|
-
const registered = await registerMandateOrDisable({
|
|
492
619
|
registerCap,
|
|
493
|
-
|
|
494
|
-
mintToken,
|
|
495
|
-
ceiling: input.ceiling,
|
|
496
|
-
currency: input.currency,
|
|
497
|
-
});
|
|
498
|
-
return {
|
|
499
|
-
...facts,
|
|
500
|
-
...approvedCeilingFacts(facts, registered.approvedCeilingMinor),
|
|
501
|
-
merchantHost: new URL(merchant.url).hostname,
|
|
502
|
-
registerFailed: registered.registerFailed,
|
|
503
|
-
...(registered.registerFailureReason !== undefined
|
|
504
|
-
? { registerFailureReason: registered.registerFailureReason }
|
|
505
|
-
: {}),
|
|
620
|
+
expiresAtMs: mintTokenExpiryMs(mintToken, now().getTime() + 10 * 60 * 1000),
|
|
506
621
|
};
|
|
622
|
+
return activateBudget(pending, (i) => serverCreateIntent(input.approvalBaseUrl, mintToken, i)).catch((err) => activationFailure(err, pending));
|
|
623
|
+
},
|
|
624
|
+
// RESUME leg (#8470): the owner already approved, but the intent bootstrap
|
|
625
|
+
// did not complete. Read the server's durable state under the SAME one-use
|
|
626
|
+
// credential: a created intent is registered as-is, nothing-dispatched is
|
|
627
|
+
// dispatched once, pending/ambiguous stays uncertain. No approval page, no
|
|
628
|
+
// passkey, and never a sibling intent.
|
|
629
|
+
async resumeCardMandate(input) {
|
|
630
|
+
const pending = pendingActivations.get(input.resumeToken);
|
|
631
|
+
if (!pending || pending.expiresAtMs <= now().getTime()) {
|
|
632
|
+
if (pending)
|
|
633
|
+
dropActivation(input.resumeToken);
|
|
634
|
+
throw new CardMandateActivationError('no resumable budget activation for this operation — its approval credential expired; start a fresh approval', {
|
|
635
|
+
phase: 'intent',
|
|
636
|
+
outcome: 'not_created',
|
|
637
|
+
resumable: false,
|
|
638
|
+
status: null,
|
|
639
|
+
errorCode: 'activation_resume_expired',
|
|
640
|
+
requestId: null,
|
|
641
|
+
bootstrapState: 'unknown',
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
let read;
|
|
645
|
+
try {
|
|
646
|
+
read = await serverReadIntentBootstrap(pending.approvalBaseUrl, pending.mintToken);
|
|
647
|
+
}
|
|
648
|
+
catch (err) {
|
|
649
|
+
return activationFailure(err, pending, input.resumeToken);
|
|
650
|
+
}
|
|
651
|
+
if (read.state === 'created' && read.intentId) {
|
|
652
|
+
const intentId = read.intentId;
|
|
653
|
+
const facts = await activateBudget(pending, async () => ({
|
|
654
|
+
intentId,
|
|
655
|
+
status: read.intentStatus,
|
|
656
|
+
}));
|
|
657
|
+
dropActivation(input.resumeToken);
|
|
658
|
+
return facts;
|
|
659
|
+
}
|
|
660
|
+
if (read.state === 'none') {
|
|
661
|
+
return activateBudget(pending, (i) => serverCreateIntent(pending.approvalBaseUrl, pending.mintToken, i))
|
|
662
|
+
.then((facts) => {
|
|
663
|
+
dropActivation(input.resumeToken);
|
|
664
|
+
return facts;
|
|
665
|
+
})
|
|
666
|
+
.catch((err) => activationFailure(err, pending, input.resumeToken));
|
|
667
|
+
}
|
|
668
|
+
throw new CardMandateActivationError(read.state === 'ambiguous'
|
|
669
|
+
? 'the card network never confirmed this budget intent and its outcome cannot be verified; this approval cannot be reused'
|
|
670
|
+
: read.state === 'pending'
|
|
671
|
+
? 'this budget intent is still being created; check again shortly'
|
|
672
|
+
: 'the budget activation state could not be read; check again shortly', {
|
|
673
|
+
phase: 'intent',
|
|
674
|
+
outcome: 'uncertain',
|
|
675
|
+
// Stays parked and queryable: a further resume only re-reads state
|
|
676
|
+
// and can never redispatch under this token.
|
|
677
|
+
resumable: true,
|
|
678
|
+
status: null,
|
|
679
|
+
errorCode: read.state === 'ambiguous'
|
|
680
|
+
? 'budget_intent_creation_ambiguous'
|
|
681
|
+
: read.state === 'pending'
|
|
682
|
+
? 'budget_intent_creation_pending'
|
|
683
|
+
: 'budget_intent_state_unavailable',
|
|
684
|
+
requestId: read.requestId,
|
|
685
|
+
bootstrapState: read.state,
|
|
686
|
+
resumeToken: input.resumeToken,
|
|
687
|
+
resumeExpiresAt: new Date(pending.expiresAtMs).toISOString(),
|
|
688
|
+
});
|
|
507
689
|
},
|
|
508
690
|
// PICKUP leg: the owner already approved the ceiling in the account panel
|
|
509
691
|
// and handed this runtime a single-use pickup code. Claim it, verify it was
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { Browser, BrowserContext, Page } from 'playwright-core';
|
|
2
2
|
import { type FieldMap } from './detect.js';
|
|
3
|
-
import { type Mandate } from './mandate.js';
|
|
3
|
+
import { type Mandate, type MandateRefusalCode } from './mandate.js';
|
|
4
4
|
import type { Instrument } from './instrument.js';
|
|
5
5
|
import type { Contact, OtpResolver } from './types.js';
|
|
6
6
|
import { EvidenceLog } from './evidence.js';
|
|
7
7
|
import { type ObservedOutcome } from './outcome.js';
|
|
8
8
|
import { type WebBotAuthConfig } from './web-bot-auth.js';
|
|
9
|
+
import { type NavigationRedirectEvidence } from './shopify-primary-domain.js';
|
|
9
10
|
export { minorFromDecimal, pageCurrency } from './amount.js';
|
|
10
11
|
export type CheckoutMode = 'dry-run' | 'submit';
|
|
11
12
|
export type CheckoutRoute = 'guest-card';
|
|
@@ -53,6 +54,8 @@ export type CheckoutResult = {
|
|
|
53
54
|
credentialLifecycle: CredentialLifecycle;
|
|
54
55
|
credentialTiming: CredentialTiming;
|
|
55
56
|
failureCode?: CheckoutFailureCode;
|
|
57
|
+
/** Bounded reason behind a `blocked-by-mandate` outcome; absent otherwise. */
|
|
58
|
+
refusalCode?: MandateRefusalCode;
|
|
56
59
|
detail?: string;
|
|
57
60
|
};
|
|
58
61
|
export type PrepareCheckoutOptions = {
|
|
@@ -190,7 +193,34 @@ export declare class InMemoryPreparedCheckoutStore implements PreparedCheckoutSe
|
|
|
190
193
|
private scheduleReaper;
|
|
191
194
|
private closeState;
|
|
192
195
|
}
|
|
196
|
+
export type TrustedOriginVerdict = Readonly<{
|
|
197
|
+
code: MandateRefusalCode;
|
|
198
|
+
reason: string;
|
|
199
|
+
}>;
|
|
200
|
+
export declare function trustedMerchantOriginVerdict(options: PrepareCheckoutOptions, pageUrl: string, expectedOrigin?: string): TrustedOriginVerdict | null;
|
|
193
201
|
export declare function trustedMerchantOriginRefusal(options: PrepareCheckoutOptions, pageUrl: string, expectedOrigin?: string): string | null;
|
|
202
|
+
/**
|
|
203
|
+
* Bind the storefront a trusted UCP continuation actually lands on (#8669).
|
|
204
|
+
*
|
|
205
|
+
* The merchant published its UCP business profile at its own business origin
|
|
206
|
+
* and declared the permanent `*.myshopify.com` service that issued the
|
|
207
|
+
* continuation; the CLI verified both before minting the handoff. That is
|
|
208
|
+
* independently verified merchant provenance, so the review may bind the final
|
|
209
|
+
* page origin when, and only when: the navigation started on that declared
|
|
210
|
+
* myshopify origin, the final origin is plain HTTPS, and its host is the
|
|
211
|
+
* declared business host modulo a leading `www.` label. The redirect chain in
|
|
212
|
+
* between (Shopify's primary-domain hop, its shop.app bounce, #8496) carries no
|
|
213
|
+
* authority either way: a redirect cannot land on the merchant's own business
|
|
214
|
+
* domain unless the merchant controls it, and any other final host stays an
|
|
215
|
+
* undeclared origin. Shopify's `primary_domain_redirection` proof is recorded
|
|
216
|
+
* as evidence when present but is not required.
|
|
217
|
+
*/
|
|
218
|
+
export declare function trustedShopifyAliasOrigin(args: {
|
|
219
|
+
allowedOrigins: readonly string[];
|
|
220
|
+
initialUrl: string;
|
|
221
|
+
finalUrl: string;
|
|
222
|
+
redirects: readonly NavigationRedirectEvidence[];
|
|
223
|
+
}): string | null;
|
|
194
224
|
export declare function reconcileHeldOutcome(original: ObservedOutcome, held: ObservedOutcome): ObservedOutcome;
|
|
195
225
|
export declare function debugShotMaskPlan(fields: FieldMap): {
|
|
196
226
|
skipReason: string | null;
|