@visa/cli 4.1.0-rc.2 → 4.1.0-rc.200
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +200 -226
- package/dist/checkout-engine/adapters/generic.d.ts +88 -0
- package/dist/checkout-engine/adapters/generic.js +526 -0
- package/dist/checkout-engine/adapters/index.d.ts +10 -0
- package/dist/checkout-engine/adapters/index.js +24 -0
- package/dist/checkout-engine/adapters/shopify.d.ts +55 -0
- package/dist/checkout-engine/adapters/shopify.js +514 -0
- package/dist/checkout-engine/adapters/stripe-like.d.ts +10 -0
- package/dist/checkout-engine/adapters/stripe-like.js +21 -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 +46 -0
- package/dist/checkout-engine/browser-launch.js +81 -0
- package/dist/checkout-engine/ceremony.d.ts +64 -0
- package/dist/checkout-engine/ceremony.js +261 -0
- package/dist/checkout-engine/cli-engine.d.ts +315 -0
- package/dist/checkout-engine/cli-engine.js +996 -0
- 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 +61 -0
- package/dist/checkout-engine/detect.js +398 -0
- package/dist/checkout-engine/evidence.d.ts +25 -0
- package/dist/checkout-engine/evidence.js +104 -0
- package/dist/checkout-engine/executor.d.ts +215 -0
- package/dist/checkout-engine/executor.js +1520 -0
- package/dist/checkout-engine/hosted-approval.d.ts +195 -0
- package/dist/checkout-engine/hosted-approval.js +498 -0
- package/dist/checkout-engine/index.d.ts +9 -0
- package/dist/checkout-engine/index.js +11 -0
- package/dist/checkout-engine/instrument.d.ts +61 -0
- package/dist/checkout-engine/instrument.js +87 -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 +37 -0
- package/dist/checkout-engine/live-fill-approval.js +76 -0
- package/dist/checkout-engine/mandate/card-mandate.d.ts +121 -0
- package/dist/checkout-engine/mandate/card-mandate.js +227 -0
- package/dist/checkout-engine/mandate/mandate-ledger.d.ts +178 -0
- package/dist/checkout-engine/mandate/mandate-ledger.js +395 -0
- package/dist/checkout-engine/mandate.d.ts +25 -0
- package/dist/checkout-engine/mandate.js +100 -0
- package/dist/checkout-engine/outcome.d.ts +30 -0
- package/dist/checkout-engine/outcome.js +225 -0
- package/dist/checkout-engine/owner-only-file.d.ts +19 -0
- package/dist/checkout-engine/owner-only-file.js +41 -0
- package/dist/checkout-engine/package.json +3 -0
- 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 +121 -0
- package/dist/checkout-engine/receipt.js +138 -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 +52 -0
- package/dist/checkout-engine/types.js +2 -0
- package/dist/checkout-engine/unresolved-charges.d.ts +34 -0
- package/dist/checkout-engine/unresolved-charges.js +125 -0
- package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +101 -0
- package/dist/checkout-engine/vgs-gateway/server-mint-client.js +218 -0
- package/dist/checkout-engine/vgs-live-instrument.d.ts +144 -0
- package/dist/checkout-engine/vgs-live-instrument.js +229 -0
- package/dist/checkout-engine/vic-confirmation.d.ts +52 -0
- package/dist/checkout-engine/vic-confirmation.js +45 -0
- package/dist/checkout-engine/web-bot-auth.d.ts +92 -0
- package/dist/checkout-engine/web-bot-auth.js +159 -0
- package/dist/cli.js +721 -445
- package/dist/mcp-apps/ucp-checkout.html +280 -0
- package/dist/mcp-server/index.js +573 -175
- package/dist/skills/pair-visa-agent/RUNTIMES.md +93 -0
- package/dist/skills/pair-visa-agent/SKILL.md +557 -0
- package/dist/skills/pair-visa-agent/scripts/setup.mjs +48 -0
- package/dist/subway-direct.mjs +1 -0
- package/install.ps1 +5 -43
- package/install.sh +5 -37
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +33 -25
- package/server.json +4 -4
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser provisioning for the checkout engine.
|
|
3
|
+
*
|
|
4
|
+
* @purpose The engine depends on `playwright-core` (NOT `playwright`) so that
|
|
5
|
+
* installing @visa/cli never triggers a postinstall Chromium download. A real
|
|
6
|
+
* browser is resolved lazily, on the first checkout, in this order:
|
|
7
|
+
*
|
|
8
|
+
* 1. VISA_CHECKOUT_BROWSER — explicit executable path override (deterministic
|
|
9
|
+
* for CI / pinned environments; wins over everything). If it is set but
|
|
10
|
+
* FAILS to launch, we ABORT loudly rather than fall through — a pinned
|
|
11
|
+
* money flow must not run in a browser the operator did not choose.
|
|
12
|
+
* 2. channel: 'chrome' — a system-installed Google Chrome (no download).
|
|
13
|
+
* 3. channel: 'msedge' — a system-installed Microsoft Edge (no download).
|
|
14
|
+
* 4. default chromium — a playwright-managed browser, IF one was already
|
|
15
|
+
* installed via `npx playwright-core install chromium` (still no download
|
|
16
|
+
* here — it only USES an existing one; launch throws if absent).
|
|
17
|
+
*
|
|
18
|
+
* If none launch, we throw one actionable error listing every attempt and the
|
|
19
|
+
* three ways to fix it. We never silently download hundreds of MB mid-command;
|
|
20
|
+
* provisioning stays an explicit user choice (install Chrome, run the documented
|
|
21
|
+
* `playwright-core install`, or point VISA_CHECKOUT_BROWSER at a binary).
|
|
22
|
+
*
|
|
23
|
+
* The install command is `playwright-core`, NOT `playwright`: this package no
|
|
24
|
+
* longer depends on the full `playwright`, so `npx playwright …` would fetch an
|
|
25
|
+
* unrelated latest `playwright` and can install a browser revision that does not
|
|
26
|
+
* match the pinned `playwright-core` — the very launch this is meant to fix.
|
|
27
|
+
* `npx playwright-core install` uses the installed, version-matched package.
|
|
28
|
+
*/
|
|
29
|
+
import { chromium } from 'playwright-core';
|
|
30
|
+
const HEADFUL = { headless: false };
|
|
31
|
+
/** Build the ordered launch attempts for the current environment. Exported for
|
|
32
|
+
* unit testing the resolution order without launching anything. */
|
|
33
|
+
export function buildLaunchAttempts(env) {
|
|
34
|
+
const attempts = [];
|
|
35
|
+
const override = env.VISA_CHECKOUT_BROWSER?.trim();
|
|
36
|
+
if (override) {
|
|
37
|
+
attempts.push({
|
|
38
|
+
label: `VISA_CHECKOUT_BROWSER (${override})`,
|
|
39
|
+
opts: { ...HEADFUL, executablePath: override },
|
|
40
|
+
override: true,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
attempts.push({
|
|
44
|
+
label: 'system Chrome (channel=chrome)',
|
|
45
|
+
opts: { ...HEADFUL, channel: 'chrome' },
|
|
46
|
+
});
|
|
47
|
+
attempts.push({ label: 'system Edge (channel=msedge)', opts: { ...HEADFUL, channel: 'msedge' } });
|
|
48
|
+
attempts.push({ label: 'playwright-managed chromium', opts: { ...HEADFUL } });
|
|
49
|
+
return attempts;
|
|
50
|
+
}
|
|
51
|
+
export async function launchCheckoutBrowser(deps = {}) {
|
|
52
|
+
const env = deps.env ?? process.env;
|
|
53
|
+
const launch = deps.launch ?? ((opts) => chromium.launch(opts));
|
|
54
|
+
const log = deps.log ?? (() => { });
|
|
55
|
+
const errors = [];
|
|
56
|
+
for (const attempt of buildLaunchAttempts(env)) {
|
|
57
|
+
try {
|
|
58
|
+
const browser = await launch(attempt.opts);
|
|
59
|
+
log(`checkout browser: launched via ${attempt.label}`);
|
|
60
|
+
return browser;
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
const first = err.message.split('\n')[0];
|
|
64
|
+
if (attempt.override) {
|
|
65
|
+
// An explicit VISA_CHECKOUT_BROWSER is a deterministic, pinned choice
|
|
66
|
+
// (CI / a locked-down operator env). If it fails to launch, ABORT loudly
|
|
67
|
+
// rather than silently substituting a system browser the operator did
|
|
68
|
+
// not pin — this is a money flow. Unsetting the var opts back into the
|
|
69
|
+
// system-browser fallback below.
|
|
70
|
+
throw new Error(`VISA_CHECKOUT_BROWSER="${env.VISA_CHECKOUT_BROWSER?.trim()}" failed to launch: ${first}. ` +
|
|
71
|
+
'Fix the path/binary, or unset VISA_CHECKOUT_BROWSER to allow a system-browser ' +
|
|
72
|
+
'fallback — refusing to substitute an unpinned browser for a checkout.');
|
|
73
|
+
}
|
|
74
|
+
errors.push(`${attempt.label}: ${first}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
throw new Error('Could not launch a browser for checkout. Tried — ' +
|
|
78
|
+
errors.join(' | ') +
|
|
79
|
+
'. Install Google Chrome, or run `npx playwright-core install chromium` ' +
|
|
80
|
+
'(matches the pinned version), or set VISA_CHECKOUT_BROWSER to a browser executable path.');
|
|
81
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { PurchaseAssurance, VgsCheckoutTarget } from './vgs-live-instrument.js';
|
|
2
|
+
/** ISO-4217 numeric codes for the consent screen — mirrors the dev-harness map. */
|
|
3
|
+
export declare const CURRENCY_NUMERIC: Record<string, string>;
|
|
4
|
+
export declare function currencyNumeric(code: string): string | null;
|
|
5
|
+
/**
|
|
6
|
+
* Build the runner-side PurchaseAssurance from a completed ceremony. Scope
|
|
7
|
+
* fields come verbatim from the checkout target, so the instrument's
|
|
8
|
+
* scope-alignment validation (#5709) is satisfied by construction; `now` is
|
|
9
|
+
* injectable for tests.
|
|
10
|
+
*/
|
|
11
|
+
export declare function assuranceFromCeremony(target: VgsCheckoutTarget, assuranceData: unknown, now?: Date): PurchaseAssurance;
|
|
12
|
+
export type CeremonyPageConfig = {
|
|
13
|
+
tokenId: string;
|
|
14
|
+
environment: 'live' | 'sandbox';
|
|
15
|
+
consumerEmail: string;
|
|
16
|
+
merchantName: string;
|
|
17
|
+
/** Decimal major-unit amount, e.g. "1.00" — drives the consent screen. */
|
|
18
|
+
amount: string;
|
|
19
|
+
/** Display currency, e.g. "USD". */
|
|
20
|
+
currency: string;
|
|
21
|
+
/** ISO-4217 numeric code for the SDK, e.g. "840". */
|
|
22
|
+
currencyNumericCode: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* The single-purpose approval page. Pure string builder so tests can assert
|
|
26
|
+
* the config embed is intact and escape-safe. The config is embedded as JSON
|
|
27
|
+
* with `<` escaped, so a hostile merchant name cannot break out of the script
|
|
28
|
+
* element.
|
|
29
|
+
*/
|
|
30
|
+
export declare function ceremonyPageHtml(cfg: CeremonyPageConfig): string;
|
|
31
|
+
export type RunCeremonyOptions = {
|
|
32
|
+
page: CeremonyPageConfig;
|
|
33
|
+
target: VgsCheckoutTarget;
|
|
34
|
+
/** Contents of the vendored VgsAgenticAuth module, served to the page. */
|
|
35
|
+
vendorSdkJs: string;
|
|
36
|
+
/** Short-lived VGS bearer for the ceremony SDK (client-credentials mint). */
|
|
37
|
+
mintAccessToken: () => Promise<string>;
|
|
38
|
+
/**
|
|
39
|
+
* TLS material for the loopback server. The Visa device-binding iframe
|
|
40
|
+
* requires an HTTPS parent page, so real callers must pass this; `null`
|
|
41
|
+
* serves plain HTTP and exists ONLY so tests can exercise the server
|
|
42
|
+
* without certificates.
|
|
43
|
+
*/
|
|
44
|
+
tls: {
|
|
45
|
+
key: string | Buffer;
|
|
46
|
+
cert: string | Buffer;
|
|
47
|
+
} | null;
|
|
48
|
+
/** Default 4400 — the origin the operator's browser already trusts. */
|
|
49
|
+
port?: number;
|
|
50
|
+
/** Overall wait for the human to complete the ceremony. Default 4 minutes. */
|
|
51
|
+
timeoutMs?: number;
|
|
52
|
+
/** Open the approval page in the operator's browser. Default: macOS `open`. */
|
|
53
|
+
openUrl?: (url: string) => void;
|
|
54
|
+
/** Status output. Default: stderr. */
|
|
55
|
+
log?: (line: string) => void;
|
|
56
|
+
};
|
|
57
|
+
export declare const CEREMONY_DEFAULT_PORT = 4400;
|
|
58
|
+
export declare const CEREMONY_DEFAULT_TIMEOUT_MS: number;
|
|
59
|
+
/**
|
|
60
|
+
* Serve the approval page on loopback, open it in the operator's browser, and
|
|
61
|
+
* resolve with the fresh PurchaseAssurance once the passkey completes. Rejects
|
|
62
|
+
* on timeout, port conflict, or server failure; the server is always closed.
|
|
63
|
+
*/
|
|
64
|
+
export declare function runInteractiveCeremony(opts: RunCeremonyOptions): Promise<PurchaseAssurance>;
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import { exec } from 'node:child_process';
|
|
4
|
+
// In-run device-binding ceremony — replaces the manual dev-harness tab and the
|
|
5
|
+
// exported assurance file. After the operator types the approval phrase, the
|
|
6
|
+
// runner serves a single-purpose loopback page, opens it in the operator's
|
|
7
|
+
// normal browser (where their passkey, popup allowance, and cert exception
|
|
8
|
+
// already live), and receives the ceremony's assuranceData back in-process.
|
|
9
|
+
// The assurance is therefore seconds old at intent mint (the only
|
|
10
|
+
// proven-reliable configuration — see the 2026-07-17 PENDING-intent runs) and
|
|
11
|
+
// never touches disk.
|
|
12
|
+
//
|
|
13
|
+
// Security posture (same as the dev-harness, contained tighter):
|
|
14
|
+
// - The server binds 127.0.0.1 ONLY. /api/token hands the page a short-lived
|
|
15
|
+
// VGS bearer, so the server must never be reachable from another machine.
|
|
16
|
+
// - assuranceData exists in process memory only; nothing is persisted.
|
|
17
|
+
// - The page carries no card data and no service-account secret.
|
|
18
|
+
/** ISO-4217 numeric codes for the consent screen — mirrors the dev-harness map. */
|
|
19
|
+
export const CURRENCY_NUMERIC = {
|
|
20
|
+
USD: '840',
|
|
21
|
+
CAD: '124',
|
|
22
|
+
EUR: '978',
|
|
23
|
+
GBP: '826',
|
|
24
|
+
AUD: '036',
|
|
25
|
+
};
|
|
26
|
+
export function currencyNumeric(code) {
|
|
27
|
+
return CURRENCY_NUMERIC[code.toUpperCase()] ?? null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Build the runner-side PurchaseAssurance from a completed ceremony. Scope
|
|
31
|
+
* fields come verbatim from the checkout target, so the instrument's
|
|
32
|
+
* scope-alignment validation (#5709) is satisfied by construction; `now` is
|
|
33
|
+
* injectable for tests.
|
|
34
|
+
*/
|
|
35
|
+
export function assuranceFromCeremony(target, assuranceData, now = new Date()) {
|
|
36
|
+
return {
|
|
37
|
+
assuranceData,
|
|
38
|
+
mintedAt: now.toISOString(),
|
|
39
|
+
merchantHost: new URL(target.merchantUrl).hostname,
|
|
40
|
+
transactionAmount: target.transactionAmount,
|
|
41
|
+
transactionCurrencyCode: target.transactionCurrencyCode,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The single-purpose approval page. Pure string builder so tests can assert
|
|
46
|
+
* the config embed is intact and escape-safe. The config is embedded as JSON
|
|
47
|
+
* with `<` escaped, so a hostile merchant name cannot break out of the script
|
|
48
|
+
* element.
|
|
49
|
+
*/
|
|
50
|
+
export function ceremonyPageHtml(cfg) {
|
|
51
|
+
const esc = (s) => s.replace(/[&<>"']/g, (ch) => {
|
|
52
|
+
const map = {
|
|
53
|
+
'&': '&',
|
|
54
|
+
'<': '<',
|
|
55
|
+
'>': '>',
|
|
56
|
+
'"': '"',
|
|
57
|
+
"'": ''',
|
|
58
|
+
};
|
|
59
|
+
return map[ch];
|
|
60
|
+
});
|
|
61
|
+
const json = JSON.stringify(cfg).replace(/</g, '\\u003c');
|
|
62
|
+
return `<!doctype html>
|
|
63
|
+
<html>
|
|
64
|
+
<head>
|
|
65
|
+
<meta charset="utf-8" />
|
|
66
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
67
|
+
<title>Approve purchase — Visa</title>
|
|
68
|
+
<style>
|
|
69
|
+
body { font: 15px/1.5 Menlo, 'SF Mono', monospace; max-width: 640px; margin: 40px auto; padding: 0 16px; color: #1a1a1a; background: #fff; }
|
|
70
|
+
h1 { font-size: 16px; }
|
|
71
|
+
.facts { border: 1px solid #e5e5e5; border-radius: 12px; padding: 14px; margin: 14px 0; background: #fafafa; }
|
|
72
|
+
.facts b { font-size: 18px; }
|
|
73
|
+
button { font: inherit; padding: 10px 16px; margin: 6px 6px 6px 0; border: 1px solid #111; background: #111; color: #fff; border-radius: 8px; cursor: pointer; }
|
|
74
|
+
button:disabled { opacity: .4; cursor: default; }
|
|
75
|
+
button.ghost { background: #fff; color: #111; }
|
|
76
|
+
input { font: inherit; padding: 8px; border: 1px solid #d4d4d4; border-radius: 8px; }
|
|
77
|
+
#iframeBox { border: 2px dashed #d4d4d4; border-radius: 12px; min-height: 90px; padding: 8px; margin: 12px 0; }
|
|
78
|
+
#log { color: #666; font-size: 12.5px; white-space: pre-wrap; }
|
|
79
|
+
#otpBox { display: none; margin: 10px 0; }
|
|
80
|
+
.ok { color: #16a34a } .err { color: #be123c }
|
|
81
|
+
</style>
|
|
82
|
+
</head>
|
|
83
|
+
<body>
|
|
84
|
+
<h1>Approve this purchase with your passkey</h1>
|
|
85
|
+
<div class="facts">${esc(cfg.merchantName)}<br /><b>${esc(cfg.currency)} ${esc(cfg.amount)}</b></div>
|
|
86
|
+
<button id="go">Start approval</button>
|
|
87
|
+
<button id="tap" disabled>Approve with passkey</button>
|
|
88
|
+
<div id="otpBox"><span id="otpMethods"></span> <input id="otpCode" placeholder="code" style="width:120px" /> <button id="otpSubmit" class="ghost">Submit code</button></div>
|
|
89
|
+
<div id="iframeBox"></div>
|
|
90
|
+
<div id="log">The agent is waiting in the terminal. Complete the approval here, then return to it.</div>
|
|
91
|
+
<script type="application/json" id="cfg">${json}</script>
|
|
92
|
+
<script type="module">
|
|
93
|
+
import { VgsAgenticAuth } from './vgs-agentic-auth.js'
|
|
94
|
+
const cfg = JSON.parse(document.getElementById('cfg').textContent)
|
|
95
|
+
const log = (m, c = '') => { const el = document.getElementById('log'); el.className = c; el.textContent = m }
|
|
96
|
+
let session = null
|
|
97
|
+
document.getElementById('go').onclick = async () => {
|
|
98
|
+
const go = document.getElementById('go')
|
|
99
|
+
go.disabled = true
|
|
100
|
+
try {
|
|
101
|
+
log('starting the Visa session…')
|
|
102
|
+
const { access_token, error } = await (await fetch('/api/token')).json()
|
|
103
|
+
if (error) throw new Error(error)
|
|
104
|
+
const flow = new VgsAgenticAuth({
|
|
105
|
+
tokenId: cfg.tokenId, environment: cfg.environment, consumerEmail: cfg.consumerEmail,
|
|
106
|
+
accessToken: access_token, timeout: 180000,
|
|
107
|
+
authenticationAmount: cfg.amount, currencyCode: cfg.currencyNumericCode, merchantName: cfg.merchantName,
|
|
108
|
+
})
|
|
109
|
+
const box = document.getElementById('iframeBox'); box.innerHTML = ''
|
|
110
|
+
session = await flow.startSession(box)
|
|
111
|
+
if (session.needsOtp) {
|
|
112
|
+
const b = document.getElementById('otpBox'), d = document.getElementById('otpMethods')
|
|
113
|
+
b.style.display = 'block'; d.innerHTML = ''
|
|
114
|
+
log('your bank wants a one-time code first — pick a delivery method.')
|
|
115
|
+
for (const m of session.otpMethods || []) {
|
|
116
|
+
const btn = document.createElement('button'); btn.className = 'ghost'
|
|
117
|
+
btn.textContent = 'Send via ' + (m.method || m.identifier)
|
|
118
|
+
btn.onclick = async () => { try { await session.requestOtp(m); log('code sent — type it and submit.') } catch (e) { log('sending the code failed: ' + e.message, 'err') } }
|
|
119
|
+
d.appendChild(btn)
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
log('ready — click "Approve with passkey".', 'ok')
|
|
123
|
+
document.getElementById('tap').disabled = false
|
|
124
|
+
}
|
|
125
|
+
} catch (e) {
|
|
126
|
+
log('could not start: ' + (e && e.message || e) + ' — click "Start approval" to retry.', 'err')
|
|
127
|
+
try { session && session.destroy && session.destroy() } catch {}
|
|
128
|
+
session = null
|
|
129
|
+
go.disabled = false
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
document.getElementById('otpSubmit').onclick = async () => {
|
|
133
|
+
try {
|
|
134
|
+
await session.submitOtp(document.getElementById('otpCode').value.trim())
|
|
135
|
+
document.getElementById('otpBox').style.display = 'none'
|
|
136
|
+
log('code accepted — click "Approve with passkey".', 'ok')
|
|
137
|
+
document.getElementById('tap').disabled = false
|
|
138
|
+
} catch (e) { log('code rejected: ' + (e && e.message || e), 'err') }
|
|
139
|
+
}
|
|
140
|
+
document.getElementById('tap').onclick = async () => {
|
|
141
|
+
const tap = document.getElementById('tap')
|
|
142
|
+
tap.disabled = true
|
|
143
|
+
try {
|
|
144
|
+
log('waiting for your passkey…')
|
|
145
|
+
const assuranceData = await session.authenticate()
|
|
146
|
+
try { session.destroy && session.destroy() } catch {}
|
|
147
|
+
const r = await (await fetch('/api/assurance', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ assuranceData }) })).json()
|
|
148
|
+
if (r.error) throw new Error(r.error)
|
|
149
|
+
document.getElementById('go').disabled = true
|
|
150
|
+
log('approved — return to the terminal. You can close this tab.', 'ok')
|
|
151
|
+
} catch (e) {
|
|
152
|
+
log('passkey failed: ' + (e && e.message || e) + ' — click "Start approval" to retry.', 'err')
|
|
153
|
+
try { session && session.destroy && session.destroy() } catch {}
|
|
154
|
+
session = null
|
|
155
|
+
document.getElementById('go').disabled = false
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
</script>
|
|
159
|
+
</body>
|
|
160
|
+
</html>
|
|
161
|
+
`;
|
|
162
|
+
}
|
|
163
|
+
export const CEREMONY_DEFAULT_PORT = 4400;
|
|
164
|
+
export const CEREMONY_DEFAULT_TIMEOUT_MS = 4 * 60 * 1000;
|
|
165
|
+
/**
|
|
166
|
+
* Serve the approval page on loopback, open it in the operator's browser, and
|
|
167
|
+
* resolve with the fresh PurchaseAssurance once the passkey completes. Rejects
|
|
168
|
+
* on timeout, port conflict, or server failure; the server is always closed.
|
|
169
|
+
*/
|
|
170
|
+
export function runInteractiveCeremony(opts) {
|
|
171
|
+
const port = opts.port ?? CEREMONY_DEFAULT_PORT;
|
|
172
|
+
const timeoutMs = opts.timeoutMs ?? CEREMONY_DEFAULT_TIMEOUT_MS;
|
|
173
|
+
const log = opts.log ?? ((line) => process.stderr.write(`${line}\n`));
|
|
174
|
+
const openUrl = opts.openUrl ??
|
|
175
|
+
((url) => {
|
|
176
|
+
// macOS `open`; on failure the operator still has the printed URL.
|
|
177
|
+
exec(`open ${JSON.stringify(url)}`, () => { });
|
|
178
|
+
});
|
|
179
|
+
const html = ceremonyPageHtml(opts.page);
|
|
180
|
+
return new Promise((resolve, reject) => {
|
|
181
|
+
let settled = false;
|
|
182
|
+
const handler = async (req, res) => {
|
|
183
|
+
const send = (code, body, type = 'application/json') => {
|
|
184
|
+
res.writeHead(code, { 'Content-Type': type, 'Cache-Control': 'no-store' });
|
|
185
|
+
res.end(body);
|
|
186
|
+
};
|
|
187
|
+
try {
|
|
188
|
+
const path = new URL(req.url ?? '/', 'http://localhost').pathname;
|
|
189
|
+
if (path === '/')
|
|
190
|
+
return send(200, html, 'text/html');
|
|
191
|
+
if (path === '/vgs-agentic-auth.js')
|
|
192
|
+
return send(200, opts.vendorSdkJs, 'text/javascript');
|
|
193
|
+
if (path === '/api/token') {
|
|
194
|
+
const access_token = await opts.mintAccessToken();
|
|
195
|
+
return send(200, JSON.stringify({ access_token }));
|
|
196
|
+
}
|
|
197
|
+
if (path === '/api/assurance' && req.method === 'POST') {
|
|
198
|
+
const chunks = [];
|
|
199
|
+
for await (const chunk of req)
|
|
200
|
+
chunks.push(chunk);
|
|
201
|
+
let body = {};
|
|
202
|
+
try {
|
|
203
|
+
body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return send(400, JSON.stringify({ error: 'invalid JSON' }));
|
|
207
|
+
}
|
|
208
|
+
if (body.assuranceData == null) {
|
|
209
|
+
return send(400, JSON.stringify({ error: 'assuranceData required' }));
|
|
210
|
+
}
|
|
211
|
+
// Flush { ok: true } BEFORE teardown: finish() drops every
|
|
212
|
+
// connection, and destroying the in-flight socket first would show
|
|
213
|
+
// the operator a failed approval while the runner proceeds — the
|
|
214
|
+
// end() callback fires once the response has been handed to the OS.
|
|
215
|
+
const assurance = assuranceFromCeremony(opts.target, body.assuranceData);
|
|
216
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
217
|
+
res.end(JSON.stringify({ ok: true }), () => finish(null, assurance));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
return send(404, JSON.stringify({ error: 'not found' }));
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
return send(500, JSON.stringify({ error: err.message }));
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
const server = opts.tls ? https.createServer(opts.tls, handler) : http.createServer(handler);
|
|
227
|
+
const timer = setTimeout(() => {
|
|
228
|
+
finish(new Error(`no passkey approval within ${Math.round(timeoutMs / 1000)}s — ` +
|
|
229
|
+
'the checkout was not touched; rerun when ready'));
|
|
230
|
+
}, timeoutMs);
|
|
231
|
+
timer.unref();
|
|
232
|
+
function finish(err, assurance) {
|
|
233
|
+
if (settled)
|
|
234
|
+
return;
|
|
235
|
+
settled = true;
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
server.closeAllConnections?.();
|
|
238
|
+
server.close(() => {
|
|
239
|
+
if (err)
|
|
240
|
+
reject(err);
|
|
241
|
+
else
|
|
242
|
+
resolve(assurance);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
server.on('error', (err) => {
|
|
246
|
+
finish(err.code === 'EADDRINUSE'
|
|
247
|
+
? new Error(`port ${port} is already in use (is the dev harness still running?) — ` +
|
|
248
|
+
'stop it and rerun')
|
|
249
|
+
: err);
|
|
250
|
+
});
|
|
251
|
+
// Loopback ONLY: /api/token hands the page a live VGS bearer.
|
|
252
|
+
server.listen(port, '127.0.0.1', () => {
|
|
253
|
+
const address = server.address();
|
|
254
|
+
const boundPort = typeof address === 'object' && address ? address.port : port;
|
|
255
|
+
const scheme = opts.tls ? 'https' : 'http';
|
|
256
|
+
const pageUrl = `${scheme}://localhost:${boundPort}`;
|
|
257
|
+
log(`Visa approval page: ${pageUrl} — complete the passkey there.`);
|
|
258
|
+
openUrl(pageUrl);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
}
|