@trusty-squire/mcp 1.1.13-rc.8 → 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.
Files changed (162) hide show
  1. package/README.md +139 -62
  2. package/assets/login/vnc-input.js +9 -0
  3. package/assets/login/vnc.html +214 -202
  4. package/assets/screencaps/connect-walkthrough.png +0 -0
  5. package/assets/screencaps/connect-walkthrough.svg +1 -1
  6. package/dist/api-client.d.ts +26 -3
  7. package/dist/api-client.d.ts.map +1 -1
  8. package/dist/api-client.js +19 -31
  9. package/dist/api-client.js.map +1 -1
  10. package/dist/bin.js +2 -3
  11. package/dist/bin.js.map +1 -1
  12. package/dist/bot/browser.d.ts +134 -77
  13. package/dist/bot/browser.d.ts.map +1 -1
  14. package/dist/bot/browser.js +1788 -1543
  15. package/dist/bot/browser.js.map +1 -1
  16. package/dist/bot/compact-observation-v2.d.ts +167 -0
  17. package/dist/bot/compact-observation-v2.d.ts.map +1 -0
  18. package/dist/bot/compact-observation-v2.js +1038 -0
  19. package/dist/bot/compact-observation-v2.js.map +1 -0
  20. package/dist/bot/credential-shape.d.ts +2 -0
  21. package/dist/bot/credential-shape.d.ts.map +1 -1
  22. package/dist/bot/credential-shape.js +21 -8
  23. package/dist/bot/credential-shape.js.map +1 -1
  24. package/dist/bot/element-fingerprint.d.ts +9 -0
  25. package/dist/bot/element-fingerprint.d.ts.map +1 -0
  26. package/dist/bot/element-fingerprint.js +147 -0
  27. package/dist/bot/element-fingerprint.js.map +1 -0
  28. package/dist/bot/email-verification.d.ts +7 -1
  29. package/dist/bot/email-verification.d.ts.map +1 -1
  30. package/dist/bot/email-verification.js +48 -14
  31. package/dist/bot/email-verification.js.map +1 -1
  32. package/dist/bot/google-login.d.ts +28 -52
  33. package/dist/bot/google-login.d.ts.map +1 -1
  34. package/dist/bot/google-login.js +246 -1119
  35. package/dist/bot/google-login.js.map +1 -1
  36. package/dist/bot/install-completion.d.ts +1 -2
  37. package/dist/bot/install-completion.d.ts.map +1 -1
  38. package/dist/bot/install-completion.js +1 -2
  39. package/dist/bot/install-completion.js.map +1 -1
  40. package/dist/bot/login-state.d.ts +13 -7
  41. package/dist/bot/login-state.d.ts.map +1 -1
  42. package/dist/bot/login-state.js +50 -114
  43. package/dist/bot/login-state.js.map +1 -1
  44. package/dist/bot/oauth-providers.d.ts +1 -1
  45. package/dist/bot/oauth-providers.d.ts.map +1 -1
  46. package/dist/bot/oauth-providers.js +3 -3
  47. package/dist/bot/oauth-providers.js.map +1 -1
  48. package/dist/bot/oauth-scope.d.ts +4 -0
  49. package/dist/bot/oauth-scope.d.ts.map +1 -0
  50. package/dist/bot/oauth-scope.js +58 -0
  51. package/dist/bot/oauth-scope.js.map +1 -0
  52. package/dist/bot/operator-browser-watchdog.d.ts +127 -0
  53. package/dist/bot/operator-browser-watchdog.d.ts.map +1 -0
  54. package/dist/bot/operator-browser-watchdog.js +438 -0
  55. package/dist/bot/operator-browser-watchdog.js.map +1 -0
  56. package/dist/bot/owner-process-reaper-worker.d.ts +2 -0
  57. package/dist/bot/owner-process-reaper-worker.d.ts.map +1 -0
  58. package/dist/bot/owner-process-reaper-worker.js +17 -0
  59. package/dist/bot/owner-process-reaper-worker.js.map +1 -0
  60. package/dist/bot/owner-process-reaper.d.ts +103 -0
  61. package/dist/bot/owner-process-reaper.d.ts.map +1 -0
  62. package/dist/bot/owner-process-reaper.js +1058 -0
  63. package/dist/bot/owner-process-reaper.js.map +1 -0
  64. package/dist/bot/pay-operator.d.ts +13 -2
  65. package/dist/bot/pay-operator.d.ts.map +1 -1
  66. package/dist/bot/pay-operator.js +319 -123
  67. package/dist/bot/pay-operator.js.map +1 -1
  68. package/dist/bot/profile.d.ts +10 -1
  69. package/dist/bot/profile.d.ts.map +1 -1
  70. package/dist/bot/profile.js +137 -11
  71. package/dist/bot/profile.js.map +1 -1
  72. package/dist/bot/promote-to-skill.js +1 -1
  73. package/dist/bot/promote-to-skill.js.map +1 -1
  74. package/dist/bot/provision-session.d.ts +60 -164
  75. package/dist/bot/provision-session.d.ts.map +1 -1
  76. package/dist/bot/provision-session.js +2049 -1546
  77. package/dist/bot/provision-session.js.map +1 -1
  78. package/dist/bot/remote-login-display.d.ts +61 -0
  79. package/dist/bot/remote-login-display.d.ts.map +1 -0
  80. package/dist/bot/remote-login-display.js +765 -0
  81. package/dist/bot/remote-login-display.js.map +1 -0
  82. package/dist/bot/session/hosts.d.ts +8 -0
  83. package/dist/bot/session/hosts.d.ts.map +1 -0
  84. package/dist/bot/session/hosts.js +36 -0
  85. package/dist/bot/session/hosts.js.map +1 -0
  86. package/dist/bot/session/lifecycle.d.ts +56 -0
  87. package/dist/bot/session/lifecycle.d.ts.map +1 -0
  88. package/dist/bot/session/lifecycle.js +962 -0
  89. package/dist/bot/session/lifecycle.js.map +1 -0
  90. package/dist/bot/session/model.d.ts +175 -0
  91. package/dist/bot/session/model.d.ts.map +1 -0
  92. package/dist/bot/session/model.js +77 -0
  93. package/dist/bot/session/model.js.map +1 -0
  94. package/dist/install/cli.d.ts +29 -9
  95. package/dist/install/cli.d.ts.map +1 -1
  96. package/dist/install/cli.js +243 -322
  97. package/dist/install/cli.js.map +1 -1
  98. package/dist/install/interactive.d.ts +1 -0
  99. package/dist/install/interactive.d.ts.map +1 -1
  100. package/dist/install/interactive.js +19 -5
  101. package/dist/install/interactive.js.map +1 -1
  102. package/dist/install/ui.d.ts.map +1 -1
  103. package/dist/install/ui.js +3 -15
  104. package/dist/install/ui.js.map +1 -1
  105. package/dist/operator-env.js +1 -1
  106. package/dist/operator-env.js.map +1 -1
  107. package/dist/server-instance-registry.d.ts +103 -0
  108. package/dist/server-instance-registry.d.ts.map +1 -0
  109. package/dist/server-instance-registry.js +392 -0
  110. package/dist/server-instance-registry.js.map +1 -0
  111. package/dist/server.d.ts +15 -1
  112. package/dist/server.d.ts.map +1 -1
  113. package/dist/server.js +164 -62
  114. package/dist/server.js.map +1 -1
  115. package/dist/session-guard.d.ts +28 -0
  116. package/dist/session-guard.d.ts.map +1 -0
  117. package/dist/session-guard.js +84 -0
  118. package/dist/session-guard.js.map +1 -0
  119. package/dist/session.d.ts +57 -13
  120. package/dist/session.d.ts.map +1 -1
  121. package/dist/session.js +246 -127
  122. package/dist/session.js.map +1 -1
  123. package/dist/tools/audit-log.d.ts +16 -2
  124. package/dist/tools/audit-log.d.ts.map +1 -1
  125. package/dist/tools/audit-log.js +223 -13
  126. package/dist/tools/audit-log.js.map +1 -1
  127. package/dist/tools/audit-rollup.d.ts +80 -0
  128. package/dist/tools/audit-rollup.d.ts.map +1 -0
  129. package/dist/tools/audit-rollup.js +0 -0
  130. package/dist/tools/audit-rollup.js.map +1 -0
  131. package/dist/tools/fetch-credential.d.ts +37 -0
  132. package/dist/tools/fetch-credential.d.ts.map +1 -0
  133. package/dist/tools/fetch-credential.js +153 -0
  134. package/dist/tools/fetch-credential.js.map +1 -0
  135. package/dist/tools/index.d.ts +3 -1
  136. package/dist/tools/index.d.ts.map +1 -1
  137. package/dist/tools/index.js +7 -3
  138. package/dist/tools/index.js.map +1 -1
  139. package/dist/tools/operate-pay.d.ts +4 -4
  140. package/dist/tools/operate-pay.d.ts.map +1 -1
  141. package/dist/tools/operate-pay.js +163 -96
  142. package/dist/tools/operate-pay.js.map +1 -1
  143. package/dist/tools/provision-drive.d.ts +43 -35
  144. package/dist/tools/provision-drive.d.ts.map +1 -1
  145. package/dist/tools/provision-drive.js +268 -164
  146. package/dist/tools/provision-drive.js.map +1 -1
  147. package/dist/tools/use-credential.d.ts.map +1 -1
  148. package/dist/tools/use-credential.js +3 -2
  149. package/dist/tools/use-credential.js.map +1 -1
  150. package/package.json +4 -6
  151. package/dist/bot/operator-direct-identity.d.ts +0 -26
  152. package/dist/bot/operator-direct-identity.d.ts.map +0 -1
  153. package/dist/bot/operator-direct-identity.js +0 -120
  154. package/dist/bot/operator-direct-identity.js.map +0 -1
  155. package/dist/bot/operator-profile-pool.d.ts +0 -97
  156. package/dist/bot/operator-profile-pool.d.ts.map +0 -1
  157. package/dist/bot/operator-profile-pool.js +0 -855
  158. package/dist/bot/operator-profile-pool.js.map +0 -1
  159. package/dist/bot/xvfb.d.ts +0 -13
  160. package/dist/bot/xvfb.d.ts.map +0 -1
  161. package/dist/bot/xvfb.js +0 -146
  162. package/dist/bot/xvfb.js.map +0 -1
