@visa/cli 4.1.0-rc.154 → 4.1.0-rc.156

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.
@@ -24,7 +24,8 @@ import { observeOutcome } from './outcome.js';
24
24
  import { selectAdapter } from './adapters/index.js';
25
25
  import { traceHandleFields } from './trace-handles.js';
26
26
  import { readGenericPageAmount } from './amount.js';
27
- import { detectShopifyChallenge, isShopifyCheckoutPage, readShopifyAmount, readStableShopifyAmount, } from './adapters/shopify.js';
27
+ import { webBotAuthHeadersOrNone } from './web-bot-auth.js';
28
+ import { detectShopifyChallenge, isShopifyCheckoutPage, readShopifyAmount, readStableShopifyAmount, shopifyEnglishCheckoutUrl, } from './adapters/shopify.js';
28
29
  export { minorFromDecimal, pageCurrency } from './amount.js';
29
30
  const SUBMIT_TEXT = /pay|place order|complete|buy|submit|checkout/i;
30
31
  const REVEAL_TEXT = /continue|next|proceed|review|go to payment/i;
@@ -674,7 +675,21 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
674
675
  mandate: { ...opts.mandate },
675
676
  };
676
677
  const evidence = new EvidenceLog();
677
- const context = await options.browser.newContext();
678
+ // Pin an English locale: amount reconciliation reads the order summary by
679
+ // its visible labels (Subtotal/Taxes/Total), and merchants localize by
680
+ // Accept-Language (observed live 2026-08-16: a Shopify checkout redirected
681
+ // to /es-us and rendered "Impuestos estimados", so the total never parsed
682
+ // and the review refused fail-closed on a perfectly good checkout).
683
+ // Present Web Bot Auth (RFC 9421) credentials when, and only when, an
684
+ // operator directory is configured to resolve them. Off by default: an
685
+ // unresolvable signature fails verification and is worse than none. The
686
+ // signature covers @authority, so it is bound to the checkout host — a
687
+ // cross-origin redirect simply arrives unverified, never wrongly verified.
688
+ const webBotAuthHeaders = webBotAuthHeadersOrNone(options.webBotAuth ?? null, options.url, Date.now() / 1000);
689
+ const context = await options.browser.newContext({
690
+ locale: 'en-US',
691
+ ...(webBotAuthHeaders ? { extraHTTPHeaders: webBotAuthHeaders } : {}),
692
+ });
678
693
  // Bound every action so a mis-detected or hidden element fails fast instead
679
694
  // of stalling on Playwright's long default timeout.
680
695
  context.setDefaultTimeout(6000);
@@ -732,6 +747,19 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
732
747
  // Instrument.getCredential() call can occur before an explicit approval.
733
748
  let detected = await detectFields(page);
734
749
  const shopifyPage = await isShopifyCheckoutPage(page);
750
+ if (shopifyPage) {
751
+ // Same checkout session, English presentation — the amount reader needs
752
+ // the English summary labels (see shopifyEnglishCheckoutUrl).
753
+ const englishUrl = shopifyEnglishCheckoutUrl(page.url());
754
+ if (englishUrl) {
755
+ await page.goto(englishUrl, { waitUntil: 'domcontentloaded' }).catch(() => { });
756
+ await waitForStableDom(page);
757
+ if (await isShopifyCheckoutPage(page)) {
758
+ evidence.step('navigation', { url: page.url(), reason: 'shopify-locale-normalized' });
759
+ detected = await detectFields(page);
760
+ }
761
+ }
762
+ }
735
763
  let adapter = selectAdapter(detected, { shopify: shopifyPage });
736
764
  if (options.contact && adapter.prepareContact) {
737
765
  const preparedContact = await adapter.prepareContact(page, options.contact);
@@ -799,6 +827,26 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
799
827
  evidence.step('psp-detected', { psp: p.psp, requiresAdapter: p.requiresAdapter });
800
828
  }
801
829
  }
830
+ // A checkout the runner cannot put a card INTO is not reviewable. Without a
831
+ // detected card-number field a later pay would fill nothing and dispatch
832
+ // whatever control the review happened to bind (observed live 2026-08-16:
833
+ // a Payhip storefront SEARCH form, a FastSpring "PayPal Checkout" label,
834
+ // and a Shopify discount-form "Submit" all reviewed clean this way — the
835
+ // real card fields sat in unreachable PSP iframes or an unrendered payment
836
+ // section). Every adapter fills from this same detection, so a missing
837
+ // number field here means no pay can ever succeed: refuse while it is
838
+ // still free.
839
+ if (!fields.number) {
840
+ const found = Object.keys(fields);
841
+ const reason = `no card number field detected (roles found: ${found.length ? found.join(', ') : 'none'}) — ` +
842
+ 'the card form is likely inside a PSP iframe or behind a later step, so a credential cannot be entered on this page';
843
+ evidence.step('detect', { phase: 'review', missingCardNumber: true, reason });
844
+ evidence.setSnapshotSummary(await snapshotSummary(page));
845
+ return {
846
+ status: 'finished',
847
+ result: makeResult('failed', fields, evidence, requiresAdapter, reason),
848
+ };
849
+ }
802
850
  const facts = await readTransactionFacts(page, options, 'review');
803
851
  recordTransactionFacts(evidence, 'review', facts);
804
852
  if (!facts.ok) {
@@ -61,6 +61,13 @@ export type CardMandateRecord = {
61
61
  * created record attempted registration. ISO 8601.
62
62
  */
63
63
  registerFailedAt?: string;
64
+ /**
65
+ * Set after an authenticated owner-scoped server read proves this mandate was
66
+ * revoked. The local file is only an execution cache; server revocation is
67
+ * canonical and permanently retires the cached mandate from selection while
68
+ * retaining its receipt history for the owner.
69
+ */
70
+ serverRevokedAt?: string;
64
71
  /**
65
72
  * A cross-host retail budget, not scoped to one `merchantHost`. The local
66
73
  * selector may attempt it at any host, while the provider/network still
@@ -127,6 +134,12 @@ export declare class MandateLedger {
127
134
  * Throws only if the mandate is unknown.
128
135
  */
129
136
  markRegisterFailed(mandateId: string, now?: Date): Promise<CardMandateRecord>;
137
+ /**
138
+ * Retire a locally cached mandate after the authenticated account API proves
139
+ * it was revoked. This is idempotent and monotonic: an owner revocation can
140
+ * never be undone by a later stale/local read.
141
+ */
142
+ markServerRevoked(mandateId: string, revokedAt: string): Promise<CardMandateRecord>;
130
143
  /**
131
144
  * ONE SPENDING LIMIT: adopt the ceiling the SERVER actually approved.
132
145
  *
@@ -203,6 +203,7 @@ export class MandateLedger {
203
203
  m.currencyCode.toUpperCase() === query.currencyCode.toUpperCase() &&
204
204
  !m.unhonoredAt &&
205
205
  !m.registerFailedAt &&
206
+ !m.serverRevokedAt &&
206
207
  !isExpired(m, now) &&
207
208
  hasDrawCountHeadroom(m) &&
208
209
  remainingMinor(m) >= query.amountMinor);
@@ -252,6 +253,27 @@ export class MandateLedger {
252
253
  return record;
253
254
  });
254
255
  }
256
+ /**
257
+ * Retire a locally cached mandate after the authenticated account API proves
258
+ * it was revoked. This is idempotent and monotonic: an owner revocation can
259
+ * never be undone by a later stale/local read.
260
+ */
261
+ markServerRevoked(mandateId, revokedAt) {
262
+ return this.run(async () => {
263
+ const parsed = new Date(revokedAt);
264
+ if (Number.isNaN(parsed.getTime()))
265
+ throw new Error('server revocation timestamp is invalid');
266
+ const file = await this.load();
267
+ const record = file.mandates.find((m) => m.mandateId === mandateId);
268
+ if (!record)
269
+ throw new Error(`no such mandate ${mandateId}`);
270
+ if (!record.serverRevokedAt) {
271
+ record.serverRevokedAt = parsed.toISOString();
272
+ await this.save(file);
273
+ }
274
+ return record;
275
+ });
276
+ }
255
277
  /**
256
278
  * ONE SPENDING LIMIT: adopt the ceiling the SERVER actually approved.
257
279
  *
@@ -1,7 +1,8 @@
1
1
  import type { EvidenceStep } from './evidence.js';
2
2
  import type { CheckoutMode, CheckoutOutcome, CheckoutResult, CredentialLifecycle, CredentialTiming } from './executor.js';
3
3
  import type { VicConfirmationReport } from './vic-confirmation.js';
4
- export type CheckoutReceipt = {
4
+ /** Published v1 artifact shape. Retained so patch releases remain readable/typable. */
5
+ export type CheckoutReceiptV1 = {
5
6
  schema: 'checkout-agent-receipt/v1';
6
7
  recordedAt: string;
7
8
  mode: CheckoutMode;
@@ -32,6 +33,42 @@ export type CheckoutReceipt = {
32
33
  snapshotSummary: string | null;
33
34
  };
34
35
  };
36
+ export type CheckoutReceiptV2 = {
37
+ schema: 'checkout-agent-receipt/v2';
38
+ recordedAt: string;
39
+ merchant: {
40
+ name: string;
41
+ host: string;
42
+ checkoutUrl: string | null;
43
+ };
44
+ transaction: {
45
+ amount: string;
46
+ amountMinor: number;
47
+ currency: string;
48
+ };
49
+ outcome: CheckoutOutcome;
50
+ agent: {
51
+ name: string;
52
+ } | null;
53
+ rail: {
54
+ type: 'card';
55
+ cardLast4: string | null;
56
+ };
57
+ network: {
58
+ confirmation: 'APPROVED' | 'DECLINED' | null;
59
+ };
60
+ /** Local reviewed-attempt identifier. Keeps filenames stable across schema versions. */
61
+ receiptId: string | null;
62
+ /** Merchant confirmation reference only; never substituted with an internal review id. */
63
+ reference: string | null;
64
+ recovery: {
65
+ required: boolean;
66
+ retrySafe: boolean;
67
+ action: string;
68
+ actions: string[];
69
+ };
70
+ };
71
+ export type CheckoutReceipt = CheckoutReceiptV1 | CheckoutReceiptV2;
35
72
  export type ReceiptWriteReport = {
36
73
  written: true;
37
74
  path: string;
@@ -46,6 +83,7 @@ export declare function buildReceipt(input: {
46
83
  merchant: {
47
84
  name: string;
48
85
  host: string;
86
+ url?: string;
49
87
  };
50
88
  transaction: {
51
89
  amount: string;
@@ -54,9 +92,11 @@ export declare function buildReceipt(input: {
54
92
  };
55
93
  result: CheckoutResult;
56
94
  vicConfirmation: VicConfirmationReport | null;
95
+ agentName?: string;
96
+ cardLast4?: string;
57
97
  /** Injectable for tests; defaults to now. */
58
98
  recordedAt?: Date;
59
- }): CheckoutReceipt;
99
+ }): CheckoutReceiptV2;
60
100
  /**
61
101
  * Defense-in-depth scrub before anything touches disk: any standalone
62
102
  * 12–19-digit run that passes Luhn is replaced — contiguous OR separated by
@@ -1,5 +1,6 @@
1
1
  import { mkdir, writeFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
+ import { KNOWN_MERCHANT_IDENTITIES } from './known-merchants.js';
3
4
  import { submitClickedWithoutConfirmation } from './live-fill-approval.js';
4
5
  /**
5
6
  * What the operator still owes after this run. Empty for a clean dry-run fill
@@ -29,23 +30,37 @@ function reconciliationFor(result, vicConfirmation) {
29
30
  }
30
31
  export function buildReceipt(input) {
31
32
  const { result } = input;
33
+ const reconciliation = reconciliationFor(result, input.vicConfirmation);
34
+ const fallbackAction = input.mode === 'dry-run'
35
+ ? 'Dry run only — nothing was submitted; safe to run again.'
36
+ : result.outcome === 'confirmed'
37
+ ? 'No action needed.'
38
+ : result.outcome === 'declined'
39
+ ? 'Review the decline before trying again.'
40
+ : 'Review this outcome before retrying.';
41
+ const recoveryActions = reconciliation.reasons.length > 0 ? reconciliation.reasons : [fallbackAction];
42
+ const cardLast4 = input.cardLast4 && /^\d{4}$/.test(input.cardLast4) ? input.cardLast4 : null;
43
+ const checkoutUrl = input.merchant.url && KNOWN_MERCHANT_IDENTITIES[input.merchant.url] ? input.merchant.url : null;
44
+ const networkConfirmation = input.vicConfirmation?.posted === true ? input.vicConfirmation.transactionStatus : null;
32
45
  return {
33
- schema: 'checkout-agent-receipt/v1',
46
+ schema: 'checkout-agent-receipt/v2',
34
47
  recordedAt: (input.recordedAt ?? new Date()).toISOString(),
35
- mode: input.mode,
36
- reviewId: input.reviewId,
37
- merchant: { name: input.merchant.name, host: input.merchant.host },
48
+ merchant: { name: input.merchant.name, host: input.merchant.host, checkoutUrl },
38
49
  transaction: input.transaction,
39
50
  outcome: result.outcome,
40
- detail: result.detail ?? null,
41
- confirmationRef: result.confirmationRef ?? null,
42
- vicConfirmation: input.vicConfirmation,
43
- credentialLifecycle: result.credentialLifecycle,
44
- credentialTiming: result.credentialTiming,
45
- detectedRoles: Object.keys(result.fields),
46
- requiresAdapter: [...result.requiresAdapter],
47
- reconciliation: reconciliationFor(result, input.vicConfirmation),
48
- evidence: result.evidence.toObject(),
51
+ agent: input.agentName ? { name: input.agentName } : null,
52
+ rail: { type: 'card', cardLast4 },
53
+ network: { confirmation: networkConfirmation },
54
+ receiptId: input.reviewId,
55
+ reference: result.confirmationRef ?? null,
56
+ recovery: {
57
+ required: reconciliation.required,
58
+ // A clean dry-run did not submit anything, so repeating inspection is
59
+ // safe. Every submit outcome requires either no retry or owner review.
60
+ retrySafe: input.mode === 'dry-run' && !reconciliation.required,
61
+ action: recoveryActions[0],
62
+ actions: recoveryActions,
63
+ },
49
64
  };
50
65
  }
51
66
  function luhnValid(digits) {
@@ -88,7 +103,8 @@ export function serializeReceipt(receipt) {
88
103
  }
89
104
  export function receiptFileName(receipt) {
90
105
  const ts = receipt.recordedAt.replace(/[:.]/g, '-');
91
- const review = (receipt.reviewId ?? 'no-review').replace(/[^A-Za-z0-9-]/g, '');
106
+ const receiptId = receipt.schema === 'checkout-agent-receipt/v1' ? receipt.reviewId : receipt.receiptId;
107
+ const review = (receiptId ?? 'no-review').replace(/[^A-Za-z0-9-]/g, '');
92
108
  return `receipt-${ts}-${review || 'no-review'}.json`;
93
109
  }
94
110
  /**
@@ -0,0 +1,92 @@
1
+ /** Signature validity ceiling. A signature is a bearer-ish artifact for as long
2
+ * as it is valid, so the window stays short even if a caller asks for more. */
3
+ export declare const WEB_BOT_AUTH_MAX_TTL_SECONDS = 300;
4
+ /** Default validity. Long enough to survive a redirect chain and a slow TLS
5
+ * handshake, short enough that a captured signature is near-useless. */
6
+ export declare const WEB_BOT_AUTH_DEFAULT_TTL_SECONDS = 60;
7
+ /** Label for the single signature we emit. RFC 9421 allows several per request. */
8
+ export declare const WEB_BOT_AUTH_SIGNATURE_LABEL = "sig1";
9
+ /** Tag fixed by the Web Bot Auth draft; verifiers select on it. */
10
+ export declare const WEB_BOT_AUTH_TAG = "web-bot-auth";
11
+ /** Components covered by the signature, in the order they appear in the base. */
12
+ export declare const WEB_BOT_AUTH_COVERED_COMPONENTS: readonly ["@authority", "signature-agent"];
13
+ /** Ed25519 private key in JWK form, as stored in the agent's runtime key file. */
14
+ export interface WebBotAuthPrivateJwk {
15
+ kty: 'OKP';
16
+ crv: 'Ed25519';
17
+ x: string;
18
+ d: string;
19
+ }
20
+ export interface WebBotAuthSigningKey {
21
+ /** RFC 7638 thumbprint. Must match a `kid` published in the directory. */
22
+ keyId: string;
23
+ privateJwk: WebBotAuthPrivateJwk;
24
+ }
25
+ export interface WebBotAuthConfig {
26
+ /** Absolute https URL of the operator-hosted signature directory. */
27
+ directoryUrl: string;
28
+ key: WebBotAuthSigningKey;
29
+ ttlSeconds?: number;
30
+ }
31
+ export interface WebBotAuthHeaders {
32
+ 'Signature-Input': string;
33
+ Signature: string;
34
+ 'Signature-Agent': string;
35
+ [header: string]: string;
36
+ }
37
+ export interface SignatureBaseParams {
38
+ authority: string;
39
+ directoryUrl: string;
40
+ keyId: string;
41
+ created: number;
42
+ expires: number;
43
+ }
44
+ /**
45
+ * Build the RFC 9421 signature base.
46
+ *
47
+ * The base is what actually gets signed, so its exact bytes are the contract
48
+ * with every verifier — one wrong space or a reordered parameter and the
49
+ * signature fails against a correct key. It is exported and pinned by a
50
+ * deterministic test vector for that reason.
51
+ */
52
+ export declare function buildSignatureBase(params: SignatureBaseParams): string;
53
+ /**
54
+ * The `@signature-params` value, which appears twice: as the final line of the
55
+ * signature base, and verbatim as the `Signature-Input` header value. Building
56
+ * it once is what keeps those two identical — a verifier rederives the base
57
+ * from the header it received, so any divergence fails every signature.
58
+ */
59
+ export declare function buildSignatureParams(params: SignatureBaseParams): string;
60
+ export interface BuildHeadersParams {
61
+ /** Host (and port, if non-default) of the request being signed. */
62
+ authority: string;
63
+ config: WebBotAuthConfig;
64
+ /** Unix seconds. Injected so tests are deterministic and never wall-clock. */
65
+ nowSeconds: number;
66
+ }
67
+ /**
68
+ * Produce the three Web Bot Auth headers for one request authority.
69
+ *
70
+ * Throws on unusable key material rather than emitting an unverifiable
71
+ * signature. Callers on the checkout path must treat a throw as "proceed
72
+ * unsigned" — see `webBotAuthHeadersOrNone`.
73
+ */
74
+ export declare function buildWebBotAuthHeaders(params: BuildHeadersParams): WebBotAuthHeaders;
75
+ /**
76
+ * Header-or-nothing wrapper for the checkout path.
77
+ *
78
+ * Signing is an optional trust upgrade, never a precondition for buying
79
+ * something. Unusable key material, a malformed URL, or any crypto failure
80
+ * degrades to an unsigned request — which is exactly what we sent before this
81
+ * module existed — rather than throwing into a live checkout.
82
+ */
83
+ export declare function webBotAuthHeadersOrNone(config: WebBotAuthConfig | null, targetUrl: string, nowSeconds: number): WebBotAuthHeaders | null;
84
+ /**
85
+ * Resolve signing config from the environment. Returns `null` — meaning "send
86
+ * unsigned" — unless a directory URL and a usable key are BOTH present.
87
+ *
88
+ * Default-off is deliberate. Until the operator directory is live and serving
89
+ * this key, a signature resolves to nothing and fails verification, which is
90
+ * worse than the honest unsigned request we sent before.
91
+ */
92
+ export declare function resolveWebBotAuthConfig(env: NodeJS.ProcessEnv, loadKey: () => WebBotAuthSigningKey | null): WebBotAuthConfig | null;
@@ -0,0 +1,159 @@
1
+ /**
2
+ * @purpose Web Bot Auth (RFC 9421) request signing for checkout navigations —
3
+ * the client half of Trusted Agent Protocol verification.
4
+ *
5
+ * Cloudflare's TAP verifies an agent by checking an RFC 9421 HTTP message
6
+ * signature on the request. Three headers carry it: `Signature-Input` names the
7
+ * covered components and parameters, `Signature` carries the Ed25519 signature
8
+ * over the derived base, and `Signature-Agent` gives the URL of the directory
9
+ * that resolves `keyid` to a public key. The checkout browser previously sent
10
+ * none of them, so a TAP-aware edge had nothing to verify and fell through to
11
+ * ordinary bot management.
12
+ *
13
+ * TWO HALVES, BOTH REQUIRED. This module presents a `keyid`; the auth server
14
+ * must publish that key at the `Signature-Agent` URL for anything to resolve.
15
+ * Signing while the directory is absent is strictly worse than not signing,
16
+ * because it advertises intent and then fails verification — which is why
17
+ * signing stays OFF unless explicitly configured with a directory URL.
18
+ *
19
+ * KEY MATERIAL NEVER LEAVES THIS MODULE. The signing key is accepted as a
20
+ * value, used once per request, and never logged, serialized, returned, or
21
+ * attached to evidence. Callers get headers, never the key. This module takes
22
+ * no logger for exactly that reason.
23
+ */
24
+ import { createPrivateKey, sign as cryptoSign } from 'node:crypto';
25
+ /** Signature validity ceiling. A signature is a bearer-ish artifact for as long
26
+ * as it is valid, so the window stays short even if a caller asks for more. */
27
+ export const WEB_BOT_AUTH_MAX_TTL_SECONDS = 300;
28
+ /** Default validity. Long enough to survive a redirect chain and a slow TLS
29
+ * handshake, short enough that a captured signature is near-useless. */
30
+ export const WEB_BOT_AUTH_DEFAULT_TTL_SECONDS = 60;
31
+ /** Label for the single signature we emit. RFC 9421 allows several per request. */
32
+ export const WEB_BOT_AUTH_SIGNATURE_LABEL = 'sig1';
33
+ /** Tag fixed by the Web Bot Auth draft; verifiers select on it. */
34
+ export const WEB_BOT_AUTH_TAG = 'web-bot-auth';
35
+ /** Components covered by the signature, in the order they appear in the base. */
36
+ export const WEB_BOT_AUTH_COVERED_COMPONENTS = ['@authority', 'signature-agent'];
37
+ /** Serialize an RFC 8941 structured-field string (always double-quoted). */
38
+ function sfString(value) {
39
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
40
+ }
41
+ /**
42
+ * Build the RFC 9421 signature base.
43
+ *
44
+ * The base is what actually gets signed, so its exact bytes are the contract
45
+ * with every verifier — one wrong space or a reordered parameter and the
46
+ * signature fails against a correct key. It is exported and pinned by a
47
+ * deterministic test vector for that reason.
48
+ */
49
+ export function buildSignatureBase(params) {
50
+ return [
51
+ `"@authority": ${params.authority}`,
52
+ `"signature-agent": ${sfString(params.directoryUrl)}`,
53
+ `"@signature-params": ${buildSignatureParams(params)}`,
54
+ ].join('\n');
55
+ }
56
+ /**
57
+ * The `@signature-params` value, which appears twice: as the final line of the
58
+ * signature base, and verbatim as the `Signature-Input` header value. Building
59
+ * it once is what keeps those two identical — a verifier rederives the base
60
+ * from the header it received, so any divergence fails every signature.
61
+ */
62
+ export function buildSignatureParams(params) {
63
+ return (`(${WEB_BOT_AUTH_COVERED_COMPONENTS.map(sfString).join(' ')})` +
64
+ `;created=${params.created}` +
65
+ `;expires=${params.expires}` +
66
+ `;keyid=${sfString(params.keyId)}` +
67
+ `;alg="ed25519"` +
68
+ `;tag=${sfString(WEB_BOT_AUTH_TAG)}`);
69
+ }
70
+ /**
71
+ * Produce the three Web Bot Auth headers for one request authority.
72
+ *
73
+ * Throws on unusable key material rather than emitting an unverifiable
74
+ * signature. Callers on the checkout path must treat a throw as "proceed
75
+ * unsigned" — see `webBotAuthHeadersOrNone`.
76
+ */
77
+ export function buildWebBotAuthHeaders(params) {
78
+ const { authority, config, nowSeconds } = params;
79
+ const ttl = Math.min(Math.max(1, Math.floor(config.ttlSeconds ?? WEB_BOT_AUTH_DEFAULT_TTL_SECONDS)), WEB_BOT_AUTH_MAX_TTL_SECONDS);
80
+ const created = Math.floor(nowSeconds);
81
+ const expires = created + ttl;
82
+ const baseParams = {
83
+ authority,
84
+ directoryUrl: config.directoryUrl,
85
+ keyId: config.key.keyId,
86
+ created,
87
+ expires,
88
+ };
89
+ const base = buildSignatureBase(baseParams);
90
+ const signatureParams = buildSignatureParams(baseParams);
91
+ const privateKey = createPrivateKey({
92
+ key: config.key.privateJwk,
93
+ format: 'jwk',
94
+ });
95
+ // Ed25519 takes no separate digest algorithm; null is the required argument.
96
+ const signature = cryptoSign(null, Buffer.from(base, 'utf8'), privateKey);
97
+ return {
98
+ 'Signature-Input': `${WEB_BOT_AUTH_SIGNATURE_LABEL}=${signatureParams}`,
99
+ Signature: `${WEB_BOT_AUTH_SIGNATURE_LABEL}=:${signature.toString('base64')}:`,
100
+ 'Signature-Agent': sfString(config.directoryUrl),
101
+ };
102
+ }
103
+ /**
104
+ * Header-or-nothing wrapper for the checkout path.
105
+ *
106
+ * Signing is an optional trust upgrade, never a precondition for buying
107
+ * something. Unusable key material, a malformed URL, or any crypto failure
108
+ * degrades to an unsigned request — which is exactly what we sent before this
109
+ * module existed — rather than throwing into a live checkout.
110
+ */
111
+ export function webBotAuthHeadersOrNone(config, targetUrl, nowSeconds) {
112
+ if (!config)
113
+ return null;
114
+ try {
115
+ const authority = new URL(targetUrl).host;
116
+ if (!authority)
117
+ return null;
118
+ return buildWebBotAuthHeaders({ authority, config, nowSeconds });
119
+ }
120
+ catch {
121
+ return null;
122
+ }
123
+ }
124
+ /**
125
+ * Resolve signing config from the environment. Returns `null` — meaning "send
126
+ * unsigned" — unless a directory URL and a usable key are BOTH present.
127
+ *
128
+ * Default-off is deliberate. Until the operator directory is live and serving
129
+ * this key, a signature resolves to nothing and fails verification, which is
130
+ * worse than the honest unsigned request we sent before.
131
+ */
132
+ export function resolveWebBotAuthConfig(env, loadKey) {
133
+ const directoryUrl = env.VISA_WEB_BOT_AUTH_DIRECTORY_URL?.trim();
134
+ if (!directoryUrl)
135
+ return null;
136
+ let parsed;
137
+ try {
138
+ parsed = new URL(directoryUrl);
139
+ }
140
+ catch {
141
+ return null;
142
+ }
143
+ // A directory fetched over plain http could be swapped in flight, which would
144
+ // let an observer substitute the key set our identity is judged against.
145
+ if (parsed.protocol !== 'https:')
146
+ return null;
147
+ let key = null;
148
+ try {
149
+ key = loadKey();
150
+ }
151
+ catch {
152
+ return null;
153
+ }
154
+ if (!key?.keyId || key.privateJwk?.crv !== 'Ed25519' || !key.privateJwk.d)
155
+ return null;
156
+ const rawTtl = Number(env.VISA_WEB_BOT_AUTH_TTL_SECONDS);
157
+ const ttlSeconds = Number.isFinite(rawTtl) && rawTtl > 0 ? rawTtl : undefined;
158
+ return { directoryUrl: parsed.toString(), key, ttlSeconds };
159
+ }