@mercuryo-ai/magicpay-sdk 0.1.0-test.2

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.
@@ -0,0 +1,536 @@
1
+ import { claimRemoteSecret, createRemoteSecretRequest, getRemoteSecretRequest, getRemoteSessionSecretCatalog, } from './session-client.js';
2
+ import { getMagicPayErrorCode, isMagicPayAbortError, MagicPayRequestError, isMagicPayRequestErrorStatus, } from './gateway.js';
3
+ export const LOGIN_FIELD_KEYS = ['username', 'password'];
4
+ export const IDENTITY_FIELD_KEYS = [
5
+ 'full_name',
6
+ 'document_number',
7
+ 'date_of_birth',
8
+ 'nationality',
9
+ 'issue_date',
10
+ 'expiry_date',
11
+ 'issuing_country',
12
+ ];
13
+ export const PAYMENT_CARD_FIELD_KEYS = [
14
+ 'cardholder',
15
+ 'pan',
16
+ 'exp_month',
17
+ 'exp_year',
18
+ 'cvv',
19
+ ];
20
+ export const PROTECTED_BINDING_VALUE_HINTS = [
21
+ 'direct',
22
+ 'full_name.given',
23
+ 'full_name.family',
24
+ 'date_of_birth.day',
25
+ 'date_of_birth.month',
26
+ 'date_of_birth.year',
27
+ ];
28
+ function toSecretRequestHintFields(fieldKeys) {
29
+ return fieldKeys.map((key) => ({ key }));
30
+ }
31
+ function resolveSecretRequestHintFields(hint) {
32
+ if (hint.fields.length === 0) {
33
+ return [];
34
+ }
35
+ const firstField = hint.fields[0];
36
+ if (typeof firstField === 'string') {
37
+ return toSecretRequestHintFields(hint.fields);
38
+ }
39
+ return hint.fields;
40
+ }
41
+ const SECRET_REQUEST_NOT_FULFILLED_CODES = ['secret_request_not_fulfilled'];
42
+ const SECRET_REQUEST_ALREADY_CLAIMED_CODES = ['secret_request_already_claimed'];
43
+ const SECRET_REQUEST_NOT_FULFILLED_MESSAGES = [
44
+ 'This secret request is not approved yet.',
45
+ 'This secret request does not have usable secret values.',
46
+ 'This secret request does not have resolved secret values yet.',
47
+ 'secret_request_not_fulfilled',
48
+ ];
49
+ const SECRET_REQUEST_ALREADY_CLAIMED_MESSAGES = [
50
+ 'A secret payload was already claimed for this request.',
51
+ 'This secret request is already claimed.',
52
+ 'secret_request_already_claimed',
53
+ ];
54
+ function formatUnknownSdkError(error) {
55
+ if (error instanceof Error) {
56
+ return error.message;
57
+ }
58
+ if (typeof error === 'string') {
59
+ return error;
60
+ }
61
+ if (error && typeof error === 'object') {
62
+ try {
63
+ return JSON.stringify(error);
64
+ }
65
+ catch {
66
+ return Object.prototype.toString.call(error);
67
+ }
68
+ }
69
+ return String(error);
70
+ }
71
+ function formatAbortReason(error) {
72
+ if (error instanceof Error && error.message.trim().length > 0) {
73
+ return error.message;
74
+ }
75
+ return 'MagicPay request processing was aborted by the caller.';
76
+ }
77
+ function isTerminalSecretRequestStatus(status) {
78
+ return status !== 'pending';
79
+ }
80
+ function isTransientPollFailure(outcome) {
81
+ if (outcome.kind === 'not_found' || outcome.kind === 'aborted') {
82
+ return false;
83
+ }
84
+ return (outcome.status === undefined ||
85
+ outcome.status === 0 ||
86
+ outcome.status === 408 ||
87
+ outcome.status === 429 ||
88
+ outcome.status === 502 ||
89
+ outcome.status === 503 ||
90
+ outcome.status === 504 ||
91
+ outcome.errorCode === 'request_timeout');
92
+ }
93
+ async function waitForNextPoll(delayMs, signal) {
94
+ if (delayMs <= 0) {
95
+ if (signal?.aborted) {
96
+ throw signal.reason ?? new DOMException('This operation was aborted', 'AbortError');
97
+ }
98
+ return;
99
+ }
100
+ await new Promise((resolve, reject) => {
101
+ const timeoutId = setTimeout(() => {
102
+ signal?.removeEventListener('abort', onAbort);
103
+ resolve();
104
+ }, delayMs);
105
+ const onAbort = () => {
106
+ clearTimeout(timeoutId);
107
+ signal?.removeEventListener('abort', onAbort);
108
+ reject(signal?.reason ?? new DOMException('This operation was aborted', 'AbortError'));
109
+ };
110
+ if (signal?.aborted) {
111
+ onAbort();
112
+ return;
113
+ }
114
+ signal?.addEventListener('abort', onAbort, { once: true });
115
+ });
116
+ }
117
+ function fulfilledReasonForType(type) {
118
+ return type === 'secret_read'
119
+ ? 'MagicPay approved access to the saved secret values for this request.'
120
+ : 'The user supplied the requested secret values for this request.';
121
+ }
122
+ export function describeSecretRequestStatus(status, requestType) {
123
+ switch (status) {
124
+ case 'pending':
125
+ return {
126
+ outcomeType: 'approval_pending',
127
+ message: 'Protected secret request is waiting for user approval.',
128
+ reason: 'MagicPay has not approved this secret request yet.',
129
+ nextAction: 'poll-secret',
130
+ };
131
+ case 'fulfilled':
132
+ return {
133
+ message: 'Protected secret request is approved and ready to fill.',
134
+ reason: fulfilledReasonForType(requestType),
135
+ nextAction: 'fill-secret',
136
+ };
137
+ case 'denied':
138
+ return {
139
+ outcomeType: 'approval_denied',
140
+ message: 'Protected secret request was denied and cannot be used.',
141
+ reason: 'MagicPay reports that the user denied this protected request.',
142
+ nextAction: 'ask-user',
143
+ };
144
+ case 'expired':
145
+ return {
146
+ outcomeType: 'request_expired',
147
+ message: 'Protected secret request is no longer usable.',
148
+ reason: 'MagicPay reports that this secret request expired before it could be claimed.',
149
+ nextAction: 'request-secret',
150
+ };
151
+ case 'failed':
152
+ return {
153
+ message: 'Protected secret request failed and cannot be used.',
154
+ reason: 'MagicPay reports that this secret request failed before it could be claimed.',
155
+ nextAction: 'request-secret',
156
+ };
157
+ case 'canceled':
158
+ return {
159
+ message: 'Protected secret request was canceled and cannot be used.',
160
+ reason: 'MagicPay reports that this secret request was canceled before it could be claimed.',
161
+ nextAction: 'request-secret',
162
+ };
163
+ }
164
+ }
165
+ export function evaluateSecretRequestForFill(request, options) {
166
+ if (request.fillRef !== options.fillRef) {
167
+ return {
168
+ kind: 'fill_mismatch',
169
+ requestFillRef: request.fillRef,
170
+ };
171
+ }
172
+ if (request.claimedAt) {
173
+ return {
174
+ kind: 'already_claimed',
175
+ claimedAt: request.claimedAt,
176
+ };
177
+ }
178
+ if (request.status !== 'fulfilled') {
179
+ return {
180
+ kind: 'not_ready',
181
+ statusContract: describeSecretRequestStatus(request.status, request.requestType),
182
+ };
183
+ }
184
+ const mismatchReasons = [];
185
+ if (request.host && options.observedHost && request.host !== options.observedHost) {
186
+ mismatchReasons.push('host');
187
+ }
188
+ if (request.scopeRef && request.scopeRef !== options.observedScopeRef) {
189
+ mismatchReasons.push('scope');
190
+ }
191
+ if (mismatchReasons.length > 0) {
192
+ return {
193
+ kind: 'context_mismatch',
194
+ mismatchReasons,
195
+ ...(request.host ? { expectedHost: request.host } : {}),
196
+ ...(options.observedHost ? { observedHost: options.observedHost } : {}),
197
+ ...(request.scopeRef ? { expectedScopeRef: request.scopeRef } : {}),
198
+ ...(options.observedScopeRef ? { observedScopeRef: options.observedScopeRef } : {}),
199
+ };
200
+ }
201
+ return { kind: 'ready' };
202
+ }
203
+ function normalizeCatalogLookupValue(value) {
204
+ return value.trim().replace(/\.+$/, '').toLowerCase();
205
+ }
206
+ function asJsonRecord(value) {
207
+ return value && typeof value === 'object' && !Array.isArray(value)
208
+ ? { ...value }
209
+ : {};
210
+ }
211
+ function pickFirstString(record, keys) {
212
+ for (const key of keys) {
213
+ const value = record[key];
214
+ if (typeof value === 'string' && value.trim().length > 0) {
215
+ return value.trim();
216
+ }
217
+ }
218
+ return undefined;
219
+ }
220
+ function mapRemoteSessionState(response) {
221
+ return {
222
+ sessionId: response.session.id,
223
+ status: response.session.status,
224
+ currentRequestId: response.current_request?.id ?? null,
225
+ lastEventSeq: response.session.last_event_seq,
226
+ browserSessionId: response.browser_session_id,
227
+ };
228
+ }
229
+ export function mapSessionRequestToSecretRequestSnapshot(request) {
230
+ const payload = asJsonRecord(request.payload);
231
+ const storedSecret = asJsonRecord(payload.stored_secret);
232
+ return {
233
+ requestId: request.id,
234
+ fillRef: pickFirstString(payload, ['fill_ref', 'fillRef']) ?? '',
235
+ requestType: request.type === 'secret_write' ? 'secret_write' : 'secret_read',
236
+ status: request.status,
237
+ storedSecretRef: pickFirstString(payload, ['stored_secret_ref', 'storedSecretRef']) ??
238
+ pickFirstString(storedSecret, ['id']),
239
+ kind: payload.kind === 'login' || payload.kind === 'identity' || payload.kind === 'payment_card'
240
+ ? payload.kind
241
+ : undefined,
242
+ host: pickFirstString(payload, ['host']),
243
+ pageRef: pickFirstString(asJsonRecord(payload.page), ['ref']),
244
+ scopeRef: pickFirstString(payload, ['scope_ref', 'scopeRef']),
245
+ createdAt: request.created_at,
246
+ updatedAt: request.updated_at,
247
+ ...(request.expires_at ? { expiresAt: request.expires_at } : {}),
248
+ ...(request.resolved_at ? { resolvedAt: request.resolved_at } : {}),
249
+ };
250
+ }
251
+ export function mapClaimedRemoteSecret(response) {
252
+ return {
253
+ requestId: response.request_id,
254
+ issuedAt: response.issued_at,
255
+ expiresAt: response.expires_at ?? undefined,
256
+ secret: {
257
+ values: { ...response.secret.values },
258
+ source: response.secret.source,
259
+ storedSecretRef: response.secret.stored_secret_ref ?? undefined,
260
+ credentialId: response.secret.credential_id ?? undefined,
261
+ kind: response.secret.kind ?? undefined,
262
+ },
263
+ };
264
+ }
265
+ function toStoredSecretFieldKeys(fields) {
266
+ return fields
267
+ .map((field) => field.key)
268
+ .filter((fieldKey) => [
269
+ 'username',
270
+ 'password',
271
+ 'full_name',
272
+ 'document_number',
273
+ 'date_of_birth',
274
+ 'nationality',
275
+ 'issue_date',
276
+ 'expiry_date',
277
+ 'issuing_country',
278
+ 'cardholder',
279
+ 'pan',
280
+ 'exp_month',
281
+ 'exp_year',
282
+ 'cvv',
283
+ ].includes(fieldKey));
284
+ }
285
+ export function mapApiCatalogEntryToStoredSecret(entry, host) {
286
+ return {
287
+ storedSecretRef: entry.id,
288
+ kind: entry.kind,
289
+ scope: entry.applicability.mode === 'global' ? 'global' : 'site',
290
+ displayName: entry.display_name,
291
+ fieldKeys: toStoredSecretFieldKeys(entry.fields),
292
+ intentRequired: true,
293
+ applicability: entry.applicability.mode === 'global'
294
+ ? { target: 'global' }
295
+ : { target: 'host', value: host },
296
+ };
297
+ }
298
+ export function resolveCatalogHost(urlOrHost) {
299
+ const trimmed = urlOrHost.trim();
300
+ if (trimmed.length === 0) {
301
+ throw new Error('secret_catalog_host_required');
302
+ }
303
+ try {
304
+ const parsed = new URL(trimmed);
305
+ if (!parsed.hostname) {
306
+ throw new Error('missing_hostname');
307
+ }
308
+ return normalizeCatalogLookupValue(parsed.hostname);
309
+ }
310
+ catch {
311
+ return normalizeCatalogLookupValue(trimmed);
312
+ }
313
+ }
314
+ export function resolveSecretCatalogContext(catalogByHost, urlOrHost) {
315
+ const host = resolveCatalogHost(urlOrHost);
316
+ return {
317
+ host,
318
+ catalog: catalogByHost?.[host] ?? null,
319
+ };
320
+ }
321
+ export async function fetchSecretCatalog(gateway, sessionId, urlOrHost, options = {}) {
322
+ const host = resolveCatalogHost(urlOrHost);
323
+ const entries = await getRemoteSessionSecretCatalog(gateway, sessionId, host, options);
324
+ return {
325
+ host,
326
+ syncedAt: options.syncedAt ?? new Date().toISOString(),
327
+ storedSecrets: entries.map((entry) => mapApiCatalogEntryToStoredSecret(entry, host)),
328
+ };
329
+ }
330
+ export async function createSecretRequest(gateway, input, options = {}) {
331
+ const response = await createRemoteSecretRequest(gateway, input.sessionId, {
332
+ clientRequestId: input.clientRequestId,
333
+ fillRef: input.fillRef,
334
+ purpose: input.purpose,
335
+ merchantName: input.merchantName,
336
+ ...(input.page ? { page: input.page } : {}),
337
+ secretHint: {
338
+ ...(input.secretHint.storedSecretRef
339
+ ? { storedSecretRef: input.secretHint.storedSecretRef }
340
+ : {}),
341
+ ...(input.secretHint.credentialKey
342
+ ? { credentialKey: input.secretHint.credentialKey }
343
+ : {}),
344
+ ...(input.secretHint.credentialName
345
+ ? { credentialName: input.secretHint.credentialName }
346
+ : {}),
347
+ ...(input.secretHint.kind ? { kind: input.secretHint.kind } : {}),
348
+ ...(input.secretHint.host ? { host: input.secretHint.host } : {}),
349
+ ...(input.secretHint.scopeRef ? { scopeRef: input.secretHint.scopeRef } : {}),
350
+ fields: resolveSecretRequestHintFields(input.secretHint),
351
+ },
352
+ ...(input.run ? { run: input.run } : {}),
353
+ ...(input.step ? { step: input.step } : {}),
354
+ }, options);
355
+ return {
356
+ requestId: response.request.id,
357
+ snapshot: mapSessionRequestToSecretRequestSnapshot(response.request),
358
+ reused: response.reused,
359
+ duplicate: response.duplicate,
360
+ session: mapRemoteSessionState(response),
361
+ };
362
+ }
363
+ export async function pollSecretRequest(gateway, sessionId, requestId, options = {}) {
364
+ try {
365
+ const response = await getRemoteSecretRequest(gateway, sessionId, requestId, options);
366
+ return {
367
+ success: true,
368
+ result: {
369
+ requestId: response.request.id,
370
+ snapshot: mapSessionRequestToSecretRequestSnapshot(response.request),
371
+ session: mapRemoteSessionState(response),
372
+ },
373
+ };
374
+ }
375
+ catch (error) {
376
+ return {
377
+ success: false,
378
+ kind: isMagicPayAbortError(error)
379
+ ? 'aborted'
380
+ : isMagicPayRequestErrorStatus(error, 404)
381
+ ? 'not_found'
382
+ : 'other',
383
+ reason: isMagicPayAbortError(error)
384
+ ? formatAbortReason(error)
385
+ : isMagicPayRequestErrorStatus(error, 404)
386
+ ? 'MagicPay did not return a secret request for the provided session ID and request ID.'
387
+ : formatUnknownSdkError(error),
388
+ ...(error instanceof MagicPayRequestError ? { status: error.status } : {}),
389
+ ...(error instanceof MagicPayRequestError ? { errorCode: error.errorCode } : {}),
390
+ };
391
+ }
392
+ }
393
+ export async function pollSecretRequestUntil(gateway, sessionId, requestId, options = {}) {
394
+ const startedAt = Date.now();
395
+ const timeoutMs = options.timeoutMs ?? 180_000;
396
+ const maxIntervalMs = options.maxIntervalMs ?? 30_000;
397
+ const backoffMultiplier = options.backoffMultiplier ?? 1.2;
398
+ let nextIntervalMs = options.intervalMs ?? 10_000;
399
+ let attempts = 0;
400
+ let lastResult;
401
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
402
+ throw new RangeError('MagicPay pollUntil timeout must be a finite non-negative number.');
403
+ }
404
+ if (!Number.isFinite(nextIntervalMs) || nextIntervalMs < 0) {
405
+ throw new RangeError('MagicPay pollUntil interval must be a finite non-negative number.');
406
+ }
407
+ if (!Number.isFinite(maxIntervalMs) || maxIntervalMs < 0) {
408
+ throw new RangeError('MagicPay pollUntil max interval must be a finite non-negative number.');
409
+ }
410
+ if (!Number.isFinite(backoffMultiplier) || backoffMultiplier < 1) {
411
+ throw new RangeError('MagicPay pollUntil backoff multiplier must be a finite number >= 1.');
412
+ }
413
+ while (true) {
414
+ if (options.signal?.aborted) {
415
+ return {
416
+ success: false,
417
+ requestId,
418
+ attempts,
419
+ elapsedMs: Date.now() - startedAt,
420
+ kind: 'aborted',
421
+ reason: formatAbortReason(options.signal.reason),
422
+ ...(lastResult ? { lastResult } : {}),
423
+ };
424
+ }
425
+ attempts += 1;
426
+ const outcome = await pollSecretRequest(gateway, sessionId, requestId, {
427
+ fetchImpl: options.fetchImpl,
428
+ signal: options.signal,
429
+ timeoutMs: options.requestTimeoutMs,
430
+ });
431
+ const elapsedMs = Date.now() - startedAt;
432
+ options.onAttempt?.({
433
+ attempt: attempts,
434
+ elapsedMs,
435
+ nextIntervalMs,
436
+ outcome,
437
+ });
438
+ if (outcome.success) {
439
+ lastResult = outcome.result;
440
+ const statusContract = describeSecretRequestStatus(outcome.result.snapshot.status, outcome.result.snapshot.requestType);
441
+ const stopWhen = options.stopWhen ?? 'terminal';
442
+ const shouldStop = stopWhen === 'fulfilled'
443
+ ? outcome.result.snapshot.status === 'fulfilled'
444
+ : isTerminalSecretRequestStatus(outcome.result.snapshot.status);
445
+ if (shouldStop) {
446
+ return {
447
+ success: true,
448
+ requestId: outcome.result.requestId,
449
+ attempts,
450
+ elapsedMs,
451
+ result: outcome.result,
452
+ statusContract,
453
+ };
454
+ }
455
+ }
456
+ else {
457
+ const shouldRetry = outcome.kind !== 'aborted' &&
458
+ options.retryOnTransientFailure !== false &&
459
+ isTransientPollFailure(outcome);
460
+ if (!shouldRetry) {
461
+ return {
462
+ success: false,
463
+ requestId,
464
+ attempts,
465
+ elapsedMs,
466
+ kind: outcome.kind,
467
+ reason: outcome.reason,
468
+ ...(lastResult ? { lastResult } : {}),
469
+ ...(outcome.status !== undefined ? { status: outcome.status } : {}),
470
+ ...(outcome.errorCode !== undefined ? { errorCode: outcome.errorCode } : {}),
471
+ };
472
+ }
473
+ }
474
+ if (elapsedMs >= timeoutMs) {
475
+ return {
476
+ success: false,
477
+ requestId,
478
+ attempts,
479
+ elapsedMs,
480
+ kind: 'timeout',
481
+ reason: `MagicPay secret request polling timed out after ${timeoutMs}ms.`,
482
+ ...(lastResult ? { lastResult } : {}),
483
+ };
484
+ }
485
+ const remainingMs = Math.max(0, timeoutMs - elapsedMs);
486
+ const delayMs = Math.min(nextIntervalMs, remainingMs);
487
+ try {
488
+ await waitForNextPoll(delayMs, options.signal);
489
+ }
490
+ catch (error) {
491
+ return {
492
+ success: false,
493
+ requestId,
494
+ attempts,
495
+ elapsedMs: Date.now() - startedAt,
496
+ kind: 'aborted',
497
+ reason: formatAbortReason(error),
498
+ ...(lastResult ? { lastResult } : {}),
499
+ };
500
+ }
501
+ nextIntervalMs = Math.min(maxIntervalMs, Math.round(nextIntervalMs * backoffMultiplier));
502
+ }
503
+ }
504
+ export async function claimSecretRequest(gateway, sessionId, requestId, claimId, options = {}) {
505
+ try {
506
+ const response = await claimRemoteSecret(gateway, sessionId, requestId, claimId, options);
507
+ return {
508
+ success: true,
509
+ result: mapClaimedRemoteSecret(response),
510
+ };
511
+ }
512
+ catch (error) {
513
+ return {
514
+ success: false,
515
+ kind: isMagicPayAbortError(error) ? 'aborted' : classifySecretClaimFailure(error),
516
+ reason: isMagicPayAbortError(error) ? formatAbortReason(error) : formatUnknownSdkError(error),
517
+ ...(error instanceof MagicPayRequestError ? { status: error.status } : {}),
518
+ ...(error instanceof MagicPayRequestError ? { errorCode: error.errorCode } : {}),
519
+ };
520
+ }
521
+ }
522
+ export function classifySecretClaimFailure(error) {
523
+ if (isMagicPayAbortError(error)) {
524
+ return 'aborted';
525
+ }
526
+ const errorCode = getMagicPayErrorCode(error);
527
+ if (errorCode &&
528
+ SECRET_REQUEST_NOT_FULFILLED_CODES.includes(errorCode)) {
529
+ return 'not_fulfilled';
530
+ }
531
+ if (errorCode &&
532
+ SECRET_REQUEST_ALREADY_CLAIMED_CODES.includes(errorCode)) {
533
+ return 'already_claimed';
534
+ }
535
+ return 'other';
536
+ }