@@ -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
- const { payload } = await jwtVerify(jws, createLocalJWKSet(body), {
189
- issuer: "https://vouchflow.dev",
190
- audience: expectedAudience,
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,7 +432,11 @@ function defaultDependencies() {
370
432
  onCardFillCleanupFailed: () => undefined,
371
433
  onSubmitStarted: () => undefined,
372
434
  onApprovalPending: () => undefined,
435
+ onApprovalTerminal: () => undefined,
436
+ onThreeDsHandoffArmed: () => undefined,
437
+ coordinateThreeDsAudit: async (_state, audit) => await audit(),
373
438
  onThreeDsPending: () => undefined,
439
+ onThreeDsCleared: () => undefined,
374
440
  };
375
441
  }
376
442
  function logPaymentReviewLifecycle(event) {
@@ -418,15 +484,28 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
418
484
  }
419
485
  if (resume !== undefined) {
420
486
  let reusable = false;
421
- if (deps.now() < resume.deadline) {
422
- try {
423
- const live = await api.getPaymentApproval(resume.approval_id);
424
- reusable = isLiveResumableApproval(live, resume, deps.now());
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);
425
503
  }
426
- catch (error) {
427
- if (!(error instanceof ApiCallError && error.code === "payment_approval_not_found")) {
428
- throw error;
429
- }
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;
430
509
  }
431
510
  }
432
511
  if (!reusable) {
@@ -538,14 +617,10 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
538
617
  ...(threeDsWaitSeconds !== undefined ? { three_ds_wait_seconds: threeDsWaitSeconds } : {}),
539
618
  });
