@xkova/sdk 0.1.0-alpha.1 → 0.1.0-alpha.10

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 (51) hide show
  1. package/README.md +324 -32
  2. package/dist/abi.d.ts +40 -0
  3. package/dist/abi.js +25 -0
  4. package/dist/client.d.ts +4 -0
  5. package/dist/client.js +62 -3
  6. package/dist/generated/types.d.ts +8629 -5182
  7. package/dist/held-approval.d.ts +41 -0
  8. package/dist/held-approval.js +33 -0
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.js +1 -0
  11. package/dist/member-action-intents.js +2 -2
  12. package/dist/member-bff.d.ts +60 -21
  13. package/dist/member-bff.js +81 -79
  14. package/dist/member-resources.d.ts +67 -1
  15. package/dist/member-resources.js +9 -5
  16. package/dist/request-signing.js +6 -0
  17. package/dist/resources/account-holders.d.ts +131 -1
  18. package/dist/resources/account-holders.js +44 -9
  19. package/dist/resources/branding.d.ts +10 -0
  20. package/dist/resources/branding.js +2 -0
  21. package/dist/resources/contacts.d.ts +2 -2
  22. package/dist/resources/contacts.js +3 -3
  23. package/dist/resources/escrows.d.ts +3 -0
  24. package/dist/resources/payment-requests.d.ts +4 -0
  25. package/dist/resources/payments.d.ts +13 -0
  26. package/dist/resources/payments.js +7 -0
  27. package/dist/resources/ramps.d.ts +73 -64
  28. package/dist/resources/ramps.js +2 -2
  29. package/dist/resources/rwa.d.ts +9 -9
  30. package/dist/resources/tenant.d.ts +15 -0
  31. package/dist/resources/tenant.js +6 -1
  32. package/dist/resources/tokenization.d.ts +30 -11
  33. package/dist/resources/tokenization.js +10 -10
  34. package/dist/resources/treasury.d.ts +404 -0
  35. package/dist/resources/treasury.js +115 -0
  36. package/dist/resources/wallets.d.ts +2 -0
  37. package/dist/resources/webhooks.d.ts +1 -1
  38. package/dist/resources/webhooks.js +2 -2
  39. package/dist/resources/workflows.d.ts +7 -6
  40. package/dist/resources/workflows.js +87 -24
  41. package/dist/resources/workforce-wallets.d.ts +34 -0
  42. package/dist/resources/workforce-wallets.js +19 -0
  43. package/dist/url.d.ts +1 -0
  44. package/dist/url.js +4 -0
  45. package/dist/webhook.js +1 -1
  46. package/dist/workforce-bff.d.ts +202 -0
  47. package/dist/workforce-bff.js +462 -0
  48. package/examples/postgres-checkpoint-store.mjs +116 -0
  49. package/examples/postgres-checkpoints.sql +9 -0
  50. package/llms.txt +29 -9
  51. package/package.json +8 -3
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Narrow a governance hold before reading a mutation's entity result.
3
+ *
4
+ * On a workspace with a seeded/configured governance requirement, a
5
+ * create-shaped mutation (for example, `createTreasuryCounterparty` or
6
+ * treasury signer changes) may not execute: it returns a `HeldApproval`
7
+ * (`{ status: 'pending_approval', pending_approval_request_id }`) instead of
8
+ * the entity. No entity is created. Independently eligible approvers complete
9
+ * the required approval, then the same call is reissued through normal policy.
10
+ *
11
+ * Narrow BEFORE reading entity fields:
12
+ *
13
+ * const res = await client.treasury.createCounterparty(body, opts);
14
+ * if (isHeldApproval(res)) {
15
+ * await waitForApproval(res.pending_approval_request_id); // your flow
16
+ * return client.treasury.createCounterparty(body, opts); // same request
17
+ * }
18
+ * res.id; // safely the entity now
19
+ *
20
+ * This helper grants no approval, signer authority or permission to retry a
21
+ * changed request. Existing entity-shaped responses are not governance holds.
22
+ */
23
+ import type { components } from './generated/types.js';
24
+ export type HeldApproval = components['schemas']['HeldApproval'];
25
+ /** True when a mutation response is a governance hold rather than the entity. */
26
+ export declare function isHeldApproval(res: unknown): res is HeldApproval;
27
+ /**
28
+ * Thrown by `assertNotHeld` when a mutation was governance-held. Carries the
29
+ * approval request id so a script that does not implement the attest flow
30
+ * fails loudly with the pointer a human needs.
31
+ */
32
+ export declare class XKOVAHeldApprovalError extends Error {
33
+ readonly pendingApprovalRequestId: string;
34
+ constructor(held: HeldApproval, action?: string);
35
+ }
36
+ /**
37
+ * Unwrap a `entity | HeldApproval` union for callers that treat a hold as
38
+ * exceptional (scripts, one-shot provisioning). Returns the entity, or throws
39
+ * `XKOVAHeldApprovalError` carrying the approval request id.
40
+ */
41
+ export declare function assertNotHeld<T>(res: T | HeldApproval, action?: string): T;
@@ -0,0 +1,33 @@
1
+ /** True when a mutation response is a governance hold rather than the entity. */
2
+ export function isHeldApproval(res) {
3
+ return (typeof res === 'object' &&
4
+ res !== null &&
5
+ res.status === 'pending_approval' &&
6
+ typeof res
7
+ .pending_approval_request_id === 'string');
8
+ }
9
+ /**
10
+ * Thrown by `assertNotHeld` when a mutation was governance-held. Carries the
11
+ * approval request id so a script that does not implement the attest flow
12
+ * fails loudly with the pointer a human needs.
13
+ */
14
+ export class XKOVAHeldApprovalError extends Error {
15
+ pendingApprovalRequestId;
16
+ constructor(held, action) {
17
+ super(`${action ?? 'The mutation'} was held for governance approval ` +
18
+ `(approval request ${held.pending_approval_request_id}). Attest the ` +
19
+ `request, then re-issue the same call.`);
20
+ this.name = 'XKOVAHeldApprovalError';
21
+ this.pendingApprovalRequestId = held.pending_approval_request_id;
22
+ }
23
+ }
24
+ /**
25
+ * Unwrap a `entity | HeldApproval` union for callers that treat a hold as
26
+ * exceptional (scripts, one-shot provisioning). Returns the entity, or throws
27
+ * `XKOVAHeldApprovalError` carrying the approval request id.
28
+ */
29
+ export function assertNotHeld(res, action) {
30
+ if (isHeldApproval(res))
31
+ throw new XKOVAHeldApprovalError(res, action);
32
+ return res;
33
+ }
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ export { generateIdempotencyKey, withIdempotency } from './idempotency.js';
5
5
  export { withRetry, type RetryPolicy } from './retry.js';
6
6
  export { collectCursor, paginateCursor, type CursorPage, type PaginateCursorOptions, } from './cursor.js';
7
7
  export { XKOVAApiError, XKOVAUploadError } from './errors.js';
8
+ export { isHeldApproval, assertNotHeld, XKOVAHeldApprovalError, type HeldApproval, } from './held-approval.js';
8
9
  export { type PollOptions } from './resources/workflows.js';
9
10
  export { type XKOVAMemberSessionContext } from './resources/account-holders.js';
10
11
  export { verifyWebhookSignature, type VerifyWebhookSignatureResult } from './webhook.js';
package/dist/index.js CHANGED
@@ -4,5 +4,6 @@ export { generateIdempotencyKey, withIdempotency } from './idempotency.js';
4
4
  export { withRetry } from './retry.js';
5
5
  export { collectCursor, paginateCursor, } from './cursor.js';
6
6
  export { XKOVAApiError, XKOVAUploadError } from './errors.js';
7
+ export { isHeldApproval, assertNotHeld, XKOVAHeldApprovalError, } from './held-approval.js';
7
8
  export { verifyWebhookSignature } from './webhook.js';
8
9
  export { verifyInclusion, verifyConsistency, verifySth, } from './rwa-verify.js';
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { isAllowedProtocol } from './url.js';
2
3
  export class XKOVAMemberIntentError extends Error {
3
4
  code;
4
5
  status;
@@ -33,8 +34,7 @@ export class XKOVAMemberActionIntents {
33
34
  location.username ||
34
35
  location.password ||
35
36
  location.hash ||
36
- (location.protocol !== 'https:' &&
37
- !(location.protocol === 'http:' && location.hostname === 'localhost')))
37
+ !isAllowedProtocol(location))
38
38
  throw new TypeError('Action review requires the exact application origin');
39
39
  const snapshot = JSON.parse(canonicalJson({
40
40
  request: input.request,
@@ -8,6 +8,8 @@ type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Respo
8
8
  export type XKOVAMemberProofKind = 'xkova_hosted' | 'oidc' | 'signed_launch';
9
9
  export type XKOVAMemberProofBindingMode = 'authorization_nonce' | 'server_assertion_jti';
10
10
  export type XKOVAAccountCenterIntent = 'security' | 'sessions' | 'wallet_readiness' | 'action';
11
+ /** Direct Account Center starts do not create resource-bound signing authority. */
12
+ export type XKOVAAccountCenterStartIntent = Exclude<XKOVAAccountCenterIntent, 'action'>;
11
13
  export type XKOVAMemberActionResourceType = 'payment' | 'escrow' | 'approval_request';
12
14
  export type XKOVAMemberActionName = 'sign' | 'create' | 'cancel' | 'attest';
13
15
  export interface XKOVAMemberBffClientOptions {
@@ -34,6 +36,14 @@ export interface XKOVAMemberSessionMaterial extends XKOVAMemberContext {
34
36
  /** Confidential refresh credential, BFF storage only. */
35
37
  readonly refreshToken: string;
36
38
  }
39
+ export interface XKOVAExternalMemberEnrollment {
40
+ readonly accountHolderId: string;
41
+ readonly applicationId: string;
42
+ readonly publishableKey: string;
43
+ readonly applicationConfigVersion: number;
44
+ readonly providerNamespace: string;
45
+ readonly created: boolean;
46
+ }
37
47
  export interface XKOVABrowserMemberProjection {
38
48
  readonly signedIn: true;
39
49
  readonly accountHolderId: string;
@@ -49,8 +59,12 @@ export interface StoredXKOVAMemberSession<TApplicationData = unknown> extends XK
49
59
  }
50
60
  /**
51
61
  * `runExclusive` must serialize refresh and logout for the same reference
52
- * across all BFF instances. Use a database transaction or distributed lock;
53
- * a process-local mutex cannot coordinate multiple instances.
62
+ * across all BFF instances. Use a distributed or session-level database lock;
63
+ * a process-local mutex cannot coordinate multiple instances. This lock is not
64
+ * a surrounding rollback transaction: every awaited put/replace/delete must be
65
+ * durable before resolving and must survive a later error from work(). In
66
+ * particular, a pending refresh-attempt checkpoint cannot roll back after the
67
+ * provider/API has acted. replace is exact-existing-only, never an upsert.
54
68
  */
55
69
  export interface XKOVAMemberSessionStore<TApplicationData = unknown> {
56
70
  put(reference: string, session: StoredXKOVAMemberSession<TApplicationData>): Promise<void>;
@@ -116,7 +130,7 @@ export type XKOVAMemberAuthorizationTransaction<TApplicationData = unknown> = {
116
130
  readonly sessionReference: string;
117
131
  readonly intent: XKOVAAccountCenterIntent;
118
132
  readonly actionAuthorization?: {
119
- readonly method: 'hosted_passkey';
133
+ readonly method: 'hosted_passkey' | 'xkova_webauthn';
120
134
  } | {
121
135
  readonly method: 'external_oidc';
122
136
  readonly actionToken: string;
@@ -169,6 +183,11 @@ export type XKOVAMemberAuthorizationTransaction<TApplicationData = unknown> = {
169
183
  * every BFF instance. `replace` and `delete` must target that exact state. A
170
184
  * production store must encrypt the transaction because it can briefly hold a
171
185
  * confidential provider proof while XKOVA session exchange is retried.
186
+ * Every awaited write must commit independently of a later work() exception.
187
+ * Never wrap the entire callback in a rollback transaction: that can erase the
188
+ * staged proof after its one-time provider code has already been consumed.
189
+ * On uncertain write acknowledgement, reconcile the same exact record; never
190
+ * re-exchange the provider code or invent a replacement operation identity.
172
191
  */
173
192
  export interface XKOVAMemberAuthorizationTransactionStore<TApplicationData = unknown> {
174
193
  put(stateHash: string, transaction: XKOVAMemberAuthorizationTransaction<TApplicationData>): Promise<void>;
@@ -198,6 +217,10 @@ export type XKOVAMemberProofExchangeTransaction<TApplicationData = unknown> = {
198
217
  * Durable transaction store for a confidential proof exchange that does not
199
218
  * use the browser callback helper. Pending records contain a provider proof
200
219
  * and customer application data, so production adapters must encrypt them.
220
+ * Serialize the exact reference across replicas without a surrounding rollback
221
+ * transaction. Awaited put/replace/delete checkpoints must remain committed if
222
+ * the later exchange fails. Reconcile an uncertain acknowledgement against the
223
+ * same reference and attempt identity before retrying.
201
224
  */
202
225
  export interface XKOVAMemberProofExchangeTransactionStore<TApplicationData = unknown> {
203
226
  put(exchangeHash: string, transaction: XKOVAMemberProofExchangeTransaction<TApplicationData>): Promise<void>;
@@ -239,7 +262,7 @@ export interface XKOVAAccountCenterHandoffCompletion {
239
262
  readonly applicationReturnUri?: string;
240
263
  }
241
264
  export type XKOVAMemberActionAuthorizationStart = {
242
- readonly authorizationMethod: 'hosted_passkey';
265
+ readonly authorizationMethod: 'hosted_passkey' | 'xkova_webauthn';
243
266
  readonly authorizationUrl: string;
244
267
  readonly state: string;
245
268
  readonly expiresAt: string;
@@ -321,6 +344,18 @@ export declare class XKOVAMemberBffClient {
321
344
  exchangeProof(proofToken: string, options: {
322
345
  readonly attemptId: string;
323
346
  }): Promise<XKOVAMemberSessionMaterial>;
347
+ /**
348
+ * BFF-only membership provisioning after validating the provider identity or
349
+ * resolving it from a trusted directory. This creates no login session or
350
+ * wallet and does not substitute for genuine provider-proof verification.
351
+ * Save one idempotencyKey before the first attempt and retain the same subject,
352
+ * selector and observed configuration version during response-loss recovery.
353
+ */
354
+ enrollExternalMember(input: {
355
+ readonly externalSub: string;
356
+ readonly applicationConfigVersion: number;
357
+ readonly idempotencyKey: string;
358
+ }): Promise<XKOVAExternalMemberEnrollment>;
324
359
  refresh(refreshToken: string, options: {
325
360
  readonly attemptId: string;
326
361
  }): Promise<XKOVAMemberSessionMaterial>;
@@ -350,20 +385,6 @@ export declare class XKOVAMemberBffClient {
350
385
  readonly redirectUri: string;
351
386
  readonly transactionStore: XKOVAMemberAuthorizationTransactionStore<TApplicationData>;
352
387
  }): Promise<XKOVAHostedMemberAuthorizationStart>;
353
- /**
354
- * Available only to sandbox applications enabled for demo sign-in. The stored
355
- * Hosted transaction determines the demo account and callback. An uncertain
356
- * response preserves the key so a retry recovers the same reservation.
357
- * Complete the returned callback using
358
- * completeHostedAuthorization and the original browser flow cookie.
359
- */
360
- completeSandboxDemoAuthorization<TApplicationData>(input: {
361
- readonly state: string;
362
- readonly transactionStore: XKOVAMemberAuthorizationTransactionStore<TApplicationData>;
363
- }): Promise<{
364
- readonly callbackUrl: string;
365
- readonly expiresAt: string;
366
- }>;
367
388
  /**
368
389
  * Completes the one-time Hosted Auth result-code exchange and persists the
369
390
  * provider-neutral XKOVA member session. The returned projection is safe for
@@ -385,7 +406,7 @@ export declare class XKOVAMemberBffClient {
385
406
  * attempt remain in the customer BFF stores.
386
407
  */
387
408
  beginAccountCenterHandoff<TApplicationData>(input: {
388
- readonly intent: XKOVAAccountCenterIntent;
409
+ readonly intent: XKOVAAccountCenterStartIntent;
389
410
  readonly browserOrigin: string;
390
411
  readonly returnUri: string;
391
412
  /** Exact-origin application screen to restore, persisted only by the BFF. */
@@ -439,7 +460,22 @@ export declare class XKOVAMemberBffClient {
439
460
  readonly expectedState: string;
440
461
  readonly transactionStore: XKOVAMemberAuthorizationTransactionStore<TApplicationData>;
441
462
  readonly sessionStore: XKOVAMemberSessionStore<TApplicationData>;
463
+ /**
464
+ * Recover an already consumed provider code durably if this process stops or staging fails.
465
+ * XKOVA stages the returned proof before its own exchange, but cannot atomically commit a provider result.
466
+ */
442
467
  readonly exchangeProviderCode: (input: XKOVAMemberProviderCodeExchangeInput) => XKOVAMemberProviderCodeExchangeResult | Promise<XKOVAMemberProviderCodeExchangeResult>;
468
+ /**
469
+ * Optional OIDC enrollment, invoked only after the validated provider proof
470
+ * is durably staged. Validate/extract its exact subject on your backend and
471
+ * call enrollExternalMember with this stable idempotency key. Do not perform
472
+ * enrollment RPC inside exchangeProviderCode: failure there can otherwise
473
+ * require reusing an already-consumed provider authorization code.
474
+ */
475
+ readonly ensureMemberEnrollment?: (input: {
476
+ readonly proofToken: string;
477
+ readonly idempotencyKey: string;
478
+ }) => Promise<XKOVAExternalMemberEnrollment>;
443
479
  readonly applicationData: TApplicationData;
444
480
  readonly applicationSessionSeconds?: number;
445
481
  }): Promise<EstablishedXKOVAMemberSession<TApplicationData>>;
@@ -469,6 +505,11 @@ export declare class XKOVAMemberBffClient {
469
505
  resolveResources<TApplicationData>(input: {
470
506
  readonly reference: string;
471
507
  readonly sessionStore: XKOVAMemberSessionStore<TApplicationData>;
508
+ /**
509
+ * Defaults true. Set false only when the bound resource request executes
510
+ * immediately and therefore performs the same exact-session validation.
511
+ */
512
+ readonly validateWithXkova?: boolean;
472
513
  }): Promise<{
473
514
  readonly session: StoredXKOVAMemberSession<TApplicationData>;
474
515
  readonly resources: XKOVAMemberResources;
@@ -492,8 +533,6 @@ export declare class XKOVAMemberBffClient {
492
533
  private readSessionMaterial;
493
534
  private requestSignal;
494
535
  }
495
- /** Only confirmed admission rejections may release a customer's pending demo reservation. */
496
- export declare function isDefinitiveXKOVASandboxDemoAdmissionFailure(error: unknown): boolean;
497
536
  export declare function projectXKOVAMemberSession(session: XKOVAMemberContext): XKOVABrowserMemberProjection;
498
537
  export declare function serializeXKOVAMemberSessionCookie(input: {
499
538
  readonly cookieName: string;
@@ -1,5 +1,6 @@
1
1
  import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
2
2
  import { XKOVAApiError } from './errors.js';
3
+ import { isAllowedProtocol } from './url.js';
3
4
  import { XKOVAClient } from './client.js';
4
5
  import { XKOVAMemberResources } from './member-resources.js';
5
6
  import { XKOVAMemberActionIntents, } from './member-action-intents.js';
@@ -78,6 +79,48 @@ export class XKOVAMemberBffClient {
78
79
  proof_token: proofToken,
79
80
  }), 'proof exchange');
80
81
  }
82
+ /**
83
+ * BFF-only membership provisioning after validating the provider identity or
84
+ * resolving it from a trusted directory. This creates no login session or
85
+ * wallet and does not substitute for genuine provider-proof verification.
86
+ * Save one idempotencyKey before the first attempt and retain the same subject,
87
+ * selector and observed configuration version during response-loss recovery.
88
+ */
89
+ async enrollExternalMember(input) {
90
+ if (typeof input.externalSub !== 'string' ||
91
+ input.externalSub.length === 0 ||
92
+ input.externalSub.length > 512 ||
93
+ /\u0000/u.test(input.externalSub) ||
94
+ !/\S/u.test(input.externalSub))
95
+ throw new TypeError('externalSub must be an exact nonempty provider subject of at most 512 characters');
96
+ assertPositiveInteger(input.applicationConfigVersion, 'applicationConfigVersion');
97
+ if (!/^[A-Za-z0-9._:-]{1,255}$/u.test(input.idempotencyKey))
98
+ throw new TypeError('idempotencyKey must be a retained opaque operation key of at most 255 characters');
99
+ const value = await this.recoverablePostJson('/member-auth/enrollments', {
100
+ publishable_key: this.options.publishableKey,
101
+ application_config_version: input.applicationConfigVersion,
102
+ external_sub: input.externalSub,
103
+ }, undefined, input.idempotencyKey);
104
+ const result = requireObject(value, 'external member enrollment');
105
+ const applicationId = requireString(result, 'application_id', 'external member enrollment');
106
+ assertUuid(applicationId, 'enrollment application id');
107
+ const publishableKey = requireString(result, 'publishable_key', 'external member enrollment');
108
+ const applicationConfigVersion = requirePositiveInteger(result, 'application_config_version', 'external member enrollment');
109
+ const providerNamespace = requireString(result, 'provider_namespace', 'external member enrollment');
110
+ if (publishableKey !== this.options.publishableKey ||
111
+ applicationConfigVersion !== input.applicationConfigVersion ||
112
+ !providerNamespace.startsWith('oidc:') ||
113
+ typeof result.created !== 'boolean')
114
+ throw new Error('XKOVA external member enrollment returned a different application or configuration');
115
+ return {
116
+ accountHolderId: requireString(result, 'account_holder_id', 'external member enrollment'),
117
+ applicationId,
118
+ publishableKey,
119
+ applicationConfigVersion,
120
+ providerNamespace,
121
+ created: result.created,
122
+ };
123
+ }
81
124
  async refresh(refreshToken, options) {
82
125
  if (refreshToken.length < 20) {
83
126
  throw new TypeError('member refresh token is malformed');
@@ -195,54 +238,6 @@ export class XKOVAMemberBffClient {
195
238
  throw error;
196
239
  }
197
240
  }
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
241
  /**
247
242
  * Completes the one-time Hosted Auth result-code exchange and persists the
248
243
  * provider-neutral XKOVA member session. The returned projection is safe for
@@ -354,7 +349,7 @@ export class XKOVAMemberBffClient {
354
349
  async beginAccountCenterHandoff(input) {
355
350
  const browserOrigin = normalizeBrowserOrigin(input.browserOrigin);
356
351
  const returnUri = normalizeCallbackUri(input.returnUri);
357
- assertAccountCenterIntent(input.intent);
352
+ assertAccountCenterStartIntent(input.intent);
358
353
  const applicationReturnUri = input.applicationReturnUri === undefined
359
354
  ? undefined
360
355
  : normalizeApplicationReturnUri(input.applicationReturnUri, browserOrigin);
@@ -560,7 +555,7 @@ export class XKOVAMemberBffClient {
560
555
  clientFingerprint: this.clientFingerprint,
561
556
  codeVerifier,
562
557
  redirectUri: returnUri,
563
- exchangeAttemptId: generateXKOVAMemberOperationAttemptId(),
558
+ exchangeAttemptId: randomUUID(),
564
559
  sessionReference: input.sessionReference,
565
560
  intent: 'action',
566
561
  ...(applicationReturnUri !== undefined && { applicationReturnUri }),
@@ -583,15 +578,15 @@ export class XKOVAMemberBffClient {
583
578
  const object = requireObject(value, 'member action authorization start');
584
579
  const method = requireString(object, 'authorization_method', 'member action authorization start');
585
580
  const { expiresAt, expiresAtMillis } = boundedAuthorizationExpiry(object, provisionalExpiresAt, 'member action authorization start');
586
- if (method === 'hosted_passkey') {
581
+ if (method === 'hosted_passkey' || method === 'xkova_webauthn') {
587
582
  const authorizationUrl = normalizeHostedAuthorizationUrl(requireString(object, 'authorization_url', 'member action authorization start'));
588
583
  await input.transactionStore.replace(stateHash, {
589
584
  ...transaction,
590
- actionAuthorization: { method: 'hosted_passkey' },
585
+ actionAuthorization: { method },
591
586
  expiresAt: expiresAtMillis,
592
587
  });
593
588
  return {
594
- authorizationMethod: 'hosted_passkey',
589
+ authorizationMethod: method,
595
590
  authorizationUrl,
596
591
  state,
597
592
  expiresAt,
@@ -653,7 +648,7 @@ export class XKOVAMemberBffClient {
653
648
  if (initial?.completionMode === 'account_center_result_code' &&
654
649
  initial.status === 'pending' &&
655
650
  initial.intent === 'action' &&
656
- initial.actionAuthorization?.method === 'hosted_passkey') {
651
+ ['hosted_passkey', 'xkova_webauthn'].includes(initial.actionAuthorization?.method ?? '')) {
657
652
  const completion = await this.completeAccountCenterHandoff(input);
658
653
  if (!completion.signingExchangeCode || !completion.signingUrl) {
659
654
  throw new Error('XKOVA Hosted action completion omitted its signing projection');
@@ -806,6 +801,10 @@ export class XKOVAMemberBffClient {
806
801
  if (transaction.completionMode !== 'provider_proof') {
807
802
  throw new Error('member authorization transaction is not a provider-proof flow');
808
803
  }
804
+ if (input.ensureMemberEnrollment &&
805
+ transaction.status === 'pending' &&
806
+ transaction.proofKind !== 'oidc')
807
+ throw new Error('external member enrollment is available only for an OIDC proof flow');
809
808
  if (!sameCallback(callback, new URL(transaction.redirectUri))) {
810
809
  throw new Error('member authorization callback does not match its redirect URI');
811
810
  }
@@ -868,6 +867,14 @@ export class XKOVAMemberBffClient {
868
867
  },
869
868
  });
870
869
  }
870
+ const enrollment = input.ensureMemberEnrollment
871
+ ? await input.ensureMemberEnrollment({
872
+ proofToken,
873
+ idempotencyKey: `enroll:${transaction.exchangeAttemptId}`,
874
+ })
875
+ : undefined;
876
+ if (enrollment && enrollment.publishableKey !== this.options.publishableKey)
877
+ throw new Error('member enrollment does not belong to this application');
871
878
  const established = await this.persistSessionWithAttempt({
872
879
  proofToken,
873
880
  attemptId: transaction.exchangeAttemptId,
@@ -875,6 +882,7 @@ export class XKOVAMemberBffClient {
875
882
  sessionStore: input.sessionStore,
876
883
  applicationData,
877
884
  applicationExpiresAt,
885
+ ...(enrollment !== undefined && { expectedAccountHolderId: enrollment.accountHolderId }),
878
886
  });
879
887
  await input.transactionStore.replace(stateHash, {
880
888
  status: 'completed',
@@ -976,6 +984,11 @@ export class XKOVAMemberBffClient {
976
984
  await input.sessionStore.replace(input.reference, active);
977
985
  }
978
986
  const rotated = await this.refresh(current.refreshToken, { attemptId });
987
+ if (rotated.sessionId !== current.sessionId ||
988
+ rotated.accountHolderId !== current.accountHolderId ||
989
+ rotated.providerSubject !== current.providerSubject) {
990
+ throw new Error('XKOVA member context changed during refresh');
991
+ }
979
992
  const { pendingRefreshAttemptId: _completed, ...withoutPending } = active;
980
993
  active = {
981
994
  ...withoutPending,
@@ -1061,6 +1074,9 @@ export class XKOVAMemberBffClient {
1061
1074
  const material = await this.exchangeProof(input.proofToken, {
1062
1075
  attemptId: input.attemptId,
1063
1076
  });
1077
+ if (input.expectedAccountHolderId !== undefined &&
1078
+ material.accountHolderId !== input.expectedAccountHolderId)
1079
+ throw new Error('XKOVA proof exchange returned a different member from the enrolled identity');
1064
1080
  return this.persistSessionMaterial({
1065
1081
  material,
1066
1082
  reference: input.reference,
@@ -1111,8 +1127,7 @@ export class XKOVAMemberBffClient {
1111
1127
  ...(idempotencyKey !== undefined && { idempotencyKey }),
1112
1128
  });
1113
1129
  }
1114
- async recoverablePostJson(path, body, sessionId) {
1115
- const idempotencyKey = generateIdempotencyKey();
1130
+ async recoverablePostJson(path, body, sessionId, idempotencyKey = generateIdempotencyKey()) {
1116
1131
  try {
1117
1132
  return await this.postJson(path, body, sessionId, idempotencyKey);
1118
1133
  }
@@ -1210,14 +1225,6 @@ export class XKOVAMemberBffClient {
1210
1225
  : AbortSignal.timeout(this.options.timeoutMs);
1211
1226
  }
1212
1227
  }
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
1228
  export function projectXKOVAMemberSession(session) {
1222
1229
  return {
1223
1230
  signedIn: true,
@@ -1339,8 +1346,8 @@ function normalizeApiBaseUrl(baseUrl) {
1339
1346
  if (url.username || url.password || url.search || url.hash) {
1340
1347
  throw new TypeError('XKOVA member BFF baseUrl must be an API origin or /v1 base URL');
1341
1348
  }
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');
1349
+ if (!isAllowedProtocol(url)) {
1350
+ throw new TypeError('XKOVA member BFF baseUrl must use HTTPS or loopback HTTP');
1344
1351
  }
1345
1352
  const pathname = url.pathname.replace(/\/+$/, '');
1346
1353
  if (pathname !== '' && pathname !== '/v1') {
@@ -1353,16 +1360,15 @@ function normalizeCallbackUri(value) {
1353
1360
  if (url.username || url.password || url.search || url.hash) {
1354
1361
  throw new TypeError('member authorization redirectUri must not contain credentials, query, or fragment');
1355
1362
  }
1356
- if (url.protocol !== 'https:' && !(url.protocol === 'http:' && url.hostname === 'localhost')) {
1357
- throw new TypeError('member authorization redirectUri must use HTTPS or localhost HTTP');
1363
+ if (!isAllowedProtocol(url)) {
1364
+ throw new TypeError('member authorization redirectUri must use HTTPS or loopback HTTP');
1358
1365
  }
1359
1366
  return url.toString();
1360
1367
  }
1361
1368
  function normalizeBrowserOrigin(value) {
1362
1369
  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');
1370
+ if (url.origin !== value || !isAllowedProtocol(url)) {
1371
+ throw new TypeError('member authorization browserOrigin must be an exact HTTPS or loopback origin');
1366
1372
  }
1367
1373
  return value;
1368
1374
  }
@@ -1372,18 +1378,14 @@ function normalizeApplicationReturnUri(value, browserOrigin) {
1372
1378
  url.username ||
1373
1379
  url.password ||
1374
1380
  url.hash ||
1375
- (url.protocol !== 'https:' && !(url.protocol === 'http:' && url.hostname === 'localhost'))) {
1381
+ !isAllowedProtocol(url)) {
1376
1382
  throw new TypeError('member action applicationReturnUri must use the exact browser origin');
1377
1383
  }
1378
1384
  return url.toString();
1379
1385
  }
1380
1386
  function normalizeHostedAuthorizationUrl(value) {
1381
1387
  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')))) {
1388
+ if (url.username || url.password || url.hash || !isAllowedProtocol(url)) {
1387
1389
  throw new Error('XKOVA member Hosted authorization URL must use HTTPS or loopback HTTP without credentials or a fragment');
1388
1390
  }
1389
1391
  return url.toString();
@@ -1518,8 +1520,8 @@ function assertHostedAuthorizationResultCode(value) {
1518
1520
  throw new TypeError('member Hosted authorization result code is malformed');
1519
1521
  }
1520
1522
  }
1521
- function assertAccountCenterIntent(value) {
1522
- if (!new Set(['security', 'sessions', 'wallet_readiness', 'action']).has(value)) {
1523
+ function assertAccountCenterStartIntent(value) {
1524
+ if (!new Set(['security', 'sessions', 'wallet_readiness']).has(value)) {
1523
1525
  throw new TypeError('Account Center intent is unsupported');
1524
1526
  }
1525
1527
  }