@xkova/sdk 0.1.0-alpha.1

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 (76) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +453 -0
  3. package/dist/abi.d.ts +10288 -0
  4. package/dist/abi.js +6645 -0
  5. package/dist/client.d.ts +80 -0
  6. package/dist/client.js +251 -0
  7. package/dist/cursor.d.ts +21 -0
  8. package/dist/cursor.js +28 -0
  9. package/dist/errors.d.ts +31 -0
  10. package/dist/errors.js +63 -0
  11. package/dist/generated/types.d.ts +13371 -0
  12. package/dist/generated/types.js +3 -0
  13. package/dist/idempotency.d.ts +25 -0
  14. package/dist/idempotency.js +46 -0
  15. package/dist/index.d.ts +11 -0
  16. package/dist/index.js +8 -0
  17. package/dist/member-action-intents.d.ts +72 -0
  18. package/dist/member-action-intents.js +181 -0
  19. package/dist/member-bff.d.ts +535 -0
  20. package/dist/member-bff.js +1594 -0
  21. package/dist/member-resources.d.ts +203 -0
  22. package/dist/member-resources.js +56 -0
  23. package/dist/request-signing.d.ts +23 -0
  24. package/dist/request-signing.js +112 -0
  25. package/dist/resources/account-holders.d.ts +295 -0
  26. package/dist/resources/account-holders.js +166 -0
  27. package/dist/resources/base.d.ts +47 -0
  28. package/dist/resources/base.js +25 -0
  29. package/dist/resources/branding.d.ts +154 -0
  30. package/dist/resources/branding.js +34 -0
  31. package/dist/resources/chains.d.ts +6 -0
  32. package/dist/resources/chains.js +6 -0
  33. package/dist/resources/contacts.d.ts +55 -0
  34. package/dist/resources/contacts.js +22 -0
  35. package/dist/resources/escrows.d.ts +45 -0
  36. package/dist/resources/escrows.js +18 -0
  37. package/dist/resources/fee-schedules.d.ts +40 -0
  38. package/dist/resources/fee-schedules.js +15 -0
  39. package/dist/resources/index.d.ts +21 -0
  40. package/dist/resources/index.js +21 -0
  41. package/dist/resources/integration-gateways.d.ts +114 -0
  42. package/dist/resources/integration-gateways.js +27 -0
  43. package/dist/resources/onboarding.d.ts +18 -0
  44. package/dist/resources/onboarding.js +9 -0
  45. package/dist/resources/payment-requests.d.ts +115 -0
  46. package/dist/resources/payment-requests.js +27 -0
  47. package/dist/resources/payments.d.ts +182 -0
  48. package/dist/resources/payments.js +33 -0
  49. package/dist/resources/ramps.d.ts +163 -0
  50. package/dist/resources/ramps.js +44 -0
  51. package/dist/resources/rwa.d.ts +397 -0
  52. package/dist/resources/rwa.js +150 -0
  53. package/dist/resources/tenant.d.ts +136 -0
  54. package/dist/resources/tenant.js +42 -0
  55. package/dist/resources/tokenization.d.ts +394 -0
  56. package/dist/resources/tokenization.js +126 -0
  57. package/dist/resources/transactions.d.ts +8 -0
  58. package/dist/resources/transactions.js +6 -0
  59. package/dist/resources/wallets.d.ts +56 -0
  60. package/dist/resources/wallets.js +21 -0
  61. package/dist/resources/webhooks.d.ts +115 -0
  62. package/dist/resources/webhooks.js +43 -0
  63. package/dist/resources/workflows.d.ts +44 -0
  64. package/dist/resources/workflows.js +70 -0
  65. package/dist/resources/workspace-tokens.d.ts +6 -0
  66. package/dist/resources/workspace-tokens.js +6 -0
  67. package/dist/resources/workspaces.d.ts +25 -0
  68. package/dist/resources/workspaces.js +12 -0
  69. package/dist/retry.d.ts +21 -0
  70. package/dist/retry.js +44 -0
  71. package/dist/rwa-verify.d.ts +50 -0
  72. package/dist/rwa-verify.js +159 -0
  73. package/dist/webhook.d.ts +24 -0
  74. package/dist/webhook.js +56 -0
  75. package/llms.txt +84 -0
  76. package/package.json +62 -0
@@ -0,0 +1,1594 @@
1
+ import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
2
+ import { XKOVAApiError } from './errors.js';
3
+ import { XKOVAClient } from './client.js';
4
+ import { XKOVAMemberResources } from './member-resources.js';
5
+ import { XKOVAMemberActionIntents, } from './member-action-intents.js';
6
+ export { XKOVAMemberIntentError } from './member-action-intents.js';
7
+ import { generateIdempotencyKey } from './idempotency.js';
8
+ import { signXKOVARequest, validateXKOVARequestSigner, xkovaContentDigest, } from './request-signing.js';
9
+ const COMPLETED_AUTHORIZATION_RECOVERY_MS = 5 * 60 * 1000;
10
+ const HOSTED_AUTHORIZATION_MAX_TTL_MS = 10 * 60 * 1000;
11
+ const PROVIDER_ASSERTION_AUTHORIZATION_TTL_MS = 5 * 60 * 1000;
12
+ /**
13
+ * Marks an authorization failure that makes the exact browser transaction
14
+ * unusable. BFFs may retire their short-lived flow cookie for this error.
15
+ * Other failures must preserve the cookie so the durable transaction can be
16
+ * retried with the same state and XKOVA operation attempt.
17
+ */
18
+ export class XKOVAMemberAuthorizationTerminalError extends Error {
19
+ reason;
20
+ /** BFF-stored, validated review destination. Never copied from callback query parameters. */
21
+ applicationReviewUri;
22
+ constructor(reason, message, applicationReviewUri) {
23
+ super(message);
24
+ this.name = 'XKOVAMemberAuthorizationTerminalError';
25
+ this.reason = reason;
26
+ if (applicationReviewUri !== undefined)
27
+ this.applicationReviewUri = applicationReviewUri;
28
+ }
29
+ }
30
+ /**
31
+ * Keep provider proofs and XKOVA sessions on the server. Your application
32
+ * handles the provider's sign-in UI and passes the resulting proof here.
33
+ */
34
+ export class XKOVAMemberBffClient {
35
+ options;
36
+ apiBaseUrl;
37
+ clientFingerprint;
38
+ fetchImpl;
39
+ constructor(options) {
40
+ this.options = options;
41
+ if (!options.apiKey.startsWith('xkv_')) {
42
+ throw new TypeError('XKOVA member BFF apiKey must be an XKOVA bearer credential');
43
+ }
44
+ if (!/^xkova_pk_(sandbox|production)_[A-Za-z0-9_-]{22,64}$/.test(options.publishableKey)) {
45
+ throw new TypeError('XKOVA member BFF publishableKey has an invalid shape');
46
+ }
47
+ if (options.timeoutMs !== undefined &&
48
+ (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
49
+ throw new TypeError('XKOVA member BFF timeoutMs must be a positive number');
50
+ }
51
+ if (options.requestSigner !== undefined) {
52
+ validateXKOVARequestSigner(options.requestSigner);
53
+ }
54
+ this.apiBaseUrl = normalizeApiBaseUrl(options.baseUrl);
55
+ this.clientFingerprint = memberBffClientFingerprint({
56
+ apiBaseUrl: this.apiBaseUrl,
57
+ apiKey: options.apiKey,
58
+ publishableKey: options.publishableKey,
59
+ });
60
+ this.fetchImpl = options.fetchImpl ?? fetch;
61
+ }
62
+ async issueProofChallenge(input) {
63
+ const value = await this.postJson('/member-auth/proof/challenges', {
64
+ origin: input.origin,
65
+ redirect_uri: input.redirectUri,
66
+ });
67
+ const object = requireObject(value, 'proof challenge');
68
+ const nonce = requireString(object, 'nonce', 'proof challenge');
69
+ const expiresAt = requireIsoDate(object, 'expires_at', 'proof challenge');
70
+ return { nonce, expiresAt };
71
+ }
72
+ async exchangeProof(proofToken, options) {
73
+ assertProofToken(proofToken);
74
+ const attemptId = options.attemptId;
75
+ assertOperationAttemptId(attemptId);
76
+ return this.readSessionMaterial(await this.recoverablePostJson('/member-auth/proof/exchange', {
77
+ attempt_id: attemptId,
78
+ proof_token: proofToken,
79
+ }), 'proof exchange');
80
+ }
81
+ async refresh(refreshToken, options) {
82
+ if (refreshToken.length < 20) {
83
+ throw new TypeError('member refresh token is malformed');
84
+ }
85
+ const attemptId = options.attemptId;
86
+ assertOperationAttemptId(attemptId);
87
+ return this.readSessionMaterial(await this.recoverablePostJson('/member-auth/refresh', {
88
+ attempt_id: attemptId,
89
+ refresh_token: refreshToken,
90
+ }), 'session refresh');
91
+ }
92
+ async getSession(sessionId) {
93
+ assertUuid(sessionId, 'member session id');
94
+ const value = await this.getJson('/member-auth/session', sessionId);
95
+ const object = requireObject(value, 'session status');
96
+ const returnedSessionId = requireString(object, 'session_id', 'session status');
97
+ if (returnedSessionId !== sessionId) {
98
+ throw new Error('XKOVA member session status returned a different session id');
99
+ }
100
+ return {
101
+ sessionId: returnedSessionId,
102
+ accountHolderId: requireString(object, 'account_holder_id', 'session status'),
103
+ providerSubject: requireString(object, 'provider_subject', 'session status'),
104
+ expiresAt: Date.parse(requireIsoDate(object, 'expires_at', 'session status')),
105
+ };
106
+ }
107
+ /** Revokes only the exact XKOVA session named by `sessionId`. */
108
+ async signOutExactSession(sessionId) {
109
+ assertUuid(sessionId, 'member session id');
110
+ await this.postJson('/member-auth/sign-out', { scope: 'session' }, sessionId);
111
+ }
112
+ async beginAuthorization(input) {
113
+ const redirectUri = normalizeCallbackUri(input.redirectUri);
114
+ const browserOrigin = normalizeBrowserOrigin(input.browserOrigin);
115
+ const proofBindingMode = input.proofBindingMode ?? 'authorization_nonce';
116
+ const challenge = proofBindingMode === 'authorization_nonce'
117
+ ? await this.issueProofChallenge({
118
+ origin: browserOrigin,
119
+ redirectUri,
120
+ })
121
+ : {
122
+ nonce: randomBytes(32).toString('base64url'),
123
+ expiresAt: new Date(Date.now() + PROVIDER_ASSERTION_AUTHORIZATION_TTL_MS).toISOString(),
124
+ };
125
+ const state = randomBytes(32).toString('base64url');
126
+ const codeVerifier = randomBytes(32).toString('base64url');
127
+ const expiresAt = Date.parse(challenge.expiresAt);
128
+ await input.transactionStore.put(hashOpaque(state), {
129
+ status: 'pending',
130
+ completionMode: 'provider_proof',
131
+ clientFingerprint: this.clientFingerprint,
132
+ proofKind: input.proofKind,
133
+ nonce: challenge.nonce,
134
+ codeVerifier,
135
+ redirectUri,
136
+ exchangeAttemptId: generateXKOVAMemberOperationAttemptId(),
137
+ sessionReference: randomBytes(32).toString('base64url'),
138
+ expiresAt,
139
+ });
140
+ return {
141
+ state,
142
+ nonce: challenge.nonce,
143
+ codeChallenge: createHash('sha256').update(codeVerifier).digest('base64url'),
144
+ codeChallengeMethod: 'S256',
145
+ expiresAt: challenge.expiresAt,
146
+ };
147
+ }
148
+ /**
149
+ * Persist the transaction before redirecting to Hosted Auth so the callback
150
+ * can validate state and PKCE and recover the same session after a retry.
151
+ */
152
+ async beginHostedAuthorization(input) {
153
+ const redirectUri = normalizeCallbackUri(input.redirectUri);
154
+ const browserOrigin = normalizeBrowserOrigin(input.browserOrigin);
155
+ const state = randomBytes(32).toString('base64url');
156
+ const codeVerifier = randomBytes(32).toString('base64url');
157
+ const stateHash = hashOpaque(state);
158
+ const provisionalExpiresAt = Date.now() + HOSTED_AUTHORIZATION_MAX_TTL_MS;
159
+ const transaction = {
160
+ status: 'pending',
161
+ completionMode: 'hosted_result_code',
162
+ clientFingerprint: this.clientFingerprint,
163
+ codeVerifier,
164
+ redirectUri,
165
+ exchangeAttemptId: generateXKOVAMemberOperationAttemptId(),
166
+ sessionReference: randomBytes(32).toString('base64url'),
167
+ expiresAt: provisionalExpiresAt,
168
+ };
169
+ await input.transactionStore.put(stateHash, transaction);
170
+ try {
171
+ const value = await this.recoverablePostJson('/member-auth/hosted/authorizations', {
172
+ origin: browserOrigin,
173
+ redirect_uri: redirectUri,
174
+ state,
175
+ code_challenge: createHash('sha256').update(codeVerifier).digest('base64url'),
176
+ code_challenge_method: 'S256',
177
+ });
178
+ const object = requireObject(value, 'Hosted authorization start');
179
+ const authorizationUrl = normalizeHostedAuthorizationUrl(requireString(object, 'authorization_url', 'Hosted authorization start'));
180
+ const { expiresAt, expiresAtMillis } = boundedAuthorizationExpiry(object, provisionalExpiresAt, 'Hosted authorization start');
181
+ await input.transactionStore.replace(stateHash, {
182
+ ...transaction,
183
+ authorizationUrl,
184
+ expiresAt: expiresAtMillis,
185
+ });
186
+ return { authorizationUrl, state, expiresAt };
187
+ }
188
+ catch (error) {
189
+ try {
190
+ await input.transactionStore.delete(stateHash);
191
+ }
192
+ catch (cleanupError) {
193
+ throw new AggregateError([error, cleanupError], 'XKOVA Hosted authorization initiation and local transaction cleanup failed');
194
+ }
195
+ throw error;
196
+ }
197
+ }
198
+ /**
199
+ * Available only to sandbox applications enabled for demo sign-in. The stored
200
+ * Hosted transaction determines the demo account and callback. An uncertain
201
+ * response preserves the key so a retry recovers the same reservation.
202
+ * Complete the returned callback using
203
+ * completeHostedAuthorization and the original browser flow cookie.
204
+ */
205
+ async completeSandboxDemoAuthorization(input) {
206
+ if (!this.options.apiKey.startsWith('xkv_test_') ||
207
+ !this.options.publishableKey.startsWith('xkova_pk_sandbox_')) {
208
+ throw new XKOVAMemberAuthorizationTerminalError('provider_rejected', 'Demo admission requires a sandbox application');
209
+ }
210
+ const stateHash = hashOpaque(input.state);
211
+ return input.transactionStore.runExclusive(stateHash, async () => {
212
+ const transaction = await input.transactionStore.get(stateHash);
213
+ if (!transaction || transaction.expiresAt <= Date.now()) {
214
+ throw new XKOVAMemberAuthorizationTerminalError('transaction_unavailable', 'Demo admission requires an active Hosted transaction');
215
+ }
216
+ assertMemberBffClientFingerprint(transaction.clientFingerprint, this.clientFingerprint);
217
+ if (transaction.status !== 'pending' ||
218
+ transaction.completionMode !== 'hosted_result_code' ||
219
+ !transaction.authorizationUrl) {
220
+ throw new XKOVAMemberAuthorizationTerminalError('transaction_unavailable', 'Demo admission requires a completed Hosted start');
221
+ }
222
+ const authorization = new URL(normalizeHostedAuthorizationUrl(transaction.authorizationUrl));
223
+ const capability = authorization.searchParams.get('transaction');
224
+ if (!/^\/authorize\/?$/.test(authorization.pathname) ||
225
+ !capability ||
226
+ !/^[A-Za-z0-9_-]{43}$/.test(capability)) {
227
+ throw new XKOVAMemberAuthorizationTerminalError('transaction_unavailable', 'Demo admission capability is invalid');
228
+ }
229
+ const value = await this.postJson('/member-auth/hosted/authorizations/sandbox-demo', { transaction: capability }, undefined, `sandbox-demo-${stateHash}`);
230
+ const body = requireObject(value, 'Sandbox demo admission');
231
+ const { expiresAt } = boundedAuthorizationExpiry(body, transaction.expiresAt, 'Sandbox demo admission');
232
+ const callback = new URL(requireString(body, 'redirect_url', 'Sandbox demo admission'));
233
+ const code = callback.searchParams.get('code');
234
+ if (!sameCallback(callback, new URL(transaction.redirectUri)) ||
235
+ callback.searchParams.getAll('state').length !== 1 ||
236
+ callback.searchParams.get('state') !== input.state ||
237
+ callback.searchParams.getAll('code').length !== 1 ||
238
+ !code ||
239
+ callback.searchParams.has('error')) {
240
+ throw new Error('Sandbox demo admission returned the wrong callback binding');
241
+ }
242
+ assertHostedAuthorizationResultCode(code);
243
+ return { callbackUrl: callback.toString(), expiresAt };
244
+ });
245
+ }
246
+ /**
247
+ * Completes the one-time Hosted Auth result-code exchange and persists the
248
+ * provider-neutral XKOVA member session. The returned projection is safe for
249
+ * a browser, but `session` and `reference` remain BFF-only material.
250
+ */
251
+ async completeHostedAuthorization(input) {
252
+ const callback = new URL(input.callbackUrl);
253
+ const state = callback.searchParams.get('state');
254
+ if (!state)
255
+ throw new Error('member Hosted authorization callback is missing state');
256
+ hashOpaque(input.expectedState);
257
+ if (!constantTimeStringEqual(state, input.expectedState)) {
258
+ throw new Error('member Hosted authorization callback state does not match the initiating browser');
259
+ }
260
+ const stateHash = hashOpaque(state);
261
+ return input.transactionStore.runExclusive(stateHash, async () => {
262
+ const transaction = await input.transactionStore.get(stateHash);
263
+ if (!transaction || transaction.expiresAt <= Date.now()) {
264
+ if (transaction)
265
+ await input.transactionStore.delete(stateHash);
266
+ throw new XKOVAMemberAuthorizationTerminalError('transaction_unavailable', 'member Hosted authorization transaction is missing, expired, or replayed');
267
+ }
268
+ assertMemberBffClientFingerprint(transaction.clientFingerprint, this.clientFingerprint);
269
+ if (transaction.completionMode !== 'hosted_result_code') {
270
+ throw new Error('member authorization transaction is not an XKOVA Hosted Auth flow');
271
+ }
272
+ if (!sameCallback(callback, new URL(transaction.redirectUri))) {
273
+ throw new Error('member Hosted authorization callback does not match its redirect URI');
274
+ }
275
+ const hostedError = callback.searchParams.get('error');
276
+ if (hostedError) {
277
+ await input.transactionStore.delete(stateHash);
278
+ throw new XKOVAMemberAuthorizationTerminalError('provider_rejected', 'XKOVA Hosted Auth did not complete authorization');
279
+ }
280
+ const code = callback.searchParams.get('code');
281
+ if (!code) {
282
+ throw new XKOVAMemberAuthorizationTerminalError('callback_missing_code', 'member Hosted authorization callback is missing code');
283
+ }
284
+ assertHostedAuthorizationResultCode(code);
285
+ const providerCodeHash = hashProviderCode(code);
286
+ if (transaction.status === 'completed') {
287
+ if (!constantTimeStringEqual(transaction.providerCodeHash, providerCodeHash)) {
288
+ throw new Error('member Hosted authorization callback code changed during retry');
289
+ }
290
+ const session = await input.sessionStore.get(transaction.sessionReference);
291
+ if (!session) {
292
+ throw new Error('completed member Hosted authorization references missing application-session material');
293
+ }
294
+ return {
295
+ reference: transaction.sessionReference,
296
+ session,
297
+ projection: projectXKOVAMemberSession(session),
298
+ };
299
+ }
300
+ let applicationExpiresAt;
301
+ let applicationData;
302
+ if (transaction.stagedApplicationSession) {
303
+ if (!constantTimeStringEqual(transaction.stagedApplicationSession.authorizationCodeHash, providerCodeHash)) {
304
+ throw new Error('member Hosted authorization callback code changed during retry');
305
+ }
306
+ applicationExpiresAt = transaction.stagedApplicationSession.applicationExpiresAt;
307
+ applicationData = transaction.stagedApplicationSession.applicationData;
308
+ }
309
+ else {
310
+ const applicationSessionSeconds = input.applicationSessionSeconds ?? 30 * 24 * 60 * 60;
311
+ assertPositiveInteger(applicationSessionSeconds, 'applicationSessionSeconds');
312
+ applicationExpiresAt = Date.now() + applicationSessionSeconds * 1000;
313
+ applicationData = input.applicationData;
314
+ await input.transactionStore.replace(stateHash, {
315
+ ...transaction,
316
+ stagedApplicationSession: {
317
+ authorizationCodeHash: providerCodeHash,
318
+ applicationExpiresAt,
319
+ applicationData,
320
+ },
321
+ });
322
+ }
323
+ const material = this.readSessionMaterial(await this.recoverablePostJson('/member-auth/hosted/authorizations/exchange', {
324
+ attempt_id: transaction.exchangeAttemptId,
325
+ code,
326
+ code_verifier: transaction.codeVerifier,
327
+ redirect_uri: transaction.redirectUri,
328
+ }), 'Hosted authorization exchange');
329
+ const established = await this.persistSessionMaterial({
330
+ material,
331
+ reference: transaction.sessionReference,
332
+ sessionStore: input.sessionStore,
333
+ applicationData,
334
+ applicationExpiresAt,
335
+ });
336
+ await input.transactionStore.replace(stateHash, {
337
+ status: 'completed',
338
+ completionMode: 'hosted_result_code',
339
+ clientFingerprint: this.clientFingerprint,
340
+ redirectUri: transaction.redirectUri,
341
+ providerCodeHash,
342
+ sessionReference: established.reference,
343
+ expiresAt: Date.now() + COMPLETED_AUTHORIZATION_RECOVERY_MS,
344
+ });
345
+ return established;
346
+ });
347
+ }
348
+ /**
349
+ * Launches the narrow, Hosted-only XKOVA Account Center from an existing
350
+ * customer application session. The browser receives only a one-time
351
+ * handoff URL and state. The member session, PKCE verifier, and exchange
352
+ * attempt remain in the customer BFF stores.
353
+ */
354
+ async beginAccountCenterHandoff(input) {
355
+ const browserOrigin = normalizeBrowserOrigin(input.browserOrigin);
356
+ const returnUri = normalizeCallbackUri(input.returnUri);
357
+ assertAccountCenterIntent(input.intent);
358
+ const applicationReturnUri = input.applicationReturnUri === undefined
359
+ ? undefined
360
+ : normalizeApplicationReturnUri(input.applicationReturnUri, browserOrigin);
361
+ assertOpaqueReference(input.sessionReference);
362
+ return input.sessionStore.runExclusive(input.sessionReference, async () => {
363
+ const memberSession = await input.sessionStore.get(input.sessionReference);
364
+ if (!memberSession || memberSession.applicationExpiresAt <= Date.now()) {
365
+ throw new XKOVAMemberAuthorizationTerminalError('transaction_unavailable', 'member application session is unavailable for Account Center');
366
+ }
367
+ const state = randomBytes(32).toString('base64url');
368
+ const codeVerifier = randomBytes(32).toString('base64url');
369
+ const stateHash = hashOpaque(state);
370
+ const provisionalExpiresAt = Date.now() + HOSTED_AUTHORIZATION_MAX_TTL_MS;
371
+ const transaction = {
372
+ status: 'pending',
373
+ completionMode: 'account_center_result_code',
374
+ clientFingerprint: this.clientFingerprint,
375
+ codeVerifier,
376
+ redirectUri: returnUri,
377
+ exchangeAttemptId: randomUUID(),
378
+ sessionReference: input.sessionReference,
379
+ intent: input.intent,
380
+ ...(applicationReturnUri !== undefined && {
381
+ applicationReturnUri,
382
+ applicationReviewUri: applicationReturnUri,
383
+ }),
384
+ expiresAt: provisionalExpiresAt,
385
+ };
386
+ await input.transactionStore.put(stateHash, transaction);
387
+ try {
388
+ const value = await this.recoverablePostJson('/account-center/handoffs', {
389
+ intent: input.intent,
390
+ origin: browserOrigin,
391
+ return_uri: returnUri,
392
+ state,
393
+ code_challenge: createHash('sha256').update(codeVerifier).digest('base64url'),
394
+ code_challenge_method: 'S256',
395
+ }, memberSession.sessionId);
396
+ const object = requireObject(value, 'Account Center handoff start');
397
+ const handoffUrl = normalizeHostedAuthorizationUrl(requireString(object, 'handoff_url', 'Account Center handoff start'));
398
+ const { expiresAt, expiresAtMillis } = boundedAuthorizationExpiry(object, provisionalExpiresAt, 'Account Center handoff start');
399
+ await input.transactionStore.replace(stateHash, {
400
+ ...transaction,
401
+ expiresAt: expiresAtMillis,
402
+ });
403
+ return { handoffUrl, state, expiresAt };
404
+ }
405
+ catch (error) {
406
+ try {
407
+ await input.transactionStore.delete(stateHash);
408
+ }
409
+ catch (cleanupError) {
410
+ throw new AggregateError([error, cleanupError], 'XKOVA Account Center handoff and local transaction cleanup failed');
411
+ }
412
+ throw error;
413
+ }
414
+ });
415
+ }
416
+ /**
417
+ * Completes a one-time Account Center return. The same existing application
418
+ * session must still exist and XKOVA binds the result to its exact member
419
+ * session, application, tenant, workspace, account holder, state, PKCE, and
420
+ * durable exchange attempt.
421
+ */
422
+ async completeAccountCenterHandoff(input) {
423
+ const callback = new URL(input.callbackUrl);
424
+ const state = callback.searchParams.get('state');
425
+ if (!state)
426
+ throw new Error('Account Center callback is missing state');
427
+ hashOpaque(input.expectedState);
428
+ if (!constantTimeStringEqual(state, input.expectedState)) {
429
+ throw new Error('Account Center callback state does not match the initiating browser');
430
+ }
431
+ const stateHash = hashOpaque(state);
432
+ return input.transactionStore.runExclusive(stateHash, async () => {
433
+ const transaction = await input.transactionStore.get(stateHash);
434
+ if (!transaction) {
435
+ throw new XKOVAMemberAuthorizationTerminalError('transaction_unavailable', 'Account Center transaction is missing, expired, or replayed');
436
+ }
437
+ assertMemberBffClientFingerprint(transaction.clientFingerprint, this.clientFingerprint);
438
+ if (transaction.completionMode !== 'account_center_result_code') {
439
+ throw new Error('member authorization transaction is not an Account Center flow');
440
+ }
441
+ if (!sameCallback(callback, new URL(transaction.redirectUri))) {
442
+ throw new Error('Account Center callback does not match its return URI');
443
+ }
444
+ if (transaction.expiresAt <= Date.now()) {
445
+ await input.transactionStore.delete(stateHash);
446
+ throw new XKOVAMemberAuthorizationTerminalError('transaction_unavailable', 'Account Center transaction expired', transaction.applicationReviewUri);
447
+ }
448
+ const callbackError = callback.searchParams.get('error');
449
+ if (callbackError) {
450
+ await input.transactionStore.delete(stateHash);
451
+ throw new XKOVAMemberAuthorizationTerminalError(callbackError === 'access_denied' ? 'authorization_cancelled' : 'provider_rejected', 'XKOVA Account Center did not complete', transaction.applicationReviewUri);
452
+ }
453
+ const code = callback.searchParams.get('code');
454
+ if (!code) {
455
+ await input.transactionStore.delete(stateHash);
456
+ throw new XKOVAMemberAuthorizationTerminalError('callback_missing_code', 'Account Center callback is missing code', transaction.applicationReviewUri);
457
+ }
458
+ assertHostedAuthorizationResultCode(code);
459
+ const providerCodeHash = hashProviderCode(code);
460
+ if (transaction.status === 'completed') {
461
+ if (!constantTimeStringEqual(transaction.providerCodeHash, providerCodeHash)) {
462
+ throw new Error('Account Center callback code changed during retry');
463
+ }
464
+ return {
465
+ status: 'completed',
466
+ intent: transaction.intent,
467
+ ...(transaction.applicationReturnUri !== undefined && {
468
+ applicationReturnUri: transaction.applicationReturnUri,
469
+ }),
470
+ ...(transaction.signingExchangeCode !== undefined && {
471
+ signingExchangeCode: transaction.signingExchangeCode,
472
+ }),
473
+ ...(transaction.signingUrl !== undefined && { signingUrl: transaction.signingUrl }),
474
+ };
475
+ }
476
+ const memberSession = await input.sessionStore.get(transaction.sessionReference);
477
+ if (!memberSession || memberSession.applicationExpiresAt <= Date.now()) {
478
+ throw new XKOVAMemberAuthorizationTerminalError('transaction_unavailable', 'member application session is unavailable for Account Center completion', transaction.applicationReviewUri);
479
+ }
480
+ const value = await this.recoverablePostJson('/account-center/handoffs/exchange', {
481
+ attempt_id: transaction.exchangeAttemptId,
482
+ code,
483
+ code_verifier: transaction.codeVerifier,
484
+ return_uri: transaction.redirectUri,
485
+ }, memberSession.sessionId);
486
+ const object = requireObject(value, 'Account Center handoff completion');
487
+ if (object['status'] !== 'completed' || object['intent'] !== transaction.intent) {
488
+ throw new Error('XKOVA Account Center returned a mismatched completion projection');
489
+ }
490
+ const signingExchangeCode = transaction.intent === 'action'
491
+ ? requireString(object, 'signing_exchange_code', 'Account Center action completion')
492
+ : undefined;
493
+ const signingUrl = transaction.intent === 'action'
494
+ ? normalizeHostedAuthorizationUrl(requireString(object, 'signing_url', 'Account Center action completion'))
495
+ : undefined;
496
+ await input.transactionStore.replace(stateHash, {
497
+ status: 'completed',
498
+ completionMode: 'account_center_result_code',
499
+ clientFingerprint: this.clientFingerprint,
500
+ redirectUri: transaction.redirectUri,
501
+ providerCodeHash,
502
+ sessionReference: transaction.sessionReference,
503
+ intent: transaction.intent,
504
+ ...(signingExchangeCode !== undefined && { signingExchangeCode }),
505
+ ...(signingUrl !== undefined && { signingUrl }),
506
+ ...(transaction.applicationReturnUri !== undefined && {
507
+ applicationReturnUri: transaction.applicationReturnUri,
508
+ }),
509
+ ...(transaction.applicationReviewUri !== undefined && {
510
+ applicationReviewUri: transaction.applicationReviewUri,
511
+ }),
512
+ ...(transaction.actionResource !== undefined && {
513
+ actionResource: transaction.actionResource,
514
+ }),
515
+ expiresAt: Date.now() + COMPLETED_AUTHORIZATION_RECOVERY_MS,
516
+ });
517
+ return {
518
+ status: 'completed',
519
+ intent: transaction.intent,
520
+ ...(transaction.applicationReturnUri !== undefined && {
521
+ applicationReturnUri: transaction.applicationReturnUri,
522
+ }),
523
+ ...(signingExchangeCode !== undefined && { signingExchangeCode }),
524
+ ...(signingUrl !== undefined && { signingUrl }),
525
+ };
526
+ });
527
+ }
528
+ /**
529
+ * Binds fresh authorization to one existing resource. Hosted applications
530
+ * use Account Center passkeys. OIDC applications receive the assurance
531
+ * requirements their BFF must request from the registered provider.
532
+ */
533
+ async beginMemberAction(input) {
534
+ assertMemberAction(input.resourceType, input.action);
535
+ if (input.resourceId.length === 0 || input.resourceId.length > 80) {
536
+ throw new TypeError('member action resourceId must contain from 1 through 80 characters');
537
+ }
538
+ const browserOrigin = normalizeBrowserOrigin(input.browserOrigin);
539
+ const returnUri = normalizeCallbackUri(input.returnUri);
540
+ const applicationReturnUri = input.applicationReturnUri === undefined
541
+ ? undefined
542
+ : normalizeApplicationReturnUri(input.applicationReturnUri, browserOrigin);
543
+ const applicationReviewUri = input.applicationReviewUri === undefined
544
+ ? undefined
545
+ : normalizeApplicationReturnUri(input.applicationReviewUri, browserOrigin);
546
+ assertOpaqueReference(input.sessionReference);
547
+ return input.sessionStore.runExclusive(input.sessionReference, async () => {
548
+ const memberSession = await input.sessionStore.get(input.sessionReference);
549
+ if (!memberSession || memberSession.applicationExpiresAt <= Date.now()) {
550
+ throw new XKOVAMemberAuthorizationTerminalError('transaction_unavailable', 'member application session is unavailable for action authorization');
551
+ }
552
+ const state = randomBytes(32).toString('base64url');
553
+ const codeVerifier = randomBytes(32).toString('base64url');
554
+ const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
555
+ const stateHash = hashOpaque(state);
556
+ const provisionalExpiresAt = Date.now() + HOSTED_AUTHORIZATION_MAX_TTL_MS;
557
+ const transaction = {
558
+ status: 'pending',
559
+ completionMode: 'account_center_result_code',
560
+ clientFingerprint: this.clientFingerprint,
561
+ codeVerifier,
562
+ redirectUri: returnUri,
563
+ exchangeAttemptId: generateXKOVAMemberOperationAttemptId(),
564
+ sessionReference: input.sessionReference,
565
+ intent: 'action',
566
+ ...(applicationReturnUri !== undefined && { applicationReturnUri }),
567
+ ...(applicationReviewUri !== undefined && { applicationReviewUri }),
568
+ actionResource: { type: input.resourceType, id: input.resourceId, action: input.action },
569
+ expiresAt: provisionalExpiresAt,
570
+ };
571
+ await input.transactionStore.put(stateHash, transaction);
572
+ try {
573
+ const value = await this.recoverablePostJson('/member-actions', {
574
+ resource_type: input.resourceType,
575
+ resource_id: input.resourceId,
576
+ action: input.action,
577
+ origin: browserOrigin,
578
+ return_uri: returnUri,
579
+ state,
580
+ code_challenge: codeChallenge,
581
+ code_challenge_method: 'S256',
582
+ }, memberSession.sessionId);
583
+ const object = requireObject(value, 'member action authorization start');
584
+ const method = requireString(object, 'authorization_method', 'member action authorization start');
585
+ const { expiresAt, expiresAtMillis } = boundedAuthorizationExpiry(object, provisionalExpiresAt, 'member action authorization start');
586
+ if (method === 'hosted_passkey') {
587
+ const authorizationUrl = normalizeHostedAuthorizationUrl(requireString(object, 'authorization_url', 'member action authorization start'));
588
+ await input.transactionStore.replace(stateHash, {
589
+ ...transaction,
590
+ actionAuthorization: { method: 'hosted_passkey' },
591
+ expiresAt: expiresAtMillis,
592
+ });
593
+ return {
594
+ authorizationMethod: 'hosted_passkey',
595
+ authorizationUrl,
596
+ state,
597
+ expiresAt,
598
+ };
599
+ }
600
+ if (method !== 'external_oidc') {
601
+ throw new Error('XKOVA member action returned an unsupported authorization method');
602
+ }
603
+ const actionToken = requireString(object, 'action_token', 'member action authorization start');
604
+ const nonce = requireString(object, 'nonce', 'member action authorization start');
605
+ const issuer = requireString(object, 'issuer', 'member action authorization start');
606
+ const audience = requireString(object, 'audience', 'member action authorization start');
607
+ const maxAuthAgeSeconds = requirePositiveInteger(object, 'max_auth_age_seconds', 'member action authorization start');
608
+ const requiredAcrValues = requireStringArray(object, 'required_acr_values', 'member action authorization start');
609
+ const requiredAmrValues = requireStringArray(object, 'required_amr_values', 'member action authorization start');
610
+ await input.transactionStore.replace(stateHash, {
611
+ ...transaction,
612
+ actionAuthorization: { method: 'external_oidc', actionToken, nonce },
613
+ expiresAt: expiresAtMillis,
614
+ });
615
+ return {
616
+ authorizationMethod: 'external_oidc',
617
+ actionToken,
618
+ state,
619
+ nonce,
620
+ codeChallenge,
621
+ codeChallengeMethod: 'S256',
622
+ issuer,
623
+ audience,
624
+ maxAuthAgeSeconds,
625
+ requiredAcrValues,
626
+ requiredAmrValues,
627
+ expiresAt,
628
+ };
629
+ }
630
+ catch (error) {
631
+ try {
632
+ await input.transactionStore.delete(stateHash);
633
+ }
634
+ catch (cleanupError) {
635
+ throw new AggregateError([error, cleanupError], 'XKOVA member action initiation and local transaction cleanup failed');
636
+ }
637
+ throw error;
638
+ }
639
+ });
640
+ }
641
+ /** Completes the exact Hosted-passkey or FI-OIDC action ceremony. */
642
+ async completeMemberAction(input) {
643
+ const callback = new URL(input.callbackUrl);
644
+ const state = callback.searchParams.get('state');
645
+ if (!state)
646
+ throw new Error('member action callback is missing state');
647
+ hashOpaque(input.expectedState);
648
+ if (!constantTimeStringEqual(state, input.expectedState)) {
649
+ throw new Error('member action callback state does not match the initiating browser');
650
+ }
651
+ const stateHash = hashOpaque(state);
652
+ const initial = await input.transactionStore.get(stateHash);
653
+ if (initial?.completionMode === 'account_center_result_code' &&
654
+ initial.status === 'pending' &&
655
+ initial.intent === 'action' &&
656
+ initial.actionAuthorization?.method === 'hosted_passkey') {
657
+ const completion = await this.completeAccountCenterHandoff(input);
658
+ if (!completion.signingExchangeCode || !completion.signingUrl) {
659
+ throw new Error('XKOVA Hosted action completion omitted its signing projection');
660
+ }
661
+ return {
662
+ signingExchangeCode: completion.signingExchangeCode,
663
+ signingUrl: completion.signingUrl,
664
+ ...(initial.applicationReturnUri !== undefined && {
665
+ applicationReturnUri: initial.applicationReturnUri,
666
+ }),
667
+ };
668
+ }
669
+ return input.transactionStore.runExclusive(stateHash, async () => {
670
+ const transaction = await input.transactionStore.get(stateHash);
671
+ if (!transaction) {
672
+ throw new XKOVAMemberAuthorizationTerminalError('transaction_unavailable', 'member action transaction is missing, expired, or replayed');
673
+ }
674
+ assertMemberBffClientFingerprint(transaction.clientFingerprint, this.clientFingerprint);
675
+ if (transaction.completionMode !== 'account_center_result_code' ||
676
+ transaction.intent !== 'action') {
677
+ throw new Error('member authorization transaction is not an action flow');
678
+ }
679
+ if (!sameCallback(callback, new URL(transaction.redirectUri))) {
680
+ throw new Error('member action callback does not match its return URI');
681
+ }
682
+ if (transaction.expiresAt <= Date.now()) {
683
+ await input.transactionStore.delete(stateHash);
684
+ throw new XKOVAMemberAuthorizationTerminalError('transaction_unavailable', 'member action transaction expired', transaction.applicationReviewUri);
685
+ }
686
+ const callbackError = callback.searchParams.get('error');
687
+ if (callbackError) {
688
+ await input.transactionStore.delete(stateHash);
689
+ throw new XKOVAMemberAuthorizationTerminalError(callbackError === 'access_denied' ? 'authorization_cancelled' : 'provider_rejected', 'member action provider did not complete authorization', transaction.applicationReviewUri);
690
+ }
691
+ const code = callback.searchParams.get('code');
692
+ if (!code) {
693
+ await input.transactionStore.delete(stateHash);
694
+ throw new XKOVAMemberAuthorizationTerminalError('callback_missing_code', 'member action callback is missing code', transaction.applicationReviewUri);
695
+ }
696
+ const providerCodeHash = hashProviderCode(code);
697
+ if (transaction.status === 'completed') {
698
+ if (!constantTimeStringEqual(transaction.providerCodeHash, providerCodeHash)) {
699
+ throw new Error('member action callback code changed during retry');
700
+ }
701
+ if (!transaction.signingExchangeCode || !transaction.signingUrl) {
702
+ throw new Error('completed member action is missing its signing projection');
703
+ }
704
+ return {
705
+ signingExchangeCode: transaction.signingExchangeCode,
706
+ signingUrl: transaction.signingUrl,
707
+ ...(transaction.applicationReturnUri !== undefined && {
708
+ applicationReturnUri: transaction.applicationReturnUri,
709
+ }),
710
+ };
711
+ }
712
+ if (transaction.actionAuthorization?.method !== 'external_oidc') {
713
+ throw new Error('member action transaction does not contain external OIDC authority');
714
+ }
715
+ const memberSession = await input.sessionStore.get(transaction.sessionReference);
716
+ if (!memberSession || memberSession.applicationExpiresAt <= Date.now()) {
717
+ throw new XKOVAMemberAuthorizationTerminalError('transaction_unavailable', 'member application session is unavailable for action completion', transaction.applicationReviewUri);
718
+ }
719
+ let proofToken;
720
+ const staged = transaction.actionAuthorization.stagedProviderExchange;
721
+ if (staged) {
722
+ if (!constantTimeStringEqual(staged.providerCodeHash, providerCodeHash)) {
723
+ throw new Error('member action callback code changed during retry');
724
+ }
725
+ proofToken = staged.proofToken;
726
+ }
727
+ else {
728
+ if (!input.exchangeProviderCode) {
729
+ throw new TypeError('external member action completion requires exchangeProviderCode');
730
+ }
731
+ const provider = await input.exchangeProviderCode({
732
+ code,
733
+ codeVerifier: transaction.codeVerifier,
734
+ nonce: transaction.actionAuthorization.nonce,
735
+ redirectUri: transaction.redirectUri,
736
+ proofKind: 'oidc',
737
+ });
738
+ proofToken = provider.proofToken;
739
+ assertProofToken(proofToken);
740
+ assertExactProofNonce(provider.nonceToken ?? proofToken, transaction.actionAuthorization.nonce);
741
+ await input.transactionStore.replace(stateHash, {
742
+ ...transaction,
743
+ actionAuthorization: {
744
+ ...transaction.actionAuthorization,
745
+ stagedProviderExchange: { providerCodeHash, proofToken },
746
+ },
747
+ });
748
+ }
749
+ const value = await this.recoverablePostJson('/member-actions/external/complete', {
750
+ action_token: transaction.actionAuthorization.actionToken,
751
+ provider_proof: proofToken,
752
+ }, memberSession.sessionId);
753
+ const object = requireObject(value, 'member action completion');
754
+ const signingExchangeCode = requireString(object, 'signing_exchange_code', 'member action completion');
755
+ const signingUrl = normalizeHostedAuthorizationUrl(requireString(object, 'signing_url', 'member action completion'));
756
+ const expiresAt = requireIsoDate(object, 'expires_at', 'member action completion');
757
+ await input.transactionStore.replace(stateHash, {
758
+ status: 'completed',
759
+ completionMode: 'account_center_result_code',
760
+ clientFingerprint: this.clientFingerprint,
761
+ redirectUri: transaction.redirectUri,
762
+ providerCodeHash,
763
+ sessionReference: transaction.sessionReference,
764
+ intent: 'action',
765
+ signingExchangeCode,
766
+ signingUrl,
767
+ ...(transaction.applicationReturnUri !== undefined && {
768
+ applicationReturnUri: transaction.applicationReturnUri,
769
+ }),
770
+ ...(transaction.applicationReviewUri !== undefined && {
771
+ applicationReviewUri: transaction.applicationReviewUri,
772
+ }),
773
+ ...(transaction.actionResource !== undefined && {
774
+ actionResource: transaction.actionResource,
775
+ }),
776
+ expiresAt: Date.now() + COMPLETED_AUTHORIZATION_RECOVERY_MS,
777
+ });
778
+ return {
779
+ signingExchangeCode,
780
+ signingUrl,
781
+ ...(transaction.applicationReturnUri !== undefined && {
782
+ applicationReturnUri: transaction.applicationReturnUri,
783
+ }),
784
+ expiresAt,
785
+ };
786
+ });
787
+ }
788
+ async completeAuthorizationCallback(input) {
789
+ const callback = new URL(input.callbackUrl);
790
+ const state = callback.searchParams.get('state');
791
+ if (!state)
792
+ throw new Error('member authorization callback is missing state');
793
+ hashOpaque(input.expectedState);
794
+ if (!constantTimeStringEqual(state, input.expectedState)) {
795
+ throw new Error('member authorization callback state does not match the initiating browser');
796
+ }
797
+ const stateHash = hashOpaque(state);
798
+ return input.transactionStore.runExclusive(stateHash, async () => {
799
+ const transaction = await input.transactionStore.get(stateHash);
800
+ if (!transaction || transaction.expiresAt <= Date.now()) {
801
+ if (transaction)
802
+ await input.transactionStore.delete(stateHash);
803
+ throw new XKOVAMemberAuthorizationTerminalError('transaction_unavailable', 'member authorization transaction is missing, expired, or replayed');
804
+ }
805
+ assertMemberBffClientFingerprint(transaction.clientFingerprint, this.clientFingerprint);
806
+ if (transaction.completionMode !== 'provider_proof') {
807
+ throw new Error('member authorization transaction is not a provider-proof flow');
808
+ }
809
+ if (!sameCallback(callback, new URL(transaction.redirectUri))) {
810
+ throw new Error('member authorization callback does not match its redirect URI');
811
+ }
812
+ const providerError = callback.searchParams.get('error');
813
+ if (providerError) {
814
+ await input.transactionStore.delete(stateHash);
815
+ throw new XKOVAMemberAuthorizationTerminalError('provider_rejected', 'member identity provider rejected authorization');
816
+ }
817
+ const code = callback.searchParams.get('code');
818
+ if (!code) {
819
+ throw new XKOVAMemberAuthorizationTerminalError('callback_missing_code', 'member authorization callback is missing code');
820
+ }
821
+ const providerCodeHash = hashProviderCode(code);
822
+ if (transaction.status === 'completed') {
823
+ if (!constantTimeStringEqual(transaction.providerCodeHash, providerCodeHash)) {
824
+ throw new Error('member authorization callback code changed during retry');
825
+ }
826
+ const session = await input.sessionStore.get(transaction.sessionReference);
827
+ if (!session) {
828
+ throw new Error('completed member authorization references missing application-session material');
829
+ }
830
+ return {
831
+ reference: transaction.sessionReference,
832
+ session,
833
+ projection: projectXKOVAMemberSession(session),
834
+ };
835
+ }
836
+ let proofToken;
837
+ let applicationExpiresAt;
838
+ let applicationData;
839
+ if (transaction.stagedProviderExchange) {
840
+ if (!constantTimeStringEqual(transaction.stagedProviderExchange.providerCodeHash, providerCodeHash)) {
841
+ throw new Error('member authorization callback code changed during retry');
842
+ }
843
+ proofToken = transaction.stagedProviderExchange.proofToken;
844
+ applicationExpiresAt = transaction.stagedProviderExchange.applicationExpiresAt;
845
+ applicationData = transaction.stagedProviderExchange.applicationData;
846
+ }
847
+ else {
848
+ const result = await input.exchangeProviderCode({
849
+ code,
850
+ codeVerifier: transaction.codeVerifier,
851
+ nonce: transaction.nonce,
852
+ redirectUri: transaction.redirectUri,
853
+ proofKind: transaction.proofKind,
854
+ });
855
+ assertExactProofNonce(result.nonceToken ?? result.proofToken, transaction.nonce);
856
+ proofToken = result.proofToken;
857
+ const applicationSessionSeconds = input.applicationSessionSeconds ?? 30 * 24 * 60 * 60;
858
+ assertPositiveInteger(applicationSessionSeconds, 'applicationSessionSeconds');
859
+ applicationExpiresAt = Date.now() + applicationSessionSeconds * 1000;
860
+ applicationData = input.applicationData;
861
+ await input.transactionStore.replace(stateHash, {
862
+ ...transaction,
863
+ stagedProviderExchange: {
864
+ providerCodeHash,
865
+ proofToken,
866
+ applicationExpiresAt,
867
+ applicationData,
868
+ },
869
+ });
870
+ }
871
+ const established = await this.persistSessionWithAttempt({
872
+ proofToken,
873
+ attemptId: transaction.exchangeAttemptId,
874
+ reference: transaction.sessionReference,
875
+ sessionStore: input.sessionStore,
876
+ applicationData,
877
+ applicationExpiresAt,
878
+ });
879
+ await input.transactionStore.replace(stateHash, {
880
+ status: 'completed',
881
+ completionMode: 'provider_proof',
882
+ clientFingerprint: this.clientFingerprint,
883
+ redirectUri: transaction.redirectUri,
884
+ providerCodeHash,
885
+ sessionReference: established.reference,
886
+ expiresAt: Date.now() + COMPLETED_AUTHORIZATION_RECOVERY_MS,
887
+ });
888
+ return established;
889
+ });
890
+ }
891
+ async establishSession(input) {
892
+ const ttl = input.applicationSessionSeconds ?? 30 * 24 * 60 * 60;
893
+ assertPositiveInteger(ttl, 'applicationSessionSeconds');
894
+ const exchangeHash = hashProofExchangeId(input.exchangeId);
895
+ return input.exchangeStore.runExclusive(exchangeHash, async () => {
896
+ let transaction = await input.exchangeStore.get(exchangeHash);
897
+ if (transaction && transaction.expiresAt <= Date.now()) {
898
+ await input.exchangeStore.delete(exchangeHash);
899
+ transaction = null;
900
+ }
901
+ if (transaction) {
902
+ assertMemberBffClientFingerprint(transaction.clientFingerprint, this.clientFingerprint);
903
+ }
904
+ if (transaction?.status === 'completed') {
905
+ const session = await input.sessionStore.get(transaction.sessionReference);
906
+ if (!session) {
907
+ throw new Error('completed member proof exchange references missing application-session material');
908
+ }
909
+ return {
910
+ reference: transaction.sessionReference,
911
+ session,
912
+ projection: projectXKOVAMemberSession(session),
913
+ };
914
+ }
915
+ if (transaction?.status === 'pending' && input.proofToken !== undefined) {
916
+ if (!constantTimeStringEqual(hashProof(input.proofToken), hashProof(transaction.proofToken))) {
917
+ throw new Error('member proof changed during exchange recovery');
918
+ }
919
+ }
920
+ if (!transaction) {
921
+ if (input.proofToken === undefined) {
922
+ throw new Error('member proof exchange is not staged');
923
+ }
924
+ assertProofToken(input.proofToken);
925
+ const now = Date.now();
926
+ transaction = {
927
+ status: 'pending',
928
+ clientFingerprint: this.clientFingerprint,
929
+ attemptId: generateXKOVAMemberOperationAttemptId(),
930
+ proofToken: input.proofToken,
931
+ sessionReference: randomBytes(32).toString('base64url'),
932
+ applicationExpiresAt: now + ttl * 1000,
933
+ applicationData: input.applicationData,
934
+ expiresAt: Math.min(now + 24 * 60 * 60 * 1000, now + ttl * 1000),
935
+ };
936
+ await input.exchangeStore.put(exchangeHash, transaction);
937
+ }
938
+ if (transaction.status !== 'pending') {
939
+ throw new Error('member proof exchange transaction has an invalid state');
940
+ }
941
+ const established = await this.persistSessionWithAttempt({
942
+ proofToken: transaction.proofToken,
943
+ attemptId: transaction.attemptId,
944
+ reference: transaction.sessionReference,
945
+ sessionStore: input.sessionStore,
946
+ applicationData: transaction.applicationData,
947
+ applicationExpiresAt: transaction.applicationExpiresAt,
948
+ });
949
+ await input.exchangeStore.replace(exchangeHash, {
950
+ status: 'completed',
951
+ clientFingerprint: this.clientFingerprint,
952
+ sessionReference: transaction.sessionReference,
953
+ expiresAt: transaction.expiresAt,
954
+ });
955
+ return established;
956
+ });
957
+ }
958
+ async resolveSession(input) {
959
+ assertOpaqueReference(input.reference);
960
+ const skew = input.refreshSkewSeconds ?? 60;
961
+ assertPositiveInteger(skew, 'refreshSkewSeconds');
962
+ return input.sessionStore.runExclusive(input.reference, async () => {
963
+ const current = await input.sessionStore.get(input.reference);
964
+ if (!current)
965
+ return null;
966
+ if (current.applicationExpiresAt <= Date.now()) {
967
+ await input.sessionStore.delete(input.reference);
968
+ return null;
969
+ }
970
+ try {
971
+ let active = current;
972
+ if (current.expiresAt <= Date.now() + skew * 1000) {
973
+ const attemptId = current.pendingRefreshAttemptId ?? generateXKOVAMemberOperationAttemptId();
974
+ if (current.pendingRefreshAttemptId === undefined) {
975
+ active = { ...current, pendingRefreshAttemptId: attemptId };
976
+ await input.sessionStore.replace(input.reference, active);
977
+ }
978
+ const rotated = await this.refresh(current.refreshToken, { attemptId });
979
+ const { pendingRefreshAttemptId: _completed, ...withoutPending } = active;
980
+ active = {
981
+ ...withoutPending,
982
+ ...rotated,
983
+ };
984
+ await input.sessionStore.replace(input.reference, active);
985
+ }
986
+ if (input.validateWithXkova !== false) {
987
+ const context = await this.getSession(active.sessionId);
988
+ if (context.accountHolderId !== active.accountHolderId ||
989
+ context.providerSubject !== active.providerSubject) {
990
+ throw new Error('XKOVA member context changed for an active session');
991
+ }
992
+ if (context.expiresAt !== active.expiresAt) {
993
+ active = { ...active, expiresAt: context.expiresAt };
994
+ await input.sessionStore.replace(input.reference, active);
995
+ }
996
+ }
997
+ return active;
998
+ }
999
+ catch (error) {
1000
+ if (isTerminalSessionError(error)) {
1001
+ await input.sessionStore.delete(input.reference);
1002
+ return null;
1003
+ }
1004
+ throw error;
1005
+ }
1006
+ });
1007
+ }
1008
+ /**
1009
+ * Resolve, refresh and validate a confidential session, then bind typed
1010
+ * business operations to it. Call once per incoming BFF request, after the
1011
+ * application has checked its browser Origin and mutation CSRF proof.
1012
+ * Do not cache the returned request-scoped resources across requests.
1013
+ */
1014
+ async resolveResources(input) {
1015
+ const session = await this.resolveSession(input);
1016
+ if (!session)
1017
+ return null;
1018
+ const context = Object.freeze({
1019
+ memberSessionId: session.sessionId,
1020
+ memberAuthPublishableKey: this.options.publishableKey,
1021
+ accountHolderId: session.accountHolderId,
1022
+ });
1023
+ return {
1024
+ session,
1025
+ resources: new XKOVAMemberResources(XKOVAClient.forMemberSession(this.options, context), context, session.accountHolderId),
1026
+ };
1027
+ }
1028
+ /** Bind durable action reviews to the same exact session and public resources as normal reads. */
1029
+ async resolveActionIntents(input) {
1030
+ const resolved = await this.resolveResources(input);
1031
+ if (!resolved)
1032
+ return null;
1033
+ return new XKOVAMemberActionIntents({
1034
+ clientFingerprint: this.clientFingerprint,
1035
+ sessionReference: input.reference,
1036
+ accountHolderId: resolved.session.accountHolderId,
1037
+ applicationExpiresAt: resolved.session.applicationExpiresAt,
1038
+ }, input.intentStore, resolved.resources);
1039
+ }
1040
+ async logoutSession(input) {
1041
+ assertOpaqueReference(input.reference);
1042
+ await input.sessionStore.runExclusive(input.reference, async () => {
1043
+ const session = await input.sessionStore.get(input.reference);
1044
+ if (!session)
1045
+ return;
1046
+ try {
1047
+ await this.signOutExactSession(session.sessionId);
1048
+ }
1049
+ catch (error) {
1050
+ if (!isAlreadyRevokedSessionError(error))
1051
+ throw error;
1052
+ }
1053
+ await input.sessionStore.delete(input.reference);
1054
+ });
1055
+ }
1056
+ async persistSessionWithAttempt(input) {
1057
+ assertOpaqueReference(input.reference);
1058
+ assertOperationAttemptId(input.attemptId);
1059
+ const applicationExpiresAt = input.applicationExpiresAt ??
1060
+ Date.now() + (input.applicationSessionSeconds ?? 30 * 24 * 60 * 60) * 1000;
1061
+ const material = await this.exchangeProof(input.proofToken, {
1062
+ attemptId: input.attemptId,
1063
+ });
1064
+ return this.persistSessionMaterial({
1065
+ material,
1066
+ reference: input.reference,
1067
+ sessionStore: input.sessionStore,
1068
+ applicationData: input.applicationData,
1069
+ applicationExpiresAt,
1070
+ });
1071
+ }
1072
+ async persistSessionMaterial(input) {
1073
+ assertOpaqueReference(input.reference);
1074
+ if (!Number.isSafeInteger(input.applicationExpiresAt) ||
1075
+ input.applicationExpiresAt <= Date.now()) {
1076
+ throw new TypeError('application session expiry must be in the future');
1077
+ }
1078
+ const session = {
1079
+ ...input.material,
1080
+ applicationExpiresAt: input.applicationExpiresAt,
1081
+ applicationData: input.applicationData,
1082
+ };
1083
+ const existing = await input.sessionStore.get(input.reference);
1084
+ if (existing) {
1085
+ if (existing.sessionId !== session.sessionId ||
1086
+ existing.refreshToken !== session.refreshToken ||
1087
+ existing.accountHolderId !== session.accountHolderId ||
1088
+ existing.providerSubject !== session.providerSubject ||
1089
+ existing.applicationExpiresAt !== session.applicationExpiresAt) {
1090
+ throw new Error('member proof recovery found different material at its stable session reference');
1091
+ }
1092
+ return {
1093
+ reference: input.reference,
1094
+ session: existing,
1095
+ projection: projectXKOVAMemberSession(existing),
1096
+ };
1097
+ }
1098
+ await input.sessionStore.put(input.reference, session);
1099
+ return {
1100
+ reference: input.reference,
1101
+ session,
1102
+ projection: projectXKOVAMemberSession(session),
1103
+ };
1104
+ }
1105
+ postJson(path, body, sessionId, idempotencyKey) {
1106
+ return this.requestJson({
1107
+ method: 'POST',
1108
+ path,
1109
+ ...(body !== undefined && { body }),
1110
+ ...(sessionId !== undefined && { sessionId }),
1111
+ ...(idempotencyKey !== undefined && { idempotencyKey }),
1112
+ });
1113
+ }
1114
+ async recoverablePostJson(path, body, sessionId) {
1115
+ const idempotencyKey = generateIdempotencyKey();
1116
+ try {
1117
+ return await this.postJson(path, body, sessionId, idempotencyKey);
1118
+ }
1119
+ catch (error) {
1120
+ if (!isAmbiguousTransportFailure(error))
1121
+ throw error;
1122
+ return this.postJson(path, body, sessionId, idempotencyKey);
1123
+ }
1124
+ }
1125
+ getJson(path, sessionId) {
1126
+ return this.requestJson({ method: 'GET', path, sessionId });
1127
+ }
1128
+ async requestJson(input) {
1129
+ const url = new URL(`${this.apiBaseUrl}${input.path}`);
1130
+ const serializedBody = input.body === undefined ? undefined : JSON.stringify(input.body);
1131
+ if (input.body !== undefined && serializedBody === undefined) {
1132
+ throw new TypeError('XKOVA member BFF request body is not JSON serializable');
1133
+ }
1134
+ const body = serializedBody === undefined ? new Uint8Array() : new TextEncoder().encode(serializedBody);
1135
+ const headers = new Headers({
1136
+ accept: 'application/json',
1137
+ authorization: `Bearer ${this.options.apiKey}`,
1138
+ 'x-xkova-publishable-key': this.options.publishableKey,
1139
+ });
1140
+ if (input.method === 'POST') {
1141
+ headers.set('idempotency-key', input.idempotencyKey ?? generateIdempotencyKey());
1142
+ }
1143
+ if (serializedBody !== undefined) {
1144
+ headers.set('content-type', 'application/json');
1145
+ }
1146
+ if (input.sessionId !== undefined) {
1147
+ headers.set('x-member-session-id', input.sessionId);
1148
+ }
1149
+ const signer = this.options.requestSigner;
1150
+ if (input.method === 'POST' && this.options.apiKey.startsWith('xkv_live_')) {
1151
+ if (!signer) {
1152
+ throw new Error('XKOVA production member-session mutations require a request signer');
1153
+ }
1154
+ }
1155
+ if (input.method === 'POST' && signer !== undefined) {
1156
+ if (serializedBody !== undefined) {
1157
+ headers.set('content-digest', xkovaContentDigest(body));
1158
+ }
1159
+ const signature = await signXKOVARequest({
1160
+ method: input.method,
1161
+ url,
1162
+ headers,
1163
+ body,
1164
+ signer,
1165
+ });
1166
+ headers.set('signature-input', signature.signatureInput);
1167
+ headers.set('signature', signature.signature);
1168
+ }
1169
+ const signal = this.requestSignal();
1170
+ const response = await this.fetchImpl(url, {
1171
+ method: input.method,
1172
+ headers,
1173
+ cache: 'no-store',
1174
+ redirect: 'error',
1175
+ ...(serializedBody !== undefined && { body: serializedBody }),
1176
+ ...(signal !== undefined && { signal }),
1177
+ });
1178
+ if (!response.ok)
1179
+ throw await XKOVAApiError.fromResponse(response);
1180
+ if (response.status === 204)
1181
+ return undefined;
1182
+ const text = await response.text();
1183
+ if (text.length === 0)
1184
+ return undefined;
1185
+ try {
1186
+ return JSON.parse(text);
1187
+ }
1188
+ catch {
1189
+ throw new Error(`XKOVA member BFF received non-JSON HTTP ${response.status}`);
1190
+ }
1191
+ }
1192
+ readSessionMaterial(value, operation) {
1193
+ const object = requireObject(value, operation);
1194
+ const member = requireObject(object['member'], `${operation} member`);
1195
+ const expiresIn = object['expires_in'];
1196
+ if (typeof expiresIn !== 'number' || !Number.isInteger(expiresIn) || expiresIn <= 0) {
1197
+ throw new Error(`XKOVA member ${operation} returned an invalid expiry`);
1198
+ }
1199
+ return {
1200
+ sessionId: requireString(object, 'session_id', operation),
1201
+ refreshToken: requireString(object, 'refresh_token', operation),
1202
+ accountHolderId: requireString(member, 'account_holder_id', `${operation} member`),
1203
+ providerSubject: requireString(member, 'provider_subject', `${operation} member`),
1204
+ expiresAt: Date.now() + expiresIn * 1000,
1205
+ };
1206
+ }
1207
+ requestSignal() {
1208
+ return this.options.timeoutMs === undefined
1209
+ ? undefined
1210
+ : AbortSignal.timeout(this.options.timeoutMs);
1211
+ }
1212
+ }
1213
+ /** Only confirmed admission rejections may release a customer's pending demo reservation. */
1214
+ export function isDefinitiveXKOVASandboxDemoAdmissionFailure(error) {
1215
+ return (error instanceof XKOVAMemberAuthorizationTerminalError ||
1216
+ (error instanceof XKOVAApiError &&
1217
+ (error.status < 500 ||
1218
+ error.code === 'demo_persona_unavailable' ||
1219
+ error.code === 'demo_login_disabled')));
1220
+ }
1221
+ export function projectXKOVAMemberSession(session) {
1222
+ return {
1223
+ signedIn: true,
1224
+ accountHolderId: session.accountHolderId,
1225
+ expiresAt: new Date(session.expiresAt).toISOString(),
1226
+ };
1227
+ }
1228
+ export function serializeXKOVAMemberSessionCookie(input) {
1229
+ assertHostCookieName(input.cookieName);
1230
+ assertOpaqueReference(input.reference);
1231
+ assertPositiveInteger(input.maxAgeSeconds, 'member cookie maxAgeSeconds');
1232
+ return `${input.cookieName}=${encodeURIComponent(input.reference)}; Path=/; Max-Age=${input.maxAgeSeconds}; HttpOnly; Secure; SameSite=Strict`;
1233
+ }
1234
+ /** Short-lived Lax cookie for cross-site OAuth/OIDC callback state only. */
1235
+ export function serializeXKOVAMemberFlowCookie(input) {
1236
+ assertHostCookieName(input.cookieName);
1237
+ assertOpaqueReference(input.reference);
1238
+ const maxAge = input.maxAgeSeconds ?? 300;
1239
+ assertPositiveInteger(maxAge, 'member flow cookie maxAgeSeconds');
1240
+ if (maxAge > 600) {
1241
+ throw new TypeError('member flow cookie maxAgeSeconds must not exceed 600');
1242
+ }
1243
+ return `${input.cookieName}=${encodeURIComponent(input.reference)}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`;
1244
+ }
1245
+ export function serializeClearedXKOVAMemberCookie(cookieName) {
1246
+ assertHostCookieName(cookieName);
1247
+ return `${cookieName}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Strict`;
1248
+ }
1249
+ /** One short-lived cookie per ceremony, so another tab cannot replace its browser binding. */
1250
+ export function xkovaMemberFlowCookieName(cookiePrefix, state) {
1251
+ assertHostCookieName(cookiePrefix);
1252
+ assertOpaqueReference(state);
1253
+ return `${cookiePrefix}_${hashOpaque(state)}`;
1254
+ }
1255
+ /** State must match a real HttpOnly cookie. A callback query alone is never authority. */
1256
+ export function readXKOVAMemberCallbackFlow(input) {
1257
+ const states = new URL(input.callbackUrl).searchParams.getAll('state');
1258
+ if (states.length !== 1 || !/^[A-Za-z0-9_-]{43,128}$/.test(states[0]))
1259
+ return null;
1260
+ const state = states[0];
1261
+ const cookieName = xkovaMemberFlowCookieName(input.cookiePrefix, state);
1262
+ const cookieState = readXKOVAMemberCookie(input.cookieHeader, cookieName);
1263
+ return cookieState && constantTimeStringEqual(state, cookieState) ? { state, cookieName } : null;
1264
+ }
1265
+ export function readXKOVAMemberCookie(cookieHeader, cookieName) {
1266
+ assertHostCookieName(cookieName);
1267
+ if (!cookieHeader)
1268
+ return null;
1269
+ for (const segment of cookieHeader.split(';')) {
1270
+ const [name, ...valueParts] = segment.trim().split('=');
1271
+ if (name !== cookieName)
1272
+ continue;
1273
+ let value;
1274
+ try {
1275
+ value = decodeURIComponent(valueParts.join('='));
1276
+ }
1277
+ catch {
1278
+ return null;
1279
+ }
1280
+ return /^[A-Za-z0-9_-]{43,128}$/.test(value) ? value : null;
1281
+ }
1282
+ return null;
1283
+ }
1284
+ export function createXKOVAMemberCsrfToken(input) {
1285
+ assertOpaqueReference(input.sessionReference);
1286
+ const secret = csrfSecret(input.secret);
1287
+ const ttl = input.ttlSeconds ?? 15 * 60;
1288
+ assertPositiveInteger(ttl, 'CSRF ttlSeconds');
1289
+ if (ttl > 24 * 60 * 60) {
1290
+ throw new TypeError('CSRF ttlSeconds must not exceed 24 hours');
1291
+ }
1292
+ const expiresAt = Math.floor((input.now ?? Date.now()) / 1000) + ttl;
1293
+ const nonce = randomBytes(18).toString('base64url');
1294
+ const payload = `xkv1.${expiresAt}.${nonce}`;
1295
+ const mac = createHmac('sha256', secret)
1296
+ .update(`${payload}.${input.sessionReference}`)
1297
+ .digest('base64url');
1298
+ return `${payload}.${mac}`;
1299
+ }
1300
+ export function verifyXKOVAMemberCsrfToken(input) {
1301
+ try {
1302
+ assertOpaqueReference(input.sessionReference);
1303
+ const secret = csrfSecret(input.secret);
1304
+ const parts = input.token.split('.');
1305
+ if (parts.length !== 4 ||
1306
+ parts[0] !== 'xkv1' ||
1307
+ !/^\d+$/.test(parts[1] ?? '') ||
1308
+ !/^[A-Za-z0-9_-]{22,}$/.test(parts[2] ?? '') ||
1309
+ !/^[A-Za-z0-9_-]{43}$/.test(parts[3] ?? '')) {
1310
+ return false;
1311
+ }
1312
+ const expiresAt = Number(parts[1]);
1313
+ if (!Number.isSafeInteger(expiresAt))
1314
+ return false;
1315
+ const now = Math.floor((input.now ?? Date.now()) / 1000);
1316
+ if (expiresAt <= now || expiresAt > now + 24 * 60 * 60)
1317
+ return false;
1318
+ const payload = parts.slice(0, 3).join('.');
1319
+ const expected = createHmac('sha256', secret)
1320
+ .update(`${payload}.${input.sessionReference}`)
1321
+ .digest();
1322
+ const presented = Buffer.from(parts[3], 'base64url');
1323
+ return presented.byteLength === expected.byteLength && timingSafeEqual(presented, expected);
1324
+ }
1325
+ catch {
1326
+ return false;
1327
+ }
1328
+ }
1329
+ export function assertXKOVAMemberRequestOrigin(origin, allowedOrigins) {
1330
+ if (!origin)
1331
+ throw new Error('member BFF request requires an Origin header');
1332
+ const normalized = new URL(origin).origin;
1333
+ if (normalized !== origin || !allowedOrigins.includes(normalized)) {
1334
+ throw new Error('member BFF request Origin is not allowlisted');
1335
+ }
1336
+ }
1337
+ function normalizeApiBaseUrl(baseUrl) {
1338
+ const url = new URL(baseUrl);
1339
+ if (url.username || url.password || url.search || url.hash) {
1340
+ throw new TypeError('XKOVA member BFF baseUrl must be an API origin or /v1 base URL');
1341
+ }
1342
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && url.hostname === 'localhost')) {
1343
+ throw new TypeError('XKOVA member BFF baseUrl must use HTTPS or localhost HTTP');
1344
+ }
1345
+ const pathname = url.pathname.replace(/\/+$/, '');
1346
+ if (pathname !== '' && pathname !== '/v1') {
1347
+ throw new TypeError('XKOVA member BFF baseUrl must be an API origin or /v1 base URL');
1348
+ }
1349
+ return `${url.origin}/v1`;
1350
+ }
1351
+ function normalizeCallbackUri(value) {
1352
+ const url = new URL(value);
1353
+ if (url.username || url.password || url.search || url.hash) {
1354
+ throw new TypeError('member authorization redirectUri must not contain credentials, query, or fragment');
1355
+ }
1356
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && url.hostname === 'localhost')) {
1357
+ throw new TypeError('member authorization redirectUri must use HTTPS or localhost HTTP');
1358
+ }
1359
+ return url.toString();
1360
+ }
1361
+ function normalizeBrowserOrigin(value) {
1362
+ const url = new URL(value);
1363
+ if (url.origin !== value ||
1364
+ (url.protocol !== 'https:' && !(url.protocol === 'http:' && url.hostname === 'localhost'))) {
1365
+ throw new TypeError('member authorization browserOrigin must be an exact HTTPS or localhost origin');
1366
+ }
1367
+ return value;
1368
+ }
1369
+ function normalizeApplicationReturnUri(value, browserOrigin) {
1370
+ const url = new URL(value);
1371
+ if (url.origin !== browserOrigin ||
1372
+ url.username ||
1373
+ url.password ||
1374
+ url.hash ||
1375
+ (url.protocol !== 'https:' && !(url.protocol === 'http:' && url.hostname === 'localhost'))) {
1376
+ throw new TypeError('member action applicationReturnUri must use the exact browser origin');
1377
+ }
1378
+ return url.toString();
1379
+ }
1380
+ function normalizeHostedAuthorizationUrl(value) {
1381
+ const url = new URL(value);
1382
+ if (url.username ||
1383
+ url.password ||
1384
+ url.hash ||
1385
+ (url.protocol !== 'https:' &&
1386
+ !(url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1')))) {
1387
+ throw new Error('XKOVA member Hosted authorization URL must use HTTPS or loopback HTTP without credentials or a fragment');
1388
+ }
1389
+ return url.toString();
1390
+ }
1391
+ function sameCallback(actual, expected) {
1392
+ return (actual.username === '' &&
1393
+ actual.password === '' &&
1394
+ actual.protocol === expected.protocol &&
1395
+ actual.host === expected.host &&
1396
+ actual.pathname === expected.pathname &&
1397
+ actual.hash === '');
1398
+ }
1399
+ function requireObject(value, operation) {
1400
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
1401
+ throw new Error(`XKOVA member ${operation} returned a malformed object`);
1402
+ }
1403
+ return value;
1404
+ }
1405
+ function requireString(value, key, operation) {
1406
+ const field = value[key];
1407
+ if (typeof field !== 'string' || field.length === 0) {
1408
+ throw new Error(`XKOVA member ${operation} returned an invalid ${key}`);
1409
+ }
1410
+ return field;
1411
+ }
1412
+ function requireIsoDate(value, key, operation) {
1413
+ const field = requireString(value, key, operation);
1414
+ if (!Number.isFinite(Date.parse(field))) {
1415
+ throw new Error(`XKOVA member ${operation} returned an invalid ${key}`);
1416
+ }
1417
+ return field;
1418
+ }
1419
+ /**
1420
+ * Server TTL starts after request transit, not at the BFF's request start.
1421
+ * Validate against receipt time, then intersect it with the original local
1422
+ * deadline. Network latency never extends the state/PKCE transaction lifetime.
1423
+ */
1424
+ function boundedAuthorizationExpiry(value, localDeadline, operation) {
1425
+ const serverExpiry = Date.parse(requireIsoDate(value, 'expires_at', operation));
1426
+ const receivedAt = Date.now();
1427
+ if (serverExpiry <= receivedAt ||
1428
+ serverExpiry > receivedAt + HOSTED_AUTHORIZATION_MAX_TTL_MS ||
1429
+ localDeadline <= receivedAt) {
1430
+ throw new Error(`XKOVA member ${operation} returned an invalid expiry window`);
1431
+ }
1432
+ const expiresAtMillis = Math.min(serverExpiry, localDeadline);
1433
+ return { expiresAtMillis, expiresAt: new Date(expiresAtMillis).toISOString() };
1434
+ }
1435
+ function requirePositiveInteger(value, key, operation) {
1436
+ const field = value[key];
1437
+ if (!Number.isSafeInteger(field) || field <= 0) {
1438
+ throw new Error(`XKOVA member ${operation} returned an invalid ${key}`);
1439
+ }
1440
+ return field;
1441
+ }
1442
+ function requireStringArray(value, key, operation) {
1443
+ const field = value[key];
1444
+ if (!Array.isArray(field) ||
1445
+ field.some((entry) => typeof entry !== 'string' || entry.length === 0)) {
1446
+ throw new Error(`XKOVA member ${operation} returned an invalid ${key}`);
1447
+ }
1448
+ return field;
1449
+ }
1450
+ function assertUuid(value, label) {
1451
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
1452
+ throw new TypeError(`${label} must be a UUID`);
1453
+ }
1454
+ }
1455
+ function assertHostCookieName(cookieName) {
1456
+ if (!/^__Host-[A-Za-z0-9_-]+$/.test(cookieName)) {
1457
+ throw new TypeError('member cookie name must use the __Host- prefix');
1458
+ }
1459
+ }
1460
+ function assertOpaqueReference(reference) {
1461
+ if (!/^[A-Za-z0-9_-]{43,128}$/.test(reference)) {
1462
+ throw new TypeError('member application-session reference is malformed');
1463
+ }
1464
+ }
1465
+ export function generateXKOVAMemberOperationAttemptId() {
1466
+ return randomBytes(32).toString('base64url');
1467
+ }
1468
+ function assertOperationAttemptId(value) {
1469
+ if (!/^[A-Za-z0-9_-]{43}$/.test(value)) {
1470
+ throw new TypeError('member session operation attemptId is malformed');
1471
+ }
1472
+ }
1473
+ function assertProofToken(proofToken) {
1474
+ if (proofToken.length < 20 || proofToken.length > 8192) {
1475
+ throw new TypeError('provider proof must contain from 20 through 8192 characters');
1476
+ }
1477
+ }
1478
+ function hashProof(proofToken) {
1479
+ assertProofToken(proofToken);
1480
+ return createHash('sha256')
1481
+ .update('xkova:member-provider-proof:v1\0')
1482
+ .update(proofToken)
1483
+ .digest('hex');
1484
+ }
1485
+ function hashProofExchangeId(exchangeId) {
1486
+ assertOpaqueReference(exchangeId);
1487
+ return createHash('sha256')
1488
+ .update('xkova:member-proof-exchange:v1\0')
1489
+ .update(exchangeId)
1490
+ .digest('hex');
1491
+ }
1492
+ function assertExactProofNonce(proofToken, expectedNonce) {
1493
+ try {
1494
+ const parts = proofToken.split('.');
1495
+ if (parts.length !== 3 || !parts[1])
1496
+ throw new Error('invalid JWT');
1497
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
1498
+ if (typeof payload.nonce !== 'string')
1499
+ throw new Error('missing nonce');
1500
+ const expected = Buffer.from(expectedNonce, 'utf8');
1501
+ const actual = Buffer.from(payload.nonce, 'utf8');
1502
+ if (actual.byteLength !== expected.byteLength || !timingSafeEqual(actual, expected)) {
1503
+ throw new Error('nonce mismatch');
1504
+ }
1505
+ }
1506
+ catch {
1507
+ throw new XKOVAMemberAuthorizationTerminalError('proof_nonce_mismatch', 'provider proof nonce does not match the exact authorization transaction');
1508
+ }
1509
+ }
1510
+ function hashProviderCode(value) {
1511
+ if (value.length === 0 || value.length > 4096) {
1512
+ throw new TypeError('member authorization code has an invalid length');
1513
+ }
1514
+ return createHash('sha256').update('xkova:member-provider-code:v1\0').update(value).digest('hex');
1515
+ }
1516
+ function assertHostedAuthorizationResultCode(value) {
1517
+ if (!/^[A-Za-z0-9_-]{43}$/.test(value)) {
1518
+ throw new TypeError('member Hosted authorization result code is malformed');
1519
+ }
1520
+ }
1521
+ function assertAccountCenterIntent(value) {
1522
+ if (!new Set(['security', 'sessions', 'wallet_readiness', 'action']).has(value)) {
1523
+ throw new TypeError('Account Center intent is unsupported');
1524
+ }
1525
+ }
1526
+ function assertMemberAction(resourceType, action) {
1527
+ const allowed = {
1528
+ payment: ['sign'],
1529
+ escrow: ['create', 'cancel'],
1530
+ approval_request: ['attest'],
1531
+ };
1532
+ if (!Object.prototype.hasOwnProperty.call(allowed, resourceType) ||
1533
+ !allowed[resourceType].includes(action)) {
1534
+ throw new TypeError('member action resource and action are unsupported');
1535
+ }
1536
+ }
1537
+ function constantTimeStringEqual(left, right) {
1538
+ const leftBytes = Buffer.from(left, 'utf8');
1539
+ const rightBytes = Buffer.from(right, 'utf8');
1540
+ return leftBytes.byteLength === rightBytes.byteLength && timingSafeEqual(leftBytes, rightBytes);
1541
+ }
1542
+ function memberBffClientFingerprint(input) {
1543
+ return createHash('sha256')
1544
+ .update('xkova:member-bff-client:v1\0')
1545
+ .update(input.apiBaseUrl)
1546
+ .update('\0')
1547
+ .update(input.apiKey)
1548
+ .update('\0')
1549
+ .update(input.publishableKey)
1550
+ .digest('base64url');
1551
+ }
1552
+ function assertMemberBffClientFingerprint(stored, expected) {
1553
+ if (!/^[A-Za-z0-9_-]{43}$/.test(stored) || !constantTimeStringEqual(stored, expected)) {
1554
+ throw new Error('member BFF transaction belongs to a different client application');
1555
+ }
1556
+ }
1557
+ function assertPositiveInteger(value, label) {
1558
+ if (!Number.isInteger(value) || value <= 0) {
1559
+ throw new TypeError(`${label} must be a positive integer`);
1560
+ }
1561
+ }
1562
+ function hashOpaque(value) {
1563
+ if (!/^[A-Za-z0-9_-]{43,128}$/.test(value)) {
1564
+ throw new TypeError('member authorization state is malformed');
1565
+ }
1566
+ return createHash('sha256')
1567
+ .update('xkova:member-authorization-state:v1\0')
1568
+ .update(value)
1569
+ .digest('hex');
1570
+ }
1571
+ function csrfSecret(value) {
1572
+ const bytes = typeof value === 'string' ? Buffer.from(value, 'utf8') : value;
1573
+ if (bytes.byteLength < 32)
1574
+ throw new TypeError('member CSRF secret must be at least 32 bytes');
1575
+ return bytes;
1576
+ }
1577
+ function isTerminalSessionError(error) {
1578
+ return (error instanceof XKOVAApiError &&
1579
+ new Set([
1580
+ 'member_auth_session_revoked',
1581
+ 'member_auth_refresh_replay_detected',
1582
+ 'member_auth_account_holder_inactive',
1583
+ 'member_auth_token_invalid',
1584
+ 'member_auth_token_expired',
1585
+ ]).has(error.code));
1586
+ }
1587
+ function isAlreadyRevokedSessionError(error) {
1588
+ return (error instanceof XKOVAApiError &&
1589
+ error.status === 401 &&
1590
+ error.code === 'member_auth_session_revoked');
1591
+ }
1592
+ function isAmbiguousTransportFailure(error) {
1593
+ return !(error instanceof XKOVAApiError) || error.status >= 500;
1594
+ }