540
619
  await deps.surfaceApprovalUrl(approvalUrl);
541
- // [P0] This call's own wait budget, bounded by the server approval expiry.
542
- // Undefined pollBudgetMs (direct executeOperatePay callers) = no additional
543
- // bound, i.e. the legacy full-deadline blocking behavior.
620
+ // This call's wait is bounded by both its client budget and server expiry.
544
621
  const callDeadline = deps.pollBudgetMs === undefined
545
622
  ? deadline
546
623
  : Math.min(deadline, deps.now() + deps.pollBudgetMs);
547
- // True once this call's (not the overall) budget is exhausted with no
548
- // resolution — distinguishes "still pending, ask again" from "expired".
549
624
  let budgetExhausted = false;
550
625
  const shouldKeepPolling = () => {
551
626
  const now = deps.now();
@@ -557,37 +632,30 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
557
632
  }
558
633
  return true;
559
634
  };
560
- // In the JIT branch, the approval starts card-less; the card is bound
561
- // server-side mid-ceremony. Track the latest binding so a timeout/expiry
562
- // can distinguish "no card was ever added" (card_required) from "card was
563
- // 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.
564
637
  const timeoutResult = () => {
565
638
  if (jit && !hasBoundCard(boundCardRef)) {
566
639
  return cardRequiredResult(approvalUrl, checkout, "the add-card link expired before a card was added");
567
640
  }
568
- const base = {
569
- status: "payment_approval_timeout",
570
- approval_url: approvalUrl,
571
- merchant: checkout.merchant,
572
- amount_cents: checkout.amount_cents,
573
- currency: checkout.currency,
574
- };
575
- // JIT + a bound card = the user added a card but never approved. The card
576
- // stays in the vault, so the retry is a fast has-card approval.
577
- return jit ? { ...base, card_persisted: true } : base;
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();
578
654
  };
