@visa/cli 4.1.0-rc.14 → 4.1.0-rc.16
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/cli-engine.js +25 -8
- package/dist/checkout-engine/hosted-approval.d.ts +10 -1
- package/dist/checkout-engine/hosted-approval.js +13 -1
- package/dist/checkout-engine/vgs-gateway/fetch-credential.mjs +8 -0
- package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +49 -0
- package/dist/checkout-engine/vgs-gateway/server-mint-client.js +150 -0
- package/dist/cli.js +198 -200
- package/dist/mcp-server/index.js +84 -86
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// createCliCheckoutEngine — the review()/pay() adapter consumed by @visa/cli's
|
|
2
|
-
// pay_merchant tool. It COMPOSES
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// pay_merchant tool. It COMPOSES prepareCheckout, runHostedApproval, the
|
|
3
|
+
// server-side mint (serverCreateIntent/serverFetchCryptogram — the credential is
|
|
4
|
+
// minted by the verify-web deployment, not this machine), submitApprovedCheckout,
|
|
5
|
+
// reportVicOutcome, and receipts into a two-call API.
|
|
6
6
|
//
|
|
7
7
|
// A live browser + prepared-checkout session is held in-process between review
|
|
8
8
|
// and pay, keyed by reviewId, so the submitted checkout is the exact one the
|
|
@@ -17,7 +17,7 @@ import { launchCheckoutBrowser } from './browser-launch.js';
|
|
|
17
17
|
import { prepareCheckout as realPrepareCheckout, submitApprovedCheckout as realSubmitApprovedCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
|
|
18
18
|
import { runHostedApproval as realRunHostedApproval } from './hosted-approval.js';
|
|
19
19
|
import { VgsAssuranceInstrument, decimalToMinor, } from './vgs-live-instrument.js';
|
|
20
|
-
import {
|
|
20
|
+
import { serverCreateIntent, serverFetchCryptogram, serverPostConfirmation, } from './vgs-gateway/server-mint-client.js';
|
|
21
21
|
import { buildReceipt, writeReceipt as realWriteReceipt } from './receipt.js';
|
|
22
22
|
import { reportVicOutcome as realReportVicOutcome, } from './vic-confirmation.js';
|
|
23
23
|
const RECEIPT_DIR = join(homedir(), '.visa-mcp', 'checkout-receipts');
|
|
@@ -152,10 +152,27 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
152
152
|
target: session.target,
|
|
153
153
|
consumerEmail: session.contact.email,
|
|
154
154
|
});
|
|
155
|
+
// Server-side mint (Phase 1): the approval claim releases a scoped mint
|
|
156
|
+
// token; the credential is minted by the verify-web deployment (which
|
|
157
|
+
// holds the VGS secret), never on this machine. No token means the
|
|
158
|
+
// deployment ran the dev-auth stub — refuse loudly rather than reach for
|
|
159
|
+
// a client-held secret (there is none anymore).
|
|
160
|
+
const approvalBase = input.approvalBaseUrl;
|
|
161
|
+
const mintToken = assurance.mintToken;
|
|
162
|
+
if (!mintToken) {
|
|
163
|
+
return {
|
|
164
|
+
outcome: 'failed',
|
|
165
|
+
confirmationRef: null,
|
|
166
|
+
receiptPath: null,
|
|
167
|
+
detail: 'the approval server issued no mint token — server-side minting requires the ' +
|
|
168
|
+
'verify-web deployment to run real/turnkey auth (not the dev stub). Retry once it does.',
|
|
169
|
+
vicConfirmation: null,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
155
172
|
const instrument = new VgsAssuranceInstrument(credential, assurance, session.target, session.contact.fullName ?? '', async (mintInput) => {
|
|
156
|
-
const { intentId, status } = await
|
|
173
|
+
const { intentId, status } = await serverCreateIntent(approvalBase, mintToken, mintInput);
|
|
157
174
|
try {
|
|
158
|
-
const payment = await
|
|
175
|
+
const payment = await serverFetchCryptogram(approvalBase, mintToken, {
|
|
159
176
|
tokenId: mintInput.tokenId,
|
|
160
177
|
intentId,
|
|
161
178
|
transaction: mintInput.transaction,
|
|
@@ -185,7 +202,7 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
185
202
|
transactionAmount: session.target.transactionAmount,
|
|
186
203
|
transactionCurrencyCode: session.currency,
|
|
187
204
|
},
|
|
188
|
-
post:
|
|
205
|
+
post: (confInput) => serverPostConfirmation(approvalBase, mintToken, confInput),
|
|
189
206
|
});
|
|
190
207
|
}
|
|
191
208
|
let receiptPath = null;
|
|
@@ -66,4 +66,13 @@ export declare function resolveApprovalBaseUrl(flagValue: string | undefined, en
|
|
|
66
66
|
* assurance), so a cleartext remote origin is never acceptable.
|
|
67
67
|
*/
|
|
68
68
|
export declare function assertApprovalBaseUrl(value: string): string;
|
|
69
|
-
|
|
69
|
+
/**
|
|
70
|
+
* The claim also releases the scoped MINT TOKEN (server-side mint, Phase 1) —
|
|
71
|
+
* bound to this exact approved purchase — so the runner can call the gateway
|
|
72
|
+
* mint routes without the VGS secret. Absent when the deployment ran the
|
|
73
|
+
* dev-auth stub (it mints no token); the caller then surfaces a clear error
|
|
74
|
+
* rather than falling back to a client-held secret.
|
|
75
|
+
*/
|
|
76
|
+
export declare function runHostedApproval(opts: HostedApprovalOptions): Promise<PurchaseAssurance & {
|
|
77
|
+
mintToken?: string;
|
|
78
|
+
}>;
|
|
@@ -56,6 +56,13 @@ function stripTrailingSlashes(value) {
|
|
|
56
56
|
out = out.slice(0, -1);
|
|
57
57
|
return out;
|
|
58
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* The claim also releases the scoped MINT TOKEN (server-side mint, Phase 1) —
|
|
61
|
+
* bound to this exact approved purchase — so the runner can call the gateway
|
|
62
|
+
* mint routes without the VGS secret. Absent when the deployment ran the
|
|
63
|
+
* dev-auth stub (it mints no token); the caller then surfaces a clear error
|
|
64
|
+
* rather than falling back to a client-held secret.
|
|
65
|
+
*/
|
|
59
66
|
export async function runHostedApproval(opts) {
|
|
60
67
|
const { baseUrl, tokenId, target, consumerEmail, log = () => { }, openUrl = defaultOpenUrl, fetchImpl = fetch, sleep = defaultSleep, now = Date.now, pollIntervalMs = HOSTED_APPROVAL_POLL_MS, timeoutMs = HOSTED_APPROVAL_TIMEOUT_MS, } = opts;
|
|
61
68
|
const base = stripTrailingSlashes(assertApprovalBaseUrl(baseUrl));
|
|
@@ -143,7 +150,12 @@ export async function runHostedApproval(opts) {
|
|
|
143
150
|
}
|
|
144
151
|
}
|
|
145
152
|
log('passkey approval received from the hosted page.');
|
|
146
|
-
return
|
|
153
|
+
return {
|
|
154
|
+
...assuranceFromCeremony(target, doc.assuranceData),
|
|
155
|
+
...(typeof doc.mintToken === 'string' && doc.mintToken
|
|
156
|
+
? { mintToken: doc.mintToken }
|
|
157
|
+
: {}),
|
|
158
|
+
};
|
|
147
159
|
}
|
|
148
160
|
// status 'pending' — the operator is still signing in / tapping.
|
|
149
161
|
}
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
// ⚠️ RETIRED CLIENT-SECRET PATH — used ONLY by the local `pnpm fill:live` spike
|
|
2
|
+
// (run-live-fill.ts) and the older pay.ts dev entrypoints, NOT by the shipped
|
|
3
|
+
// pay_merchant CLI. The productionized CLI (cli-engine.ts) mints SERVER-SIDE via
|
|
4
|
+
// a scoped mint token (vgs-gateway/server-mint-client.ts) and never holds
|
|
5
|
+
// VGS_USERNAME/VGS_PASSWORD. This module (and its spike callers) are slated for
|
|
6
|
+
// deletion in the Phase-1 follow-up; do NOT add new callers. The
|
|
7
|
+
// no-client-vgs-secret gate keeps the shipped path clear of everything below.
|
|
8
|
+
//
|
|
1
9
|
// Real credential fetch — NO mocks. Calls the live VGS agentic gateway to mint a
|
|
2
10
|
// fresh, intent-scoped payment credential (DPAN + expiry + the 3-digit DAVV) and
|
|
3
11
|
// returns it in memory. Same wire calls the enrollment harness (../server-e2e.mjs)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { VgsCheckoutTarget, VgsPaymentCredential } from '../vgs-live-instrument.js';
|
|
2
|
+
export type ServerMintDeps = {
|
|
3
|
+
/** Injectable for tests — defaults to global fetch. */
|
|
4
|
+
fetchImpl?: typeof fetch;
|
|
5
|
+
/** Injectable for tests — never wall-clock-sleep in a unit test. */
|
|
6
|
+
sleep?: (ms: number) => Promise<void>;
|
|
7
|
+
env?: NodeJS.ProcessEnv;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Create a fresh intent via POST {base}/api/vgs/intent. The server holds the VGS
|
|
11
|
+
* credential and calls the gateway; we send the same mandate shape the local
|
|
12
|
+
* path built (cap = ceil(amount)+10, min 25; merchant category Retail/5999).
|
|
13
|
+
*/
|
|
14
|
+
export declare function serverCreateIntent(base: string, mintToken: string, input: {
|
|
15
|
+
tokenId: string;
|
|
16
|
+
assuranceData: unknown;
|
|
17
|
+
transaction: VgsCheckoutTarget;
|
|
18
|
+
}, deps?: ServerMintDeps): Promise<{
|
|
19
|
+
intentId: string;
|
|
20
|
+
status: string | null;
|
|
21
|
+
}>;
|
|
22
|
+
/**
|
|
23
|
+
* Mint the FULL payment credential via POST {base}/api/vgs/payment-cryptogram.
|
|
24
|
+
* The server's route does a SINGLE gateway call and 502s on a not-COMPLETED
|
|
25
|
+
* (e.g. PENDING) cryptogram, but live intent approval is asynchronous and the
|
|
26
|
+
* first cryptogram answer can be PENDING (#5709). So we retry the route on any
|
|
27
|
+
* non-2xx up to PENDING_ATTEMPTS, matching fetch-credential.mjs's cadence
|
|
28
|
+
* exactly — a genuinely hard failure just surfaces after the same bounded wait.
|
|
29
|
+
* Re-POSTing for the same intentId is idempotent (mirrors the old client loop).
|
|
30
|
+
*/
|
|
31
|
+
export declare function serverFetchCryptogram(base: string, mintToken: string, input: {
|
|
32
|
+
tokenId: string;
|
|
33
|
+
intentId: string;
|
|
34
|
+
transaction: VgsCheckoutTarget;
|
|
35
|
+
}, deps?: ServerMintDeps): Promise<VgsPaymentCredential>;
|
|
36
|
+
/** Report the observed merchant outcome via POST {base}/api/vgs/confirmation. */
|
|
37
|
+
export declare function serverPostConfirmation(base: string, mintToken: string, input: {
|
|
38
|
+
tokenId: string;
|
|
39
|
+
intentId: string;
|
|
40
|
+
transactionStatus: string;
|
|
41
|
+
transactionType: string;
|
|
42
|
+
transactionTimestamp: string;
|
|
43
|
+
transaction: {
|
|
44
|
+
transactionAmount: string;
|
|
45
|
+
transactionCurrencyCode: string;
|
|
46
|
+
};
|
|
47
|
+
}, deps?: ServerMintDeps): Promise<{
|
|
48
|
+
ok: true;
|
|
49
|
+
}>;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Server-side mint client (Phase 1) — the checkout runner mints the payment
|
|
2
|
+
// credential by calling the verify-web deployment's gateway routes with a
|
|
3
|
+
// short-lived, purpose-scoped MINT TOKEN, instead of holding the shared VGS
|
|
4
|
+
// service-account secret (the gateway client id/secret) on the machine.
|
|
5
|
+
//
|
|
6
|
+
// The mint token is issued at approval time (agent-approval/complete, which is
|
|
7
|
+
// session + internal-tester + token-ownership gated) and released to this
|
|
8
|
+
// runner by the claim leg. It is bound to the exact approved purchase, so these
|
|
9
|
+
// calls can only mint the credential the human approved.
|
|
10
|
+
//
|
|
11
|
+
// Request derivation here mirrors ../fetch-credential.mjs EXACTLY (mandate cap,
|
|
12
|
+
// consumer prompt, PENDING retry cadence) — the only change is the transport:
|
|
13
|
+
// verify-web routes + Bearer mint token, never the VGS gateway + VGS secret.
|
|
14
|
+
const defaultSleep = (ms) => new Promise((r) => {
|
|
15
|
+
const t = setTimeout(r, ms);
|
|
16
|
+
t.unref?.();
|
|
17
|
+
});
|
|
18
|
+
function stripTrailingSlashes(value) {
|
|
19
|
+
let out = value;
|
|
20
|
+
while (out.endsWith('/'))
|
|
21
|
+
out = out.slice(0, -1);
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
/** Read a stable, non-secret error message from a route's JSON body. */
|
|
25
|
+
async function routeError(res) {
|
|
26
|
+
const doc = (await res.json().catch(() => null));
|
|
27
|
+
return doc?.error || doc?.error_code || `HTTP ${res.status}`;
|
|
28
|
+
}
|
|
29
|
+
function bearer(mintToken) {
|
|
30
|
+
return { 'content-type': 'application/json', authorization: `Bearer ${mintToken}` };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Create a fresh intent via POST {base}/api/vgs/intent. The server holds the VGS
|
|
34
|
+
* credential and calls the gateway; we send the same mandate shape the local
|
|
35
|
+
* path built (cap = ceil(amount)+10, min 25; merchant category Retail/5999).
|
|
36
|
+
*/
|
|
37
|
+
export async function serverCreateIntent(base, mintToken, input, deps = {}) {
|
|
38
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
39
|
+
const { tokenId, assuranceData, transaction: t } = input;
|
|
40
|
+
const cap = Math.max(Math.ceil(Number(t.transactionAmount) || 0) + 10, 25);
|
|
41
|
+
const res = await fetchImpl(`${stripTrailingSlashes(base)}/api/vgs/intent`, {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: bearer(mintToken),
|
|
44
|
+
body: JSON.stringify({
|
|
45
|
+
tokenId,
|
|
46
|
+
consumerPrompt: `Buy an item from ${t.merchantName} for ${t.transactionCurrencyCode.toUpperCase()} ${t.transactionAmount}`,
|
|
47
|
+
assuranceData,
|
|
48
|
+
mandates: [
|
|
49
|
+
{
|
|
50
|
+
description: `Purchase at ${t.merchantName}`,
|
|
51
|
+
declineThresholdAmount: String(cap),
|
|
52
|
+
declineThresholdCurrencyCode: t.transactionCurrencyCode.toUpperCase(),
|
|
53
|
+
effectiveUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
|
54
|
+
merchantCategory: 'Retail',
|
|
55
|
+
merchantCategoryCode: '5999',
|
|
56
|
+
preferredMerchantName: t.merchantName,
|
|
57
|
+
quantity: 1,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
62
|
+
if (!res.ok)
|
|
63
|
+
throw new Error(`server intent failed (${res.status}): ${await routeError(res)}`);
|
|
64
|
+
const doc = (await res.json().catch(() => null));
|
|
65
|
+
if (!doc?.intentId)
|
|
66
|
+
throw new Error('server intent response missing intentId');
|
|
67
|
+
return { intentId: doc.intentId, status: typeof doc.status === 'string' ? doc.status : null };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Mint the FULL payment credential via POST {base}/api/vgs/payment-cryptogram.
|
|
71
|
+
* The server's route does a SINGLE gateway call and 502s on a not-COMPLETED
|
|
72
|
+
* (e.g. PENDING) cryptogram, but live intent approval is asynchronous and the
|
|
73
|
+
* first cryptogram answer can be PENDING (#5709). So we retry the route on any
|
|
74
|
+
* non-2xx up to PENDING_ATTEMPTS, matching fetch-credential.mjs's cadence
|
|
75
|
+
* exactly — a genuinely hard failure just surfaces after the same bounded wait.
|
|
76
|
+
* Re-POSTing for the same intentId is idempotent (mirrors the old client loop).
|
|
77
|
+
*/
|
|
78
|
+
export async function serverFetchCryptogram(base, mintToken, input, deps = {}) {
|
|
79
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
80
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
81
|
+
const env = deps.env ?? process.env;
|
|
82
|
+
const attempts = Math.max(1, Number(env.VGS_CRYPTOGRAM_PENDING_ATTEMPTS) || 4);
|
|
83
|
+
const waitMs = Number.isFinite(Number(env.VGS_CRYPTOGRAM_PENDING_WAIT_MS))
|
|
84
|
+
? Number(env.VGS_CRYPTOGRAM_PENDING_WAIT_MS)
|
|
85
|
+
: 5000;
|
|
86
|
+
const { tokenId, intentId, transaction: t } = input;
|
|
87
|
+
const body = JSON.stringify({
|
|
88
|
+
tokenId,
|
|
89
|
+
intentId,
|
|
90
|
+
transaction: {
|
|
91
|
+
merchantName: t.merchantName,
|
|
92
|
+
merchantUrl: t.merchantUrl,
|
|
93
|
+
merchantCountryCode: t.merchantCountryCode,
|
|
94
|
+
transactionAmount: t.transactionAmount,
|
|
95
|
+
transactionCurrencyCode: t.transactionCurrencyCode.toUpperCase(),
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
let lastError = 'unknown';
|
|
99
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
100
|
+
const res = await fetchImpl(`${stripTrailingSlashes(base)}/api/vgs/payment-cryptogram`, {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: bearer(mintToken),
|
|
103
|
+
body,
|
|
104
|
+
});
|
|
105
|
+
if (res.ok) {
|
|
106
|
+
const c = (await res.json().catch(() => null));
|
|
107
|
+
if (!c || typeof c.networkToken !== 'string' || typeof c.cryptogramValue !== 'string') {
|
|
108
|
+
throw new Error('server cryptogram response missing credential fields');
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
networkToken: c.networkToken,
|
|
112
|
+
expMonth: Number(c.expMonth) || 0,
|
|
113
|
+
expYear: Number(c.expYear) || 0,
|
|
114
|
+
cryptogramType: c.cryptogramType ?? '',
|
|
115
|
+
cryptogramValue: c.cryptogramValue,
|
|
116
|
+
...(typeof c.cryptogramExpiresAt === 'string'
|
|
117
|
+
? { cryptogramExpiresAt: c.cryptogramExpiresAt }
|
|
118
|
+
: {}),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// A 4xx (bad request / binding refusal / auth) is terminal — never retry it.
|
|
122
|
+
if (res.status >= 400 && res.status < 500) {
|
|
123
|
+
throw new Error(`server cryptogram refused (${res.status}): ${await routeError(res)}`);
|
|
124
|
+
}
|
|
125
|
+
lastError = `${res.status}: ${await routeError(res)}`;
|
|
126
|
+
if (attempt < attempts)
|
|
127
|
+
await sleep(waitMs);
|
|
128
|
+
}
|
|
129
|
+
throw new Error(`server cryptogram not completed after ${attempts} attempts (last: ${lastError})`);
|
|
130
|
+
}
|
|
131
|
+
/** Report the observed merchant outcome via POST {base}/api/vgs/confirmation. */
|
|
132
|
+
export async function serverPostConfirmation(base, mintToken, input, deps = {}) {
|
|
133
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
134
|
+
const res = await fetchImpl(`${stripTrailingSlashes(base)}/api/vgs/confirmation`, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: bearer(mintToken),
|
|
137
|
+
body: JSON.stringify({
|
|
138
|
+
tokenId: input.tokenId,
|
|
139
|
+
intentId: input.intentId,
|
|
140
|
+
transactionStatus: input.transactionStatus,
|
|
141
|
+
transactionType: input.transactionType,
|
|
142
|
+
transactionTimestamp: input.transactionTimestamp,
|
|
143
|
+
transactionAmount: input.transaction.transactionAmount,
|
|
144
|
+
transactionCurrencyCode: input.transaction.transactionCurrencyCode.toUpperCase(),
|
|
145
|
+
}),
|
|
146
|
+
});
|
|
147
|
+
if (!res.ok)
|
|
148
|
+
throw new Error(`server confirmation failed (${res.status}): ${await routeError(res)}`);
|
|
149
|
+
return { ok: true };
|
|
150
|
+
}
|