@visa/cli 4.1.0-rc.261 → 4.1.0-rc.263

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.
@@ -63,9 +63,10 @@ export class ServerCryptogramRefusedError extends Error {
63
63
  this.status = status;
64
64
  }
65
65
  }
66
- /** Read a stable, non-secret error message from a route's JSON body. */
67
- async function routeError(res) {
68
- const doc = (await res.json().catch(() => null));
66
+ async function readRouteErrorDoc(res) {
67
+ return (await res.json().catch(() => null));
68
+ }
69
+ function routeErrorMessage(res, doc) {
69
70
  const base = doc?.error || doc?.error_code || `HTTP ${res.status}`;
70
71
  // Carry the provider's own status and rejected-attribute identifiers into the
71
72
  // message when the route reflected them. Without this the operator sees only
@@ -79,6 +80,61 @@ async function routeError(res) {
79
80
  }
80
81
  return parts.length > 0 ? `${base} [${parts.join(' ')}]` : base;
81
82
  }
83
+ async function routeError(res) {
84
+ return routeErrorMessage(res, await readRouteErrorDoc(res));
85
+ }
86
+ const BOOTSTRAP_STATES = new Set([
87
+ 'none',
88
+ 'pending',
89
+ 'created',
90
+ 'ambiguous',
91
+ 'conflict',
92
+ 'unknown',
93
+ ]);
94
+ function bootstrapStateOf(value) {
95
+ return typeof value === 'string' && BOOTSTRAP_STATES.has(value)
96
+ ? value
97
+ : 'unknown';
98
+ }
99
+ const SAFE_REQUEST_ID = /^[A-Za-z0-9._:-]{1,128}$/;
100
+ function requestIdOf(res, doc) {
101
+ const candidate = res?.headers.get('x-request-id') ?? doc?.request_id ?? null;
102
+ return typeof candidate === 'string' && SAFE_REQUEST_ID.test(candidate) ? candidate : null;
103
+ }
104
+ /**
105
+ * The budget-intent route's failure, kept structured (#8470). `status` 0 means
106
+ * the request never produced an HTTP response (network failure or abort), which
107
+ * is outcome-uncertain exactly like a 5xx: the server may have dispatched.
108
+ */
109
+ export class ServerIntentError extends Error {
110
+ facts;
111
+ code = 'SERVER_INTENT_FAILED';
112
+ constructor(message, facts) {
113
+ super(message);
114
+ this.facts = facts;
115
+ this.name = 'ServerIntentError';
116
+ }
117
+ }
118
+ function serverIntentErrorFrom(res, doc) {
119
+ const errorCode = typeof doc?.error_code === 'string'
120
+ ? doc.error_code
121
+ : typeof doc?.error === 'string' && /^[a-z0-9_]{1,64}$/.test(doc.error)
122
+ ? doc.error
123
+ : null;
124
+ const outcome = doc?.outcome === 'uncertain' || doc?.outcome === 'not_created'
125
+ ? doc.outcome
126
+ : res.status >= 500 || res.status === 0
127
+ ? 'uncertain'
128
+ : 'not_created';
129
+ return new ServerIntentError(`server intent failed (${res.status}): ${routeErrorMessage(res, doc)}`, {
130
+ status: res.status,
131
+ errorCode,
132
+ retryable: doc?.retryable === true,
133
+ requestId: requestIdOf(res, doc),
134
+ bootstrapState: bootstrapStateOf(doc?.bootstrap_state),
135
+ outcome,
136
+ });
137
+ }
82
138
  function bearer(mintToken) {
83
139
  return { 'content-type': 'application/json', authorization: `Bearer ${mintToken}` };
84
140
  }
@@ -99,34 +155,92 @@ export async function serverCreateIntent(base, mintToken, input, deps = {}) {
99
155
  const effectiveUntil = override.effectiveUntil ?? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
100
156
  const consumerPrompt = override.consumerPrompt ??
101
157
  `Buy an item from ${t.merchantName} for ${t.transactionCurrencyCode.toUpperCase()} ${t.transactionAmount}`;
102
- const res = await fetchImpl(`${stripTrailingSlashes(base)}/api/vgs/intent`, {
103
- method: 'POST',
104
- headers: bearer(mintToken),
105
- body: JSON.stringify({
106
- tokenId,
107
- consumerPrompt,
108
- assuranceData,
109
- mandates: [
110
- {
111
- description: `Purchase at ${t.merchantName}`,
112
- declineThresholdAmount,
113
- declineThresholdCurrencyCode: t.transactionCurrencyCode.toUpperCase(),
114
- effectiveUntil,
115
- merchantCategory: 'Retail',
116
- merchantCategoryCode: '5999',
117
- preferredMerchantName: t.merchantName,
118
- quantity,
119
- },
120
- ],
121
- }),
122
- });
158
+ let res;
159
+ try {
160
+ res = await fetchImpl(`${stripTrailingSlashes(base)}/api/vgs/intent`, {
161
+ method: 'POST',
162
+ headers: bearer(mintToken),
163
+ body: JSON.stringify({
164
+ tokenId,
165
+ consumerPrompt,
166
+ assuranceData,
167
+ mandates: [
168
+ {
169
+ description: `Purchase at ${t.merchantName}`,
170
+ declineThresholdAmount,
171
+ declineThresholdCurrencyCode: t.transactionCurrencyCode.toUpperCase(),
172
+ effectiveUntil,
173
+ merchantCategory: 'Retail',
174
+ merchantCategoryCode: '5999',
175
+ preferredMerchantName: t.merchantName,
176
+ quantity,
177
+ },
178
+ ],
179
+ }),
180
+ });
181
+ }
182
+ catch (err) {
183
+ // No HTTP response at all: the request may or may not have reached the
184
+ // server, so this is outcome-uncertain, never a proven non-creation.
185
+ throw new ServerIntentError(`server intent failed (0): ${err instanceof Error ? err.message : String(err)}`, {
186
+ status: 0,
187
+ errorCode: null,
188
+ retryable: false,
189
+ requestId: null,
190
+ bootstrapState: 'unknown',
191
+ outcome: 'uncertain',
192
+ });
193
+ }
123
194
  if (!res.ok)
124
- throw new Error(`server intent failed (${res.status}): ${await routeError(res)}`);
195
+ throw serverIntentErrorFrom(res, await readRouteErrorDoc(res));
125
196
  const doc = (await res.json().catch(() => null));
126
197
  if (!doc?.intentId)
127
198
  throw new Error('server intent response missing intentId');
128
199
  return { intentId: doc.intentId, status: typeof doc.status === 'string' ? doc.status : null };
129
200
  }
201
+ /**
202
+ * Read the durable budget-bootstrap state for a budget mint token via
203
+ * GET {base}/api/vgs/intent (#8470). A `created` answer carries the intent the
204
+ * server already minted under this token, so the runner registers it instead
205
+ * of dispatching again; `pending` and `ambiguous` are never redispatched.
206
+ */
207
+ export async function serverReadIntentBootstrap(base, mintToken, deps = {}) {
208
+ const fetchImpl = deps.fetchImpl ?? fetch;
209
+ let res;
210
+ try {
211
+ res = await fetchImpl(`${stripTrailingSlashes(base)}/api/vgs/intent`, {
212
+ method: 'GET',
213
+ headers: bearer(mintToken),
214
+ });
215
+ }
216
+ catch (err) {
217
+ throw new ServerIntentError(`server intent status failed (0): ${err instanceof Error ? err.message : String(err)}`, {
218
+ status: 0,
219
+ errorCode: null,
220
+ retryable: true,
221
+ requestId: null,
222
+ bootstrapState: 'unknown',
223
+ outcome: 'unknown',
224
+ });
225
+ }
226
+ const doc = (await res.json().catch(() => null));
227
+ if (!res.ok) {
228
+ const error = serverIntentErrorFrom(res, doc);
229
+ throw new ServerIntentError(error.message.replace('server intent failed', 'server intent status failed'), {
230
+ ...error.facts,
231
+ retryable: error.facts.retryable || res.status === 503,
232
+ outcome: 'unknown',
233
+ });
234
+ }
235
+ const state = bootstrapStateOf(doc?.bootstrap_state);
236
+ const intentId = typeof doc?.intent?.intentId === 'string' && doc.intent.intentId ? doc.intent.intentId : null;
237
+ return {
238
+ state: state === 'created' && !intentId ? 'unknown' : state,
239
+ intentId,
240
+ intentStatus: typeof doc?.intent?.status === 'string' ? doc.intent.status : null,
241
+ requestId: requestIdOf(res, doc),
242
+ };
243
+ }
130
244
  /**
131
245
  * Mint the FULL payment credential via POST {base}/api/vgs/payment-cryptogram.
132
246
  * The server's route does a SINGLE gateway call and 502s on a not-COMPLETED