655
+ const expiredApprovalResult = () => terminalApprovalResult("expired");
579
656
  let approved;
580
657
  let claims;
581
- // Always runs at least once — a fresh call must make one live check
582
- // before ever reporting pending, and a resumed call with a zero poll
583
- // budget must too. Two things gate every re-entry to the top: (1) right
584
- // before each sleep, shouldKeepPolling() skips the sleep entirely when
585
- // the budget is already exhausted, so a not-yet-resolved call never
586
- // blocks on a real wait before returning; (2) the iteration>0 check
587
- // here re-validates AFTER that sleep (real time — or a mocked clock —
588
- // may have advanced past the deadline during it) so the loop never
589
- // spends one more live poll call past the budget it just decided to
590
- // respect.
658
+ // Always make one live read; later iterations recheck the budget after sleep.
591
659
  let iteration = 0;
592
660
  let immediateReviewFollowup = false;
593
661
  while (true) {
@@ -595,12 +663,30 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
595
663
  break;
596
664
  immediateReviewFollowup = false;
597
665
  iteration++;
598
- const approval = await api.getPaymentApproval(approvalId, deps.now() < callDeadline ? true : "immediate");
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);
599
684
  boundCardRef = approval.card_ref;
600
- if (approval.status === "expired") {
601
- resumableState = undefined;
602
- keypairHandedOff = false;
603
- return timeoutResult();
685
+ if (approval.status === "denied") {
686
+ return terminalApprovalResult("denied");
687
+ }
688
+ if (approval.status === "expired" || approvalExpired()) {
689
+ return expiredApprovalResult();
604
690
  }
605
691
  const hasCandidate = typeof approval.jws === "string" && typeof approval.sealed_card === "string";
606
692
  if (approval.status === "approved" && !hasCandidate) {
@@ -692,6 +778,8 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
692
778
  approval_url: approvalUrl,
693
779
  };
694
780
  }
781
+ if (approvalExpired())
782
+ return expiredApprovalResult();
695
783
  let candidateCardBytes;
696
784
  let candidateCard;
697
785
  try {
@@ -731,6 +819,9 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
731
819
  }
732
820
  catch (error) {
733
821
  candidateCardBytes.fill(0);
822
+ if (isPaymentApprovalDeniedError(error)) {
823
+ return terminalApprovalResult("denied");
824
+ }
734
825
  const failureReason = error instanceof Error && /404|409/.test(error.message)
735
826
  ? "confirm_status"
736
827
  : "confirm_failed";
@@ -767,33 +858,30 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
767
858
  throw new Error("confirm_status");
768
859
  }
769
860
  catch (error) {
770
- try {
771
- const reconciliation = await api.confirmPaymentApproval(approvalId, candidate);
772
- if (reconciliation.status !== "approved")
773
- throw error;
774
- }
775
- catch {
776
- candidateCardBytes.fill(0);
777
- // The initial confirm may have actually gone through server-side (its
778
- // response was merely lost) even though reconciliation couldn't prove
779
- // it. Throwing here would leave resumableState live, and the outer
780
- // catch would resurrect this candidate as still-awaiting — letting a
781
- // later call re-confirm and re-charge a purchase that may already be
782
- // spent. Fail closed with a terminal result instead, mirroring the
783
- // review-candidate branch above.
784
- const failureReason = error instanceof Error && /404|409/.test(error.message)
785
- ? "confirm_status"
786
- : "confirm_failed";
787
- logPaymentCandidateLifecycle(approvalId, "approval", "confirmation_failed", failureReason);
788
- return {
789
- status: "payment_confirmation_failed",
790
- reason: failureReason,
791
- candidate_kind: "approval",
792
- approval_url: approvalUrl,
793
- };
861
+ candidateCardBytes.fill(0);
862
+ if (isPaymentApprovalDeniedError(error)) {
863
+ return terminalApprovalResult("denied");
794
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
+ };
795
879
  }
796
880
  }
881
+ if (approvalExpired()) {
882
+ candidateCardBytes.fill(0);
883
+ return expiredApprovalResult();
884
+ }
797
885
  logPaymentCandidateLifecycle(approvalId, "approval", "ready_to_charge");
798
886
  cardBytes = candidateCardBytes;
799
887
  card = candidateCard;
@@ -824,10 +912,10 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
824
912
  candidate_kind: "review",
825
913
  ready_to_charge: false,
826
914
  next: {
827
- tool: "operate_payment_status",
828
- wait_seconds: 15,
915
+ tool: "operate_pay",
829
916
  message: "The review signature was verified, but final payment approval is still required. " +
830
- "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.",
831
919
  },
832
920
  };
833
921
  }
@@ -846,7 +934,12 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
846
934
  merchant: checkout.merchant,
847
935
  candidate_kind: "none",
848
936
  ready_to_charge: false,
849
- next: { tool: "operate_payment_status", wait_seconds: 15 },
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
+ },
850
943
  };
851
944
  }
852
945
  if (jit) {
@@ -856,15 +949,14 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
856
949
  }
857
950
  catch { }
858
951
  }
859
- resumableState = undefined;
860
- keypairHandedOff = false;
861
- return timeoutResult();
952
+ return expiredApprovalResult();
862
953
  }
863
954
  if (claims === undefined || card === undefined) {
864
955
  resumableState = undefined;
865
956
  keypairHandedOff = false;
866
957
  return timeoutResult();
867
958
  }
959
+ terminalApprovalState = resumableState?.();
868
960
  resumableState = undefined;
869
961
  keypairHandedOff = false;
870
962
  const cardRef = cardRefArg ?? approved.card_ref;
@@ -875,7 +967,6 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
875
967
  approval_url: approvalUrl,
876
968
  };
877
969
  }
878
- deps.onCardResolved(cardRef);
879
970
  const last4 = card.pan.slice(-4);
880
971
  const mandateId = typeof claims.mandate_id === "string"
881
972
  ? claims.mandate_id
@@ -943,10 +1034,16 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
943
1034
  ...(liveOrigin !== null ? { live_checkout_origin: liveOrigin } : {}),
944
1035
  };
945
1036
  }
1037
+ if (approvalExpired())
1038
+ return expiredApprovalResult();
1039
+ deps.onCardResolved(cardRef);
946
1040
  try {
947
- await browser.fillCheckoutCardFields(card);
1041
+ await browser.fillCheckoutCardFields(card, { deadline });
948
1042
  }
949
1043
  catch (error) {
1044
+ if (error instanceof Error && error.message === "payment_approval_expired") {
1045
+ return expiredApprovalResult();
1046
+ }
950
1047
  const frameOrigin = error instanceof UnrecognizedPaymentFrameError
951
1048
  ? error.frameOrigin
952
1049
  : error instanceof PaymentCardFillCleanupError
@@ -1007,26 +1104,49 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
1007
1104
  "does not need to happen before you place the order.",
1008
1105
  };
1009
1106
  }
1107
+ if (approvalExpired())
1108
+ return expiredApprovalResult();
1109
+ deps.onCardResolved(cardRef);
1110
+ const pendingThreeDsHandoff = {
1111
+ approval_id: approvalId,
1112
+ approval_url: approvalUrl,
1113
+ checkout,
1114
+ last4,
1115
+ ...(mandateId !== undefined ? { mandate_id: mandateId } : {}),
1116
+ deadline: deps.now() + THREE_DS_RESUME_WINDOW_MS,
1117
+ outcome: "unknown",
1118
+ };
1119
+ deps.onThreeDsHandoffArmed(pendingThreeDsHandoff);
1120
+ let retainedPendingThreeDs = null;
1010
1121
  const retainPendingThreeDs = () => {
1011
- deps.onThreeDsPending({
1012
- approval_id: approvalId,
1013
- approval_url: approvalUrl,
1014
- checkout,
1015
- last4,
1016
- ...(submitResult.payment_instrument_mismatch !== undefined
1017
- ? { payment_instrument_mismatch: submitResult.payment_instrument_mismatch }
1018
- : {}),
1019
- ...(mandateId !== undefined ? { mandate_id: mandateId } : {}),
1020
- deadline: deps.now() + THREE_DS_RESUME_WINDOW_MS,
1021
- });
1122
+ const firstRetention = retainedPendingThreeDs === null;
1123
+ if (retainedPendingThreeDs === null) {
1124
+ retainedPendingThreeDs = pendingThreeDsHandoff;
1125
+ }
1126
+ if (submitResult.payment_instrument_mismatch !== undefined) {
1127
+ retainedPendingThreeDs.payment_instrument_mismatch =
1128
+ submitResult.payment_instrument_mismatch;
1129
+ }
1130
+ if (firstRetention)
1131
+ deps.onThreeDsPending(retainedPendingThreeDs);
1022
1132
  };
1023
- const pendingThreeDsNext = {
1133
+ const clearPendingThreeDs = () => {
1134
+ if (retainedPendingThreeDs === null)
1135
+ return;
1136
+ deps.onThreeDsCleared(retainedPendingThreeDs);
1137
+ retainedPendingThreeDs = null;
1138
+ };
1139
+ const pendingOutcomeNext = () => ({
1024
1140
  tool: "operate_payment_status",
1025
1141
  wait_seconds: 15,
1026
- hint: "The 3-D Secure challenge has not resolved. Call operate_payment_status to keep " +
1027
- "checking the same submitted charge — this does not re-release the card or create a " +
1028
- "new approval.",
1029
- };
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
+ });
1030
1150
  let paymentStatus = "payment_submitted";
1031
1151
  let submitResult = { three_ds_required: false, order_confirmed: false };
1032
1152
  try {
@@ -1037,48 +1157,104 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
1037
1157
  ...(args.card_issuer !== undefined
1038
1158
  ? { issuer: args.card_issuer, issuer_source: "bin_metadata" }
1039
1159
  : {}),
1160
+ }, {
1161
+ onSubmitDispatched: retainPendingThreeDs,
1162
+ beforeSubmitDispatch: () => {
1163
+ if (approvalExpired())
1164
+ throw new Error("payment_approval_expired");
1165
+ return deadline - deps.now();
1166
+ },
1040
1167
  });
1041
1168
  if (submitResult.three_ds_required)
1042
1169
  paymentStatus = "payment_3ds_required";
1043
1170
  else if (!submitResult.order_confirmed)
1044
1171
  paymentStatus = "payment_outcome_unknown";
1172
+ if (submitResult.three_ds_required)
1173
+ pendingThreeDsHandoff.outcome = "three_ds";
1045
1174
  }
1046
1175
  catch (error) {
1176
+ if (error instanceof Error && error.message === "payment_approval_expired") {
1177
+ clearPendingThreeDs();
1178
+ return expiredApprovalResult();
1179
+ }
1047
1180
  const outcomeUnknown = error instanceof PaymentSubmitOutcomeUnknownError;
1048
1181
  paymentStatus = outcomeUnknown ? "payment_outcome_unknown" : "payment_checkout_failed";
1182
+ let terminalSubmitOutcome = false;
1049
1183
  if (outcomeUnknown) {
1050
- 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
+ }
1051
1193
  const mismatch = browser.paymentInstrumentMismatch?.();
1052
1194
  if (mismatch !== undefined)
1053
1195
  submitResult.payment_instrument_mismatch = mismatch;
1054
1196
  }
1055
1197
  let audit_recorded = true;
1056
1198
  try {
1057
- await api.auditPayment({
1058
- ...checkout,
1059
- last4,
1060
- status: paymentStatus,
1061
- ...(mandateId !== undefined ? { mandate_id: mandateId } : {}),
1062
- });
1199
+ const recordAudit = async () => {
1200
+ await api.auditPayment({
1201
+ ...checkout,
1202
+ last4,
1203
+ status: paymentStatus,
1204
+ ...(mandateId !== undefined ? { mandate_id: mandateId } : {}),
1205
+ });
1206
+ };
1207
+ if (retainedPendingThreeDs === null)
1208
+ await recordAudit();
1209
+ else
1210
+ await deps.coordinateThreeDsAudit(retainedPendingThreeDs, recordAudit);
1063
1211
  }
1064
1212
  catch {
1065
1213
  audit_recorded = false;
1066
1214
  }
1067
- if (outcomeUnknown)
1215
+ if (outcomeUnknown && !terminalSubmitOutcome && retainedPendingThreeDs !== null) {
1068
1216
  retainPendingThreeDs();
1217
+ }
1218
+ else {
1219
+ clearPendingThreeDs();
1220
+ }
1069
1221
  return {
1070
1222
  status: paymentStatus,
1071
1223
  audit_recorded,
1072
- reason: outcomeUnknown
1073
- ? "payment_submit_outcome_unknown"
1074
- : error instanceof Error && /^payment_[a-z_]+(?::[a-z_]+)?$/.test(error.message)
1075
- ? error.message
1076
- : "payment_checkout_failed",
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
+ : {}),
1077
1235
  approval_url: approvalUrl,
1078
1236
  ...(submitResult.payment_instrument_mismatch !== undefined
1079
1237
  ? { warning: submitResult.payment_instrument_mismatch }
1080
1238
  : {}),
1081
- ...(outcomeUnknown ? { next: pendingThreeDsNext } : {}),
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
+ : {}),
1082
1258
  };
1083
1259
  }
1084
1260
  finally {
@@ -1090,20 +1266,32 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
1090
1266
  if (submitResult.payment_instrument_mismatch === undefined &&
1091
1267
  !submitResult.order_confirmed &&
1092
1268
  threeDsWaitMs > 0) {
1093
- getThreeDsTelegramSent = trackThreeDsNotification(api.notifyThreeDs(approvalId, threeDsNotificationMode(submitResult)));
1269
+ const challengeKnownBeforeWait = pendingThreeDsHandoff.outcome === "three_ds";
1270
+ if (challengeKnownBeforeWait) {
1271
+ getThreeDsTelegramSent = trackThreeDsNotification(api.notifyThreeDs(approvalId, threeDsNotificationMode(submitResult)));
1272
+ }
1094
1273
  const resolution = await browser.waitForThreeDsResolution(threeDsWaitMs);
1095
1274
  const mismatch = browser.paymentInstrumentMismatch?.();
1096
1275
  if (mismatch !== undefined)
1097
1276
  submitResult.payment_instrument_mismatch ??= mismatch;
1098
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
+ }
1099
1285
  }
1100
1286
  let auditRecorded = true;
1101
1287
  try {
1102
- await api.auditPayment({
1103
- ...checkout,
1104
- last4,
1105
- status: paymentStatus,
1106
- ...(mandateId !== undefined ? { mandate_id: mandateId } : {}),
1288
+ await deps.coordinateThreeDsAudit(pendingThreeDsHandoff, async () => {
1289
+ await api.auditPayment({
1290
+ ...checkout,
1291
+ last4,
1292
+ status: paymentStatus,
1293
+ ...(mandateId !== undefined ? { mandate_id: mandateId } : {}),
1294
+ });
1107
1295
  });
1108
1296
  }
1109
1297
  catch {
@@ -1121,16 +1309,23 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
1121
1309
  ...(submitResult.payment_instrument_mismatch !== undefined
1122
1310
  ? { warning: submitResult.payment_instrument_mismatch }
1123
1311
  : {}),
1124
- needs_user: {
1125
- wall: "3ds",
1126
- message: threeDsHandoffMessage(submitResult, getThreeDsTelegramSent()),
1127
- resume: "checkout",
1128
- ...(submitResult.challenge_url !== undefined ? { url: submitResult.challenge_url } : {}),
1129
- },
1130
- next: pendingThreeDsNext,
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(),
1131
1325
  };
1132
1326
  }
1133
1327
  if (paymentStatus === "payment_declined") {
1328
+ clearPendingThreeDs();
1134
1329
  return {
1135
1330
  status: paymentStatus,
1136
1331
  audit_recorded: auditRecorded,
@@ -1140,6 +1335,7 @@ export async function executeOperatePay(args, api, browser, overrides = {}) {
1140
1335
  : {}),
1141
1336
  };
1142
1337
  }
1338
+ clearPendingThreeDs();
1143
1339
  return {
1144
1340
  status: paymentStatus,
1145
1341
  audit_recorded: auditRecorded,