@trusty-squire/mcp 1.1.13-rc.9 → 1.1.13
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 +136 -64
- package/assets/login/vnc-input.js +9 -0
- package/assets/login/vnc.html +214 -202
- package/assets/screencaps/connect-walkthrough.png +0 -0
- package/assets/screencaps/connect-walkthrough.svg +1 -1
- package/dist/api-client.d.ts +26 -3
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +19 -31
- package/dist/api-client.js.map +1 -1
- package/dist/bin.js +2 -3
- package/dist/bin.js.map +1 -1
- package/dist/bot/browser.d.ts +85 -80
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +1314 -1570
- package/dist/bot/browser.js.map +1 -1
- package/dist/bot/compact-observation-v2.d.ts +167 -0
- package/dist/bot/compact-observation-v2.d.ts.map +1 -0
- package/dist/bot/compact-observation-v2.js +1038 -0
- package/dist/bot/compact-observation-v2.js.map +1 -0
- package/dist/bot/credential-shape.d.ts +2 -0
- package/dist/bot/credential-shape.d.ts.map +1 -1
- package/dist/bot/credential-shape.js +21 -8
- package/dist/bot/credential-shape.js.map +1 -1
- package/dist/bot/element-fingerprint.d.ts +9 -0
- package/dist/bot/element-fingerprint.d.ts.map +1 -0
- package/dist/bot/element-fingerprint.js +147 -0
- package/dist/bot/element-fingerprint.js.map +1 -0
- package/dist/bot/email-verification.d.ts +7 -1
- package/dist/bot/email-verification.d.ts.map +1 -1
- package/dist/bot/email-verification.js +48 -14
- package/dist/bot/email-verification.js.map +1 -1
- package/dist/bot/google-login.d.ts +28 -52
- package/dist/bot/google-login.d.ts.map +1 -1
- package/dist/bot/google-login.js +246 -1119
- package/dist/bot/google-login.js.map +1 -1
- package/dist/bot/install-completion.d.ts +1 -2
- package/dist/bot/install-completion.d.ts.map +1 -1
- package/dist/bot/install-completion.js +1 -2
- package/dist/bot/install-completion.js.map +1 -1
- package/dist/bot/login-state.d.ts +13 -7
- package/dist/bot/login-state.d.ts.map +1 -1
- package/dist/bot/login-state.js +50 -114
- package/dist/bot/login-state.js.map +1 -1
- package/dist/bot/oauth-providers.d.ts +1 -1
- package/dist/bot/oauth-providers.d.ts.map +1 -1
- package/dist/bot/oauth-providers.js +3 -3
- package/dist/bot/oauth-providers.js.map +1 -1
- package/dist/bot/oauth-scope.d.ts +4 -0
- package/dist/bot/oauth-scope.d.ts.map +1 -0
- package/dist/bot/oauth-scope.js +58 -0
- package/dist/bot/oauth-scope.js.map +1 -0
- package/dist/bot/operator-browser-watchdog.d.ts +18 -6
- package/dist/bot/operator-browser-watchdog.d.ts.map +1 -1
- package/dist/bot/operator-browser-watchdog.js +91 -37
- package/dist/bot/operator-browser-watchdog.js.map +1 -1
- package/dist/bot/owner-process-reaper-worker.d.ts +2 -0
- package/dist/bot/owner-process-reaper-worker.d.ts.map +1 -0
- package/dist/bot/owner-process-reaper-worker.js +17 -0
- package/dist/bot/owner-process-reaper-worker.js.map +1 -0
- package/dist/bot/owner-process-reaper.d.ts +103 -0
- package/dist/bot/owner-process-reaper.d.ts.map +1 -0
- package/dist/bot/owner-process-reaper.js +1058 -0
- package/dist/bot/owner-process-reaper.js.map +1 -0
- package/dist/bot/pay-operator.d.ts +7 -1
- package/dist/bot/pay-operator.d.ts.map +1 -1
- package/dist/bot/pay-operator.js +269 -103
- package/dist/bot/pay-operator.js.map +1 -1
- package/dist/bot/profile.d.ts +10 -1
- package/dist/bot/profile.d.ts.map +1 -1
- package/dist/bot/profile.js +137 -11
- package/dist/bot/profile.js.map +1 -1
- package/dist/bot/promote-to-skill.js +1 -1
- package/dist/bot/promote-to-skill.js.map +1 -1
- package/dist/bot/provision-session.d.ts +57 -186
- package/dist/bot/provision-session.d.ts.map +1 -1
- package/dist/bot/provision-session.js +1976 -1812
- package/dist/bot/provision-session.js.map +1 -1
- package/dist/bot/remote-login-display.d.ts +61 -0
- package/dist/bot/remote-login-display.d.ts.map +1 -0
- package/dist/bot/remote-login-display.js +765 -0
- package/dist/bot/remote-login-display.js.map +1 -0
- package/dist/bot/session/hosts.d.ts +8 -0
- package/dist/bot/session/hosts.d.ts.map +1 -0
- package/dist/bot/session/hosts.js +36 -0
- package/dist/bot/session/hosts.js.map +1 -0
- package/dist/bot/session/lifecycle.d.ts +56 -0
- package/dist/bot/session/lifecycle.d.ts.map +1 -0
- package/dist/bot/session/lifecycle.js +962 -0
- package/dist/bot/session/lifecycle.js.map +1 -0
- package/dist/bot/session/model.d.ts +175 -0
- package/dist/bot/session/model.d.ts.map +1 -0
- package/dist/bot/session/model.js +77 -0
- package/dist/bot/session/model.js.map +1 -0
- package/dist/install/cli.d.ts +29 -9
- package/dist/install/cli.d.ts.map +1 -1
- package/dist/install/cli.js +243 -322
- package/dist/install/cli.js.map +1 -1
- package/dist/install/interactive.d.ts +1 -0
- package/dist/install/interactive.d.ts.map +1 -1
- package/dist/install/interactive.js +19 -5
- package/dist/install/interactive.js.map +1 -1
- package/dist/install/ui.d.ts.map +1 -1
- package/dist/install/ui.js +3 -15
- package/dist/install/ui.js.map +1 -1
- package/dist/operator-env.js +1 -1
- package/dist/operator-env.js.map +1 -1
- package/dist/server-instance-registry.d.ts +103 -0
- package/dist/server-instance-registry.d.ts.map +1 -0
- package/dist/server-instance-registry.js +392 -0
- package/dist/server-instance-registry.js.map +1 -0
- package/dist/server.d.ts +15 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +164 -62
- package/dist/server.js.map +1 -1
- package/dist/session-guard.d.ts +28 -0
- package/dist/session-guard.d.ts.map +1 -0
- package/dist/session-guard.js +84 -0
- package/dist/session-guard.js.map +1 -0
- package/dist/session.d.ts +57 -13
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +246 -127
- package/dist/session.js.map +1 -1
- package/dist/tools/audit-log.d.ts +16 -2
- package/dist/tools/audit-log.d.ts.map +1 -1
- package/dist/tools/audit-log.js +223 -13
- package/dist/tools/audit-log.js.map +1 -1
- package/dist/tools/audit-rollup.d.ts +80 -0
- package/dist/tools/audit-rollup.d.ts.map +1 -0
- package/dist/tools/audit-rollup.js +0 -0
- package/dist/tools/audit-rollup.js.map +1 -0
- package/dist/tools/fetch-credential.d.ts +37 -0
- package/dist/tools/fetch-credential.d.ts.map +1 -0
- package/dist/tools/fetch-credential.js +153 -0
- package/dist/tools/fetch-credential.js.map +1 -0
- package/dist/tools/index.d.ts +3 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +7 -3
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/operate-pay.d.ts +4 -4
- package/dist/tools/operate-pay.d.ts.map +1 -1
- package/dist/tools/operate-pay.js +151 -96
- package/dist/tools/operate-pay.js.map +1 -1
- package/dist/tools/provision-drive.d.ts +47 -39
- package/dist/tools/provision-drive.d.ts.map +1 -1
- package/dist/tools/provision-drive.js +268 -164
- package/dist/tools/provision-drive.js.map +1 -1
- package/dist/tools/use-credential.d.ts.map +1 -1
- package/dist/tools/use-credential.js +3 -2
- package/dist/tools/use-credential.js.map +1 -1
- package/package.json +4 -6
- package/dist/bot/operator-direct-identity.d.ts +0 -26
- package/dist/bot/operator-direct-identity.d.ts.map +0 -1
- package/dist/bot/operator-direct-identity.js +0 -120
- package/dist/bot/operator-direct-identity.js.map +0 -1
- package/dist/bot/operator-profile-pool.d.ts +0 -97
- package/dist/bot/operator-profile-pool.d.ts.map +0 -1
- package/dist/bot/operator-profile-pool.js +0 -855
- package/dist/bot/operator-profile-pool.js.map +0 -1
- package/dist/bot/xvfb.d.ts +0 -13
- package/dist/bot/xvfb.d.ts.map +0 -1
- package/dist/bot/xvfb.js +0 -146
- package/dist/bot/xvfb.js.map +0 -1
package/dist/bot/pay-operator.js
CHANGED
|
@@ -3,7 +3,7 @@ import { classifyPaymentCandidateBinding, } from "@trusty-squire/skill-schema";
|
|
|
3
3
|
import canonicalize from "canonicalize";
|
|
4
4
|
import { createLocalJWKSet, decodeJwt, jwtVerify } from "jose";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
-
import { ApiCallError } from "../api-client.js";
|
|
6
|
+
import { ApiCallError, isPaymentApprovalTransportTimeout, } from "../api-client.js";
|
|
7
7
|
import { PaymentCardFillCleanupError, PaymentSubmitOutcomeUnknownError, UnrecognizedPaymentFrameError, } from "./browser.js";
|
|
8
8
|
import { generateOperatorKeypair, openSealed } from "./payment-hpke.js";
|
|
9
9
|
const cardSchema = z.object({
|
|
@@ -162,6 +162,40 @@ function normalizeCard(value) {
|
|
|
162
162
|
function confidenceAtLeastLow(value) {
|
|
163
163
|
return value === "low" || value === "medium" || value === "high";
|
|
164
164
|
}
|
|
165
|
+
const MAX_PREVERIFIED_MANDATE_RELAY_MS = 18 * 60 * 1_000;
|
|
166
|
+
function isJwtExpired(error) {
|
|
167
|
+
return (error !== null &&
|
|
168
|
+
typeof error === "object" &&
|
|
169
|
+
"code" in error &&
|
|
170
|
+
error.code === "ERR_JWT_EXPIRED");
|
|
171
|
+
}
|
|
172
|
+
async function verifyRelayedAssertion(jws, jwks, expectedAudience) {
|
|
173
|
+
const options = {
|
|
174
|
+
issuer: "https://vouchflow.dev",
|
|
175
|
+
audience: expectedAudience,
|
|
176
|
+
};
|
|
177
|
+
try {
|
|
178
|
+
return (await jwtVerify(jws, jwks, options)).payload;
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
if (!isJwtExpired(error))
|
|
182
|
+
throw error;
|
|
183
|
+
const decoded = decodeJwt(jws);
|
|
184
|
+
const issuedAt = decoded.iat;
|
|
185
|
+
const expiresAt = decoded.exp;
|
|
186
|
+
const now = Date.now();
|
|
187
|
+
if (!Number.isSafeInteger(issuedAt) ||
|
|
188
|
+
!Number.isSafeInteger(expiresAt) ||
|
|
189
|
+
expiresAt <= issuedAt ||
|
|
190
|
+
now - expiresAt * 1_000 > MAX_PREVERIFIED_MANDATE_RELAY_MS) {
|
|
191
|
+
throw new Error("mandate_assertion_expired");
|
|
192
|
+
}
|
|
193
|
+
return (await jwtVerify(jws, jwks, {
|
|
194
|
+
...options,
|
|
195
|
+
currentDate: new Date((expiresAt - 1) * 1_000),
|
|
196
|
+
})).payload;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
165
199
|
async function verifyMandate(jws, expectedHash, vouchflowApiBase, expectedAudience, fetchImpl) {
|
|
166
200
|
const jwksUrl = `${vouchflowApiBase.replace(/\/+$/, "")}/.well-known/jwks.json`;
|
|
167
201
|
const signal = AbortSignal.timeout(5_000);
|
|
@@ -185,10 +219,11 @@ async function verifyMandate(jws, expectedHash, vouchflowApiBase, expectedAudien
|
|
|
185
219
|
!Array.isArray(body.keys)) {
|
|
186
220
|
throw new Error("invalid_jwks");
|
|
187
221
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
222
|
+
// Every candidate returned by this authenticated relay was already checked
|
|
223
|
+
// at phone submission. Re-check all cryptographic and binding properties,
|
|
224
|
+
// while allowing only that exact candidate's short-lived assertion to age
|
|
225
|
+
// within the still-live approval window.
|
|
226
|
+
const payload = await verifyRelayedAssertion(jws, createLocalJWKSet(body), expectedAudience);
|
|
192
227
|
const signedHash = decodePayloadHash(payload.payload_sha256);
|
|
193
228
|
if (!timingSafeEqual(Buffer.from(expectedHash), Buffer.from(signedHash))) {
|
|
194
229
|
throw new Error("payload_hash_mismatch");
|
|
@@ -212,6 +247,7 @@ function safeFailureReason(error) {
|
|
|
212
247
|
"payload_hash_mismatch",
|
|
213
248
|
"invalid_mandate_context",
|
|
214
249
|
"insufficient_mandate_confidence",
|
|
250
|
+
"mandate_assertion_expired",
|
|
215
251
|
"invalid_card_pan",
|
|
216
252
|
"invalid_card_expiry",
|
|
217
253
|
];
|
|
@@ -241,6 +277,29 @@ function cardRequiredResult(approvalUrl, checkout, reason) {
|
|
|
241
277
|
},
|
|
242
278
|
};
|
|
243
279
|
}
|
|
280
|
+
function approvalDeniedResult(approvalId, approvalUrl, checkout) {
|
|
281
|
+
return {
|
|
282
|
+
status: "payment_approval_denied",
|
|
283
|
+
approval_id: approvalId,
|
|
284
|
+
approval_url: approvalUrl,
|
|
285
|
+
merchant: checkout.merchant,
|
|
286
|
+
amount_cents: checkout.amount_cents,
|
|
287
|
+
currency: checkout.currency,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
function approvalExpiredResult(approvalUrl, checkout, jit, boundCardRef) {
|
|
291
|
+
const base = {
|
|
292
|
+
status: "payment_approval_timeout",
|
|
293
|
+
approval_url: approvalUrl,
|
|
294
|
+
merchant: checkout.merchant,
|
|
295
|
+
amount_cents: checkout.amount_cents,
|
|
296
|
+
currency: checkout.currency,
|
|
297
|
+
};
|
|
298
|
+
return jit && hasBoundCard(boundCardRef) ? { ...base, card_persisted: true } : base;
|
|
299
|
+
}
|
|
300
|
+
function isPaymentApprovalDeniedError(error) {
|
|
301
|
+
return error instanceof ApiCallError && error.code === "payment_approval_denied";
|
|
302
|
+
}
|
|
244
303
|
// Total additional time (beyond this call's own bounded wait) that a
|
|
245
304
|
// resumed, still-pending decoupled/out-of-band 3DS challenge stays
|
|
246
305
|
// checkable via operate_payment_status before handing back an accurate
|
|
@@ -248,6 +307,7 @@ function cardRequiredResult(approvalUrl, checkout, reason) {
|
|
|
248
307
|
// their phone, open the banking app, and approve — but bounded, matching
|
|
249
308
|
// the rest of this file's "wait, but never forever" posture.
|
|
250
309
|
const THREE_DS_RESUME_WINDOW_MS = 20 * 60 * 1000;
|
|
310
|
+
const PAYMENT_APPROVAL_RESPONSE_RESERVE_MS = 500;
|
|
251
311
|
// The cardholder approves 3-D Secure via an app-push in their bank app while
|
|
252
312
|
// the browser's checkout JavaScript owns the native challenge handshake. Fires
|
|
253
313
|
// the Telegram nudge WITHOUT awaiting it (a slow/unresolved Telegram call must
|
|
@@ -306,6 +366,8 @@ function statusAfterThreeDsResolution(currentStatus, resolution) {
|
|
|
306
366
|
return "payment_submitted";
|
|
307
367
|
case "failed":
|
|
308
368
|
return "payment_declined";
|
|
369
|
+
case "challenge_pending":
|
|
370
|
+
return "payment_3ds_required";
|
|
309
371
|
case "timeout":
|
|
310
372
|
return currentStatus;
|
|
311
373
|
}
|
|
@@ -370,6 +432,7 @@ function defaultDependencies() {
|
|
|
370
432
|
onCardFillCleanupFailed: () => undefined,
|
|
371
433
|
onSubmitStarted: () => undefined,
|
|
372
434
|
onApprovalPending: () => undefined,
|
|
435
|
+
onApprovalTerminal: () => undefined,
|
|
373
436
|
onThreeDsHandoffArmed: () => undefined,
|
|
374
437
|
coordinateThreeDsAudit: async (_state, audit) => await audit(),
|
|
375
438
|
onThreeDsPending: () => undefined,
|
|
@@ -421,15 +484,28 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
|
|
|
421
484
|
}
|
|
422
485
|
if (resume !== undefined) {
|
|
423
486
|
let reusable = false;
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
487
|
+
try {
|
|
488
|
+
const live = await api.getPaymentApproval(resume.approval_id);
|
|
489
|
+
const liveDeadline = Date.parse(live.expires_at);
|
|
490
|
+
const terminalStatus = live.status === "denied"
|
|
491
|
+
? "denied"
|
|
492
|
+
: live.status === "expired" ||
|
|
493
|
+
(Number.isFinite(liveDeadline) ? liveDeadline : resume.deadline) <= deps.now()
|
|
494
|
+
? "expired"
|
|
495
|
+
: null;
|
|
496
|
+
if (terminalStatus !== null) {
|
|
497
|
+
deps.onApprovalTerminal(resume, terminalStatus);
|
|
498
|
+
resumableState = undefined;
|
|
499
|
+
keypairHandedOff = false;
|
|
500
|
+
return terminalStatus === "denied"
|
|
501
|
+
? approvalDeniedResult(resume.approval_id, resume.approval_url, resume.checkout)
|
|
502
|
+
: approvalExpiredResult(resume.approval_url, resume.checkout, resume.jit, resume.boundCardRef);
|
|
428
503
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
504
|
+
reusable = isLiveResumableApproval(live, resume, deps.now());
|
|
505
|
+
}
|
|
506
|
+
catch (error) {
|
|
507
|
+
if (!(error instanceof ApiCallError && error.code === "payment_approval_not_found")) {
|
|
508
|
+
throw error;
|
|
433
509
|
}
|
|
434
510
|
}
|
|
435
511
|
if (!reusable) {
|
|
@@ -541,14 +617,10 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
|
|
|
541
617
|
...(threeDsWaitSeconds !== undefined ? { three_ds_wait_seconds: threeDsWaitSeconds } : {}),
|
|
542
618
|
});
|
|
543
619
|
await deps.surfaceApprovalUrl(approvalUrl);
|
|
544
|
-
//
|
|
545
|
-
// Undefined pollBudgetMs (direct executeOperatePay callers) = no additional
|
|
546
|
-
// bound, i.e. the legacy full-deadline blocking behavior.
|
|
620
|
+
// This call's wait is bounded by both its client budget and server expiry.
|
|
547
621
|
const callDeadline = deps.pollBudgetMs === undefined
|
|
548
622
|
? deadline
|
|
549
623
|
: Math.min(deadline, deps.now() + deps.pollBudgetMs);
|
|
550
|
-
// True once this call's (not the overall) budget is exhausted with no
|
|
551
|
-
// resolution — distinguishes "still pending, ask again" from "expired".
|
|
552
624
|
let budgetExhausted = false;
|
|
553
625
|
const shouldKeepPolling = () => {
|
|
554
626
|
const now = deps.now();
|
|
@@ -560,37 +632,30 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
|
|
|
560
632
|
}
|
|
561
633
|
return true;
|
|
562
634
|
};
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
// can distinguish "no card was ever added" (card_required) from "card was
|
|
566
|
-
// stored but never approved" (payment_approval_timeout, card persists).
|
|
635
|
+
// A JIT approval that expires before binding still needs a card; a bound
|
|
636
|
+
// card remains stored even though the approval itself is terminal.
|
|
567
637
|
const timeoutResult = () => {
|
|
568
638
|
if (jit && !hasBoundCard(boundCardRef)) {
|
|
569
639
|
return cardRequiredResult(approvalUrl, checkout, "the add-card link expired before a card was added");
|
|
570
640
|
}
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
641
|
+
return approvalExpiredResult(approvalUrl, checkout, jit, boundCardRef);
|
|
642
|
+
};
|
|
643
|
+
const approvalExpired = () => deps.now() >= deadline;
|
|
644
|
+
let terminalApprovalState;
|
|
645
|
+
const terminalApprovalResult = (terminalStatus) => {
|
|
646
|
+
const state = resumableState?.() ?? terminalApprovalState;
|
|
647
|
+
if (state !== undefined)
|
|
648
|
+
deps.onApprovalTerminal(state, terminalStatus);
|
|
649
|
+
resumableState = undefined;
|
|
650
|
+
keypairHandedOff = false;
|
|
651
|
+
return terminalStatus === "denied"
|
|
652
|
+
? approvalDeniedResult(approvalId, approvalUrl, checkout)
|
|
653
|
+
: timeoutResult();
|
|
581
654
|
};
|
|
655
|
+
const expiredApprovalResult = () => terminalApprovalResult("expired");
|
|
582
656
|
let approved;
|
|
583
657
|
let claims;
|
|
584
|
-
// Always
|
|
585
|
-
// before ever reporting pending, and a resumed call with a zero poll
|
|
586
|
-
// budget must too. Two things gate every re-entry to the top: (1) right
|
|
587
|
-
// before each sleep, shouldKeepPolling() skips the sleep entirely when
|
|
588
|
-
// the budget is already exhausted, so a not-yet-resolved call never
|
|
589
|
-
// blocks on a real wait before returning; (2) the iteration>0 check
|
|
590
|
-
// here re-validates AFTER that sleep (real time — or a mocked clock —
|
|
591
|
-
// may have advanced past the deadline during it) so the loop never
|
|
592
|
-
// spends one more live poll call past the budget it just decided to
|
|
593
|
-
// respect.
|
|
658
|
+
// Always make one live read; later iterations recheck the budget after sleep.
|
|
594
659
|
let iteration = 0;
|
|
595
660
|
let immediateReviewFollowup = false;
|
|
596
661
|
while (true) {
|
|
@@ -598,12 +663,30 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
|
|
|
598
663
|
break;
|
|
599
664
|
immediateReviewFollowup = false;
|
|
600
665
|
iteration++;
|
|
601
|
-
const
|
|
666
|
+
const remainingPollMs = Math.max(Math.min(callDeadline, deadline) - deps.now(), 0);
|
|
667
|
+
const candidateRead = remainingPollMs > 0 ? true : "immediate";
|
|
668
|
+
let approval;
|
|
669
|
+
try {
|
|
670
|
+
approval =
|
|
671
|
+
candidateRead === true
|
|
672
|
+
? await api.getPaymentApproval(approvalId, true, Math.min(Math.max(remainingPollMs - PAYMENT_APPROVAL_RESPONSE_RESERVE_MS, 0), 15_000), remainingPollMs)
|
|
673
|
+
: await api.getPaymentApproval(approvalId, "immediate");
|
|
674
|
+
}
|
|
675
|
+
catch (error) {
|
|
676
|
+
if (!isPaymentApprovalTransportTimeout(error))
|
|
677
|
+
throw error;
|
|
678
|
+
budgetExhausted = true;
|
|
679
|
+
break;
|
|
680
|
+
}
|
|
681
|
+
const liveDeadline = Date.parse(approval.expires_at);
|
|
682
|
+
if (Number.isFinite(liveDeadline))
|
|
683
|
+
deadline = Math.min(deadline, liveDeadline);
|
|
602
684
|
boundCardRef = approval.card_ref;
|
|
603
|
-
if (approval.status === "
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
685
|
+
if (approval.status === "denied") {
|
|
686
|
+
return terminalApprovalResult("denied");
|
|
687
|
+
}
|
|
688
|
+
if (approval.status === "expired" || approvalExpired()) {
|
|
689
|
+
return expiredApprovalResult();
|
|
607
690
|
}
|
|
608
691
|
const hasCandidate = typeof approval.jws === "string" && typeof approval.sealed_card === "string";
|
|
609
692
|
if (approval.status === "approved" && !hasCandidate) {
|
|
@@ -695,6 +778,8 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
|
|
|
695
778
|
approval_url: approvalUrl,
|
|
696
779
|
};
|
|
697
780
|
}
|
|
781
|
+
if (approvalExpired())
|
|
782
|
+
return expiredApprovalResult();
|
|
698
783
|
let candidateCardBytes;
|
|
699
784
|
let candidateCard;
|
|
700
785
|
try {
|
|
@@ -734,6 +819,9 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
|
|
|
734
819
|
}
|
|
735
820
|
catch (error) {
|
|
736
821
|
candidateCardBytes.fill(0);
|
|
822
|
+
if (isPaymentApprovalDeniedError(error)) {
|
|
823
|
+
return terminalApprovalResult("denied");
|
|
824
|
+
}
|
|
737
825
|
const failureReason = error instanceof Error && /404|409/.test(error.message)
|
|
738
826
|
? "confirm_status"
|
|
739
827
|
: "confirm_failed";
|
|
@@ -770,33 +858,30 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
|
|
|
770
858
|
throw new Error("confirm_status");
|
|
771
859
|
}
|
|
772
860
|
catch (error) {
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
throw error;
|
|
777
|
-
}
|
|
778
|
-
catch {
|
|
779
|
-
candidateCardBytes.fill(0);
|
|
780
|
-
// The initial confirm may have actually gone through server-side (its
|
|
781
|
-
// response was merely lost) even though reconciliation couldn't prove
|
|
782
|
-
// it. Throwing here would leave resumableState live, and the outer
|
|
783
|
-
// catch would resurrect this candidate as still-awaiting — letting a
|
|
784
|
-
// later call re-confirm and re-charge a purchase that may already be
|
|
785
|
-
// spent. Fail closed with a terminal result instead, mirroring the
|
|
786
|
-
// review-candidate branch above.
|
|
787
|
-
const failureReason = error instanceof Error && /404|409/.test(error.message)
|
|
788
|
-
? "confirm_status"
|
|
789
|
-
: "confirm_failed";
|
|
790
|
-
logPaymentCandidateLifecycle(approvalId, "approval", "confirmation_failed", failureReason);
|
|
791
|
-
return {
|
|
792
|
-
status: "payment_confirmation_failed",
|
|
793
|
-
reason: failureReason,
|
|
794
|
-
candidate_kind: "approval",
|
|
795
|
-
approval_url: approvalUrl,
|
|
796
|
-
};
|
|
861
|
+
candidateCardBytes.fill(0);
|
|
862
|
+
if (isPaymentApprovalDeniedError(error)) {
|
|
863
|
+
return terminalApprovalResult("denied");
|
|
797
864
|
}
|
|
865
|
+
const failureReason = error instanceof Error && /404|409/.test(error.message)
|
|
866
|
+
? "confirm_status"
|
|
867
|
+
: "confirm_failed";
|
|
868
|
+
const state = resumableState();
|
|
869
|
+
deps.onApprovalTerminal(state, "payment_confirmation_failed");
|
|
870
|
+
resumableState = undefined;
|
|
871
|
+
keypairHandedOff = false;
|
|
872
|
+
logPaymentCandidateLifecycle(approvalId, "approval", "confirmation_failed", failureReason);
|
|
873
|
+
return {
|
|
874
|
+
status: "payment_confirmation_failed",
|
|
875
|
+
reason: failureReason,
|
|
876
|
+
candidate_kind: "approval",
|
|
877
|
+
approval_url: approvalUrl,
|
|
878
|
+
};
|
|
798
879
|
}
|
|
799
880
|
}
|
|
881
|
+
if (approvalExpired()) {
|
|
882
|
+
candidateCardBytes.fill(0);
|
|
883
|
+
return expiredApprovalResult();
|
|
884
|
+
}
|
|
800
885
|
logPaymentCandidateLifecycle(approvalId, "approval", "ready_to_charge");
|
|
801
886
|
cardBytes = candidateCardBytes;
|
|
802
887
|
card = candidateCard;
|
|
@@ -827,10 +912,10 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
|
|
|
827
912
|
candidate_kind: "review",
|
|
828
913
|
ready_to_charge: false,
|
|
829
914
|
next: {
|
|
830
|
-
tool: "
|
|
831
|
-
wait_seconds: 15,
|
|
915
|
+
tool: "operate_pay",
|
|
832
916
|
message: "The review signature was verified, but final payment approval is still required. " +
|
|
833
|
-
"Refresh the approval page if it does not advance to the final approval prompt
|
|
917
|
+
"Refresh the approval page if it does not advance to the final approval prompt, " +
|
|
918
|
+
"then call operate_pay again with the same arguments; it resumes this approval and waits.",
|
|
834
919
|
},
|
|
835
920
|
};
|
|
836
921
|
}
|
|
@@ -849,7 +934,12 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
|
|
|
849
934
|
merchant: checkout.merchant,
|
|
850
935
|
candidate_kind: "none",
|
|
851
936
|
ready_to_charge: false,
|
|
852
|
-
next: {
|
|
937
|
+
next: {
|
|
938
|
+
tool: "operate_pay",
|
|
939
|
+
message: "The bounded server wait ended before the human responded. Call operate_pay again " +
|
|
940
|
+
"with the same arguments; it resumes this approval and continues waiting without " +
|
|
941
|
+
"creating another approval.",
|
|
942
|
+
},
|
|
853
943
|
};
|
|
854
944
|
}
|
|
855
945
|
if (jit) {
|
|
@@ -859,15 +949,14 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
|
|
|
859
949
|
}
|
|
860
950
|
catch { }
|
|
861
951
|
}
|
|
862
|
-
|
|
863
|
-
keypairHandedOff = false;
|
|
864
|
-
return timeoutResult();
|
|
952
|
+
return expiredApprovalResult();
|
|
865
953
|
}
|
|
866
954
|
if (claims === undefined || card === undefined) {
|
|
867
955
|
resumableState = undefined;
|
|
868
956
|
keypairHandedOff = false;
|
|
869
957
|
return timeoutResult();
|
|
870
958
|
}
|
|
959
|
+
terminalApprovalState = resumableState?.();
|
|
871
960
|
resumableState = undefined;
|
|
872
961
|
keypairHandedOff = false;
|
|
873
962
|
const cardRef = cardRefArg ?? approved.card_ref;
|
|
@@ -878,7 +967,6 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
|
|
|
878
967
|
approval_url: approvalUrl,
|
|
879
968
|
};
|
|
880
969
|
}
|
|
881
|
-
deps.onCardResolved(cardRef);
|
|
882
970
|
const last4 = card.pan.slice(-4);
|
|
883
971
|
const mandateId = typeof claims.mandate_id === "string"
|
|
884
972
|
? claims.mandate_id
|
|
@@ -946,10 +1034,16 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
|
|
|
946
1034
|
...(liveOrigin !== null ? { live_checkout_origin: liveOrigin } : {}),
|
|
947
1035
|
};
|
|
948
1036
|
}
|
|
1037
|
+
if (approvalExpired())
|
|
1038
|
+
return expiredApprovalResult();
|
|
1039
|
+
deps.onCardResolved(cardRef);
|
|
949
1040
|
try {
|
|
950
|
-
await browser.fillCheckoutCardFields(card);
|
|
1041
|
+
await browser.fillCheckoutCardFields(card, { deadline });
|
|
951
1042
|
}
|
|
952
1043
|
catch (error) {
|
|
1044
|
+
if (error instanceof Error && error.message === "payment_approval_expired") {
|
|
1045
|
+
return expiredApprovalResult();
|
|
1046
|
+
}
|
|
953
1047
|
const frameOrigin = error instanceof UnrecognizedPaymentFrameError
|
|
954
1048
|
? error.frameOrigin
|
|
955
1049
|
: error instanceof PaymentCardFillCleanupError
|
|
@@ -1010,6 +1104,9 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
|
|
|
1010
1104
|
"does not need to happen before you place the order.",
|
|
1011
1105
|
};
|
|
1012
1106
|
}
|
|
1107
|
+
if (approvalExpired())
|
|
1108
|
+
return expiredApprovalResult();
|
|
1109
|
+
deps.onCardResolved(cardRef);
|
|
1013
1110
|
const pendingThreeDsHandoff = {
|
|
1014
1111
|
approval_id: approvalId,
|
|
1015
1112
|
approval_url: approvalUrl,
|
|
@@ -1017,6 +1114,7 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
|
|
|
1017
1114
|
last4,
|
|
1018
1115
|
...(mandateId !== undefined ? { mandate_id: mandateId } : {}),
|
|
1019
1116
|
deadline: deps.now() + THREE_DS_RESUME_WINDOW_MS,
|
|
1117
|
+
outcome: "unknown",
|
|
1020
1118
|
};
|
|
1021
1119
|
deps.onThreeDsHandoffArmed(pendingThreeDsHandoff);
|
|
1022
1120
|
let retainedPendingThreeDs = null;
|
|
@@ -1038,13 +1136,17 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
|
|
|
1038
1136
|
deps.onThreeDsCleared(retainedPendingThreeDs);
|
|
1039
1137
|
retainedPendingThreeDs = null;
|
|
1040
1138
|
};
|
|
1041
|
-
const
|
|
1139
|
+
const pendingOutcomeNext = () => ({
|
|
1042
1140
|
tool: "operate_payment_status",
|
|
1043
1141
|
wait_seconds: 15,
|
|
1044
|
-
hint:
|
|
1045
|
-
"
|
|
1046
|
-
|
|
1047
|
-
|
|
1142
|
+
hint: pendingThreeDsHandoff.outcome === "three_ds"
|
|
1143
|
+
? "The 3-D Secure challenge has not resolved. Call operate_payment_status to keep " +
|
|
1144
|
+
"checking the same submitted charge — this does not re-release the card or create a " +
|
|
1145
|
+
"new approval."
|
|
1146
|
+
: "The submitted payment has no confirmed merchant outcome or 3-D Secure evidence. " +
|
|
1147
|
+
"Call operate_payment_status to keep checking the same attempt; its outcome remains " +
|
|
1148
|
+
"unknown until terminal merchant or authentication evidence appears.",
|
|
1149
|
+
});
|
|
1048
1150
|
let paymentStatus = "payment_submitted";
|
|
1049
1151
|
let submitResult = { three_ds_required: false, order_confirmed: false };
|
|
1050
1152
|
try {
|
|
@@ -1055,17 +1157,39 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
|
|
|
1055
1157
|
...(args.card_issuer !== undefined
|
|
1056
1158
|
? { issuer: args.card_issuer, issuer_source: "bin_metadata" }
|
|
1057
1159
|
: {}),
|
|
1058
|
-
}, {
|
|
1160
|
+
}, {
|
|
1161
|
+
onSubmitDispatched: retainPendingThreeDs,
|
|
1162
|
+
beforeSubmitDispatch: () => {
|
|
1163
|
+
if (approvalExpired())
|
|
1164
|
+
throw new Error("payment_approval_expired");
|
|
1165
|
+
return deadline - deps.now();
|
|
1166
|
+
},
|
|
1167
|
+
});
|
|
1059
1168
|
if (submitResult.three_ds_required)
|
|
1060
1169
|
paymentStatus = "payment_3ds_required";
|
|
1061
1170
|
else if (!submitResult.order_confirmed)
|
|
1062
1171
|
paymentStatus = "payment_outcome_unknown";
|
|
1172
|
+
if (submitResult.three_ds_required)
|
|
1173
|
+
pendingThreeDsHandoff.outcome = "three_ds";
|
|
1063
1174
|
}
|
|
1064
1175
|
catch (error) {
|
|
1176
|
+
if (error instanceof Error && error.message === "payment_approval_expired") {
|
|
1177
|
+
clearPendingThreeDs();
|
|
1178
|
+
return expiredApprovalResult();
|
|
1179
|
+
}
|
|
1065
1180
|
const outcomeUnknown = error instanceof PaymentSubmitOutcomeUnknownError;
|
|
1066
1181
|
paymentStatus = outcomeUnknown ? "payment_outcome_unknown" : "payment_checkout_failed";
|
|
1182
|
+
let terminalSubmitOutcome = false;
|
|
1067
1183
|
if (outcomeUnknown) {
|
|
1068
|
-
await browser.waitForThreeDsResolution(0).catch(() => undefined);
|
|
1184
|
+
const resolution = await browser.waitForThreeDsResolution(0).catch(() => undefined);
|
|
1185
|
+
if (resolution !== undefined) {
|
|
1186
|
+
paymentStatus = statusAfterThreeDsResolution(paymentStatus, resolution);
|
|
1187
|
+
terminalSubmitOutcome = resolution === "succeeded" || resolution === "failed";
|
|
1188
|
+
if (resolution === "challenge_pending") {
|
|
1189
|
+
pendingThreeDsHandoff.outcome = "three_ds";
|
|
1190
|
+
submitResult.three_ds_required = true;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1069
1193
|
const mismatch = browser.paymentInstrumentMismatch?.();
|
|
1070
1194
|
if (mismatch !== undefined)
|
|
1071
1195
|
submitResult.payment_instrument_mismatch = mismatch;
|
|
@@ -1088,23 +1212,49 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
|
|
|
1088
1212
|
catch {
|
|
1089
1213
|
audit_recorded = false;
|
|
1090
1214
|
}
|
|
1091
|
-
if (outcomeUnknown && retainedPendingThreeDs !== null)
|
|
1215
|
+
if (outcomeUnknown && !terminalSubmitOutcome && retainedPendingThreeDs !== null) {
|
|
1092
1216
|
retainPendingThreeDs();
|
|
1093
|
-
|
|
1217
|
+
}
|
|
1218
|
+
else {
|
|
1094
1219
|
clearPendingThreeDs();
|
|
1220
|
+
}
|
|
1095
1221
|
return {
|
|
1096
1222
|
status: paymentStatus,
|
|
1097
1223
|
audit_recorded,
|
|
1098
|
-
|
|
1099
|
-
?
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1224
|
+
...(!terminalSubmitOutcome
|
|
1225
|
+
? {
|
|
1226
|
+
reason: outcomeUnknown
|
|
1227
|
+
? paymentStatus === "payment_3ds_required"
|
|
1228
|
+
? "payment_3ds_required"
|
|
1229
|
+
: "payment_submit_outcome_unknown"
|
|
1230
|
+
: error instanceof Error && /^payment_[a-z_]+(?::[a-z_]+)?$/.test(error.message)
|
|
1231
|
+
? error.message
|
|
1232
|
+
: "payment_checkout_failed",
|
|
1233
|
+
}
|
|
1234
|
+
: {}),
|
|
1103
1235
|
approval_url: approvalUrl,
|
|
1104
1236
|
...(submitResult.payment_instrument_mismatch !== undefined
|
|
1105
1237
|
? { warning: submitResult.payment_instrument_mismatch }
|
|
1106
1238
|
: {}),
|
|
1107
|
-
...(outcomeUnknown && retainedPendingThreeDs !== null
|
|
1239
|
+
...(outcomeUnknown && !terminalSubmitOutcome && retainedPendingThreeDs !== null
|
|
1240
|
+
? { next: pendingOutcomeNext() }
|
|
1241
|
+
: {}),
|
|
1242
|
+
...(paymentStatus === "payment_3ds_required"
|
|
1243
|
+
? {
|
|
1244
|
+
needs_user: {
|
|
1245
|
+
wall: "3ds",
|
|
1246
|
+
message: threeDsHandoffMessage(submitResult, undefined),
|
|
1247
|
+
resume: "checkout",
|
|
1248
|
+
},
|
|
1249
|
+
}
|
|
1250
|
+
: {}),
|
|
1251
|
+
...(paymentStatus === "payment_submitted"
|
|
1252
|
+
? {
|
|
1253
|
+
merchant: checkout.merchant,
|
|
1254
|
+
amount_cents: checkout.amount_cents,
|
|
1255
|
+
currency: checkout.currency,
|
|
1256
|
+
}
|
|
1257
|
+
: {}),
|
|
1108
1258
|
};
|
|
1109
1259
|
}
|
|
1110
1260
|
finally {
|
|
@@ -1116,12 +1266,22 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
|
|
|
1116
1266
|
if (submitResult.payment_instrument_mismatch === undefined &&
|
|
1117
1267
|
!submitResult.order_confirmed &&
|
|
1118
1268
|
threeDsWaitMs > 0) {
|
|
1119
|
-
|
|
1269
|
+
const challengeKnownBeforeWait = pendingThreeDsHandoff.outcome === "three_ds";
|
|
1270
|
+
if (challengeKnownBeforeWait) {
|
|
1271
|
+
getThreeDsTelegramSent = trackThreeDsNotification(api.notifyThreeDs(approvalId, threeDsNotificationMode(submitResult)));
|
|
1272
|
+
}
|
|
1120
1273
|
const resolution = await browser.waitForThreeDsResolution(threeDsWaitMs);
|
|
1121
1274
|
const mismatch = browser.paymentInstrumentMismatch?.();
|
|
1122
1275
|
if (mismatch !== undefined)
|
|
1123
1276
|
submitResult.payment_instrument_mismatch ??= mismatch;
|
|
1124
1277
|
paymentStatus = statusAfterThreeDsResolution(paymentStatus, resolution);
|
|
1278
|
+
if (resolution === "challenge_pending") {
|
|
1279
|
+
pendingThreeDsHandoff.outcome = "three_ds";
|
|
1280
|
+
submitResult.three_ds_required = true;
|
|
1281
|
+
if (!challengeKnownBeforeWait) {
|
|
1282
|
+
getThreeDsTelegramSent = trackThreeDsNotification(api.notifyThreeDs(approvalId, "detected_challenge"));
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1125
1285
|
}
|
|
1126
1286
|
let auditRecorded = true;
|
|
1127
1287
|
try {
|
|
@@ -1149,13 +1309,19 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
|
|
|
1149
1309
|
...(submitResult.payment_instrument_mismatch !== undefined
|
|
1150
1310
|
? { warning: submitResult.payment_instrument_mismatch }
|
|
1151
1311
|
: {}),
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1312
|
+
...(paymentStatus === "payment_3ds_required"
|
|
1313
|
+
? {
|
|
1314
|
+
needs_user: {
|
|
1315
|
+
wall: "3ds",
|
|
1316
|
+
message: threeDsHandoffMessage(submitResult, getThreeDsTelegramSent()),
|
|
1317
|
+
resume: "checkout",
|
|
1318
|
+
...(submitResult.challenge_url !== undefined
|
|
1319
|
+
? { url: submitResult.challenge_url }
|
|
1320
|
+
: {}),
|
|
1321
|
+
},
|
|
1322
|
+
}
|
|
1323
|
+
: {}),
|
|
1324
|
+
next: pendingOutcomeNext(),
|
|
1159
1325
|
};
|
|
1160
1326
|
}
|
|
1161
1327
|
if (paymentStatus === "payment_declined") {
|