@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.
Files changed (162) hide show
  1. package/README.md +136 -64
  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 +85 -80
  13. package/dist/bot/browser.d.ts.map +1 -1
  14. package/dist/bot/browser.js +1314 -1570
  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 +18 -6
  53. package/dist/bot/operator-browser-watchdog.d.ts.map +1 -1
  54. package/dist/bot/operator-browser-watchdog.js +91 -37
  55. package/dist/bot/operator-browser-watchdog.js.map +1 -1
  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 +7 -1
  65. package/dist/bot/pay-operator.d.ts.map +1 -1
  66. package/dist/bot/pay-operator.js +269 -103
  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 +57 -186
  75. package/dist/bot/provision-session.d.ts.map +1 -1
  76. package/dist/bot/provision-session.js +1976 -1812
  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 +151 -96
  142. package/dist/tools/operate-pay.js.map +1 -1
  143. package/dist/tools/provision-drive.d.ts +47 -39
  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,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
- if (deps.now() < resume.deadline) {
425
- try {
426
- const live = await api.getPaymentApproval(resume.approval_id);
427
- 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);
428
503
  }
429
- catch (error) {
430
- if (!(error instanceof ApiCallError && error.code === "payment_approval_not_found")) {
431
- throw error;
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
- // [P0] This call's own wait budget, bounded by the server approval expiry.
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
- // In the JIT branch, the approval starts card-less; the card is bound
564
- // server-side mid-ceremony. Track the latest binding so a timeout/expiry
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
- const base = {
572
- status: "payment_approval_timeout",
573
- approval_url: approvalUrl,
574
- merchant: checkout.merchant,
575
- amount_cents: checkout.amount_cents,
576
- currency: checkout.currency,
577
- };
578
- // JIT + a bound card = the user added a card but never approved. The card
579
- // stays in the vault, so the retry is a fast has-card approval.
580
- 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();
581
654
  };
655
+ const expiredApprovalResult = () => terminalApprovalResult("expired");
582
656
  let approved;
583
657
  let claims;
584
- // Always runs at least once — a fresh call must make one live check
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 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);
602
684
  boundCardRef = approval.card_ref;
603
- if (approval.status === "expired") {
604
- resumableState = undefined;
605
- keypairHandedOff = false;
606
- return timeoutResult();
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
- try {
774
- const reconciliation = await api.confirmPaymentApproval(approvalId, candidate);
775
- if (reconciliation.status !== "approved")
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: "operate_payment_status",
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: { 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
+ },
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
- resumableState = undefined;
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 pendingThreeDsNext = {
1139
+ const pendingOutcomeNext = () => ({
1042
1140
  tool: "operate_payment_status",
1043
1141
  wait_seconds: 15,
1044
- hint: "The 3-D Secure challenge has not resolved. Call operate_payment_status to keep " +
1045
- "checking the same submitted charge — this does not re-release the card or create a " +
1046
- "new approval.",
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
- }, { onSubmitDispatched: retainPendingThreeDs });
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
- else if (!outcomeUnknown)
1217
+ }
1218
+ else {
1094
1219
  clearPendingThreeDs();
1220
+ }
1095
1221
  return {
1096
1222
  status: paymentStatus,
1097
1223
  audit_recorded,
1098
- reason: outcomeUnknown
1099
- ? "payment_submit_outcome_unknown"
1100
- : error instanceof Error && /^payment_[a-z_]+(?::[a-z_]+)?$/.test(error.message)
1101
- ? error.message
1102
- : "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
+ : {}),
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 ? { 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
+ : {}),
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
- 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
+ }
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
- needs_user: {
1153
- wall: "3ds",
1154
- message: threeDsHandoffMessage(submitResult, getThreeDsTelegramSent()),
1155
- resume: "checkout",
1156
- ...(submitResult.challenge_url !== undefined ? { url: submitResult.challenge_url } : {}),
1157
- },
1158
- 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(),
1159
1325
  };
1160
1326
  }
1161
1327
  if (paymentStatus === "payment_declined") {