@xkova/sdk 0.1.0-alpha.6 → 0.1.0-alpha.7

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.
package/README.md CHANGED
@@ -124,6 +124,10 @@ const balance = await xkova.accountHolders.selfBalance({
124
124
  });
125
125
  ```
126
126
 
127
+ `selfBalance` can return a partial answer. A chain not known right now has no
128
+ entries in `balances` and a null `through_block` in `data_freshness`, so check
129
+ that before treating a missing token as zero or adding balances together.
130
+
127
131
  ## Register a Hosted Auth application
128
132
 
129
133
  Hosted Auth provides an optional XKOVA-hosted sign-in flow. Register an
@@ -216,6 +220,26 @@ proofs, and manages session refresh and logout. Implement its session,
216
220
  authorization-transaction, and direct proof-exchange store contracts in your
217
221
  own database. It does not include a web framework or database adapter.
218
222
 
223
+ `runExclusive` is an exclusivity boundary, not an all-or-nothing SQL
224
+ transaction. Every awaited `put`, `replace` and `delete` must commit before
225
+ returning, and remain committed if later callback work throws. Wrapping the
226
+ whole callback in a rollback transaction can erase a staged proof after its
227
+ one-time provider code was consumed. The same rule protects refresh-attempt
228
+ checkpoints. Reconcile an uncertain write acknowledgement using the exact
229
+ record and attempt identity, rather than starting a new operation.
230
+
231
+ The package includes [a PostgreSQL checkpoint-store example](examples/postgres-checkpoint-store.mjs)
232
+ and its [application-owned table definition](examples/postgres-checkpoints.sql).
233
+ Install `pg` in your backend and apply the table definition through your own
234
+ migration process. Instantiate separate stores with namespaces such as
235
+ `authorization`, `sessions` and `exchange`, one shared pool and an existing
236
+ 32-byte application encryption key. The example uses session advisory locks
237
+ across replicas, independent autocommit checkpoints, authenticated encryption
238
+ bound to namespace/reference, exact replacement without upsert, and connection
239
+ discard when lock release is uncertain. Do not use a transaction-pooling proxy
240
+ for session locks. Own key rotation, pool deadlines and expired-record cleanup
241
+ in your application; do not log records or encryption material.
242
+
219
243
  Authorization, Account Center, and action transactions expire within ten
220
244
  minutes of starting the request, or at the server's expiry if sooner. Late,
221
245
  expired, or excessively long-lived responses are rejected. Keep your BFF's
@@ -246,6 +270,51 @@ To exchange a proof without a browser callback, call `establishSession` with an
246
270
  `exchangeId`. Persist that ID before the first attempt and reuse it on retries.
247
271
  The store serializes attempts and preserves the exchange and session references.
248
272
 
273
+ ### Enroll an external identity-provider member
274
+
275
+ For a registered OIDC connection, the confidential backend can call
276
+ `memberAuth.enrollExternalMember({ externalSub, applicationConfigVersion,
277
+ idempotencyKey })`. Use the exact opaque subject from a fully verified provider
278
+ proof, the final read-back application configuration version, and one persisted
279
+ operation key. Never link by email. The API derives issuer namespace, tenant,
280
+ workspace and application from the registered connection and its bound
281
+ `member_auth` API principal. The header selector and body selector must match.
282
+ An existing issuer/subject membership is returned without replacing its identity,
283
+ status or wallet bindings. A held or closed member remains held or closed.
284
+
285
+ Enrollment creates or finds a membership, not an authenticated session or wallet.
286
+ The normal nonce-bound provider-proof exchange remains required. For callback
287
+ flows use `completeAuthorizationCallback`'s optional `ensureMemberEnrollment`
288
+ hook. It runs after the one-time provider proof is durably staged, with a stable
289
+ `idempotencyKey` preserved across retries. Verify the staged proof's signature,
290
+ issuer, audience, algorithm, expiry and nonce, then call `enrollExternalMember`
291
+ with its exact `sub`. Return that result from the hook. The helper checks that
292
+ the resulting authenticated session resolves to the enrolled account holder.
293
+ Do not put a fallible enrollment call inside `exchangeProviderCode`, before
294
+ the provider result has been staged. A version conflict requires reviewing the
295
+ changed registration and starting a new authorization flow, not blind retries.
296
+
297
+ ```ts
298
+ const completion = await memberAuth.completeAuthorizationCallback({
299
+ ...callbackInput,
300
+ ensureMemberEnrollment: async ({ proofToken, idempotencyKey }) => {
301
+ // This application function performs full JOSE and exact nonce validation.
302
+ const verified = await verifyRegisteredProviderProof(proofToken);
303
+ return memberAuth.enrollExternalMember({
304
+ externalSub: verified.sub,
305
+ applicationConfigVersion: registration.config_version,
306
+ idempotencyKey,
307
+ });
308
+ },
309
+ });
310
+ ```
311
+
312
+ The verifier and `registration` are application prerequisites in this example,
313
+ not SDK-provided provider validation or discovery functions. The public endpoint
314
+ is `POST /v1/member-auth/enrollments`; it accepts no caller-selected account
315
+ holder, tenant, issuer namespace, email or permissions. Enrollment alone never
316
+ qualifies fresh action assurance, signing authority or settlement.
317
+
249
318
  ### Account Center and action callbacks
250
319
 
251
320
  Account Center accepts a same-origin `applicationReturnUri` and preserves it
@@ -44,7 +44,7 @@ export interface paths {
44
44
  };
45
45
  get?: never;
46
46
  put?: never;
47
- /** Confirm a brand logo upload (magic-byte validated) */
47
+ /** Confirm this tenant's brand logo upload */
48
48
  post: operations["confirmBrandingLogo"];
49
49
  /** Remove the brand logo */
50
50
  delete: operations["deleteBrandingLogo"];
@@ -3029,6 +3029,23 @@ export interface paths {
3029
3029
  patch?: never;
3030
3030
  trace?: never;
3031
3031
  };
3032
+ "/member-auth/enrollments": {
3033
+ parameters: {
3034
+ query?: never;
3035
+ header?: never;
3036
+ path?: never;
3037
+ cookie?: never;
3038
+ };
3039
+ get?: never;
3040
+ put?: never;
3041
+ /** Enroll an external member for the registered identity provider */
3042
+ post: operations["enrollExternalMember"];
3043
+ delete?: never;
3044
+ options?: never;
3045
+ head?: never;
3046
+ patch?: never;
3047
+ trace?: never;
3048
+ };
3032
3049
  "/tenant/member-auth/applications": {
3033
3050
  parameters: {
3034
3051
  query?: never;
@@ -3091,7 +3108,7 @@ export interface paths {
3091
3108
  cookie?: never;
3092
3109
  };
3093
3110
  get?: never;
3094
- /** Register or update the calling backend's BYO OIDC application */
3111
+ /** Register or update the calling backend's member identity provider */
3095
3112
  put: operations["configureMemberAuthOidcApplication"];
3096
3113
  post?: never;
3097
3114
  delete?: never;
@@ -3702,6 +3719,8 @@ export type webhooks = Record<string, never>;
3702
3719
  export interface components {
3703
3720
  schemas: {
3704
3721
  TenantBranding: {
3722
+ logo_upload_limits: components["schemas"]["LogoUploadLimits"];
3723
+ has_logo_override: boolean;
3705
3724
  resolved: {
3706
3725
  brand_name: string;
3707
3726
  legal_name: string;
@@ -4038,8 +4057,10 @@ export interface components {
4038
4057
  };
4039
4058
  LedgerList: components["schemas"]["CursorPage"] & {
4040
4059
  data?: components["schemas"]["LedgerEntry"][];
4060
+ data_freshness: components["schemas"]["DataFreshness"][];
4041
4061
  };
4042
4062
  WalletBalanceResult: {
4063
+ data_freshness: components["schemas"]["DataFreshness"][];
4043
4064
  chain_id: number;
4044
4065
  wallet: string;
4045
4066
  token: string;
@@ -4308,6 +4329,7 @@ export interface components {
4308
4329
  has_more: boolean;
4309
4330
  };
4310
4331
  EscrowCreate: {
4332
+ notify_recipient?: boolean;
4311
4333
  recipient_ref: string;
4312
4334
  /** Format: uuid */
4313
4335
  token_deployment_id: string;
@@ -4318,6 +4340,8 @@ export interface components {
4318
4340
  metadata?: Record<string, never>;
4319
4341
  };
4320
4342
  EscrowCreateResponse: {
4343
+ /** @enum {string} */
4344
+ notification_status?: "queued" | "sent" | "failed" | "cancelled";
4321
4345
  escrow: components["schemas"]["Escrow"];
4322
4346
  claim_token: string;
4323
4347
  /** Format: uri */
@@ -4327,6 +4351,8 @@ export interface components {
4327
4351
  signing_expires_at?: components["schemas"]["TimestampUtc"] | null;
4328
4352
  };
4329
4353
  Escrow: {
4354
+ /** @enum {string} */
4355
+ notification_status?: "queued" | "sent" | "failed" | "cancelled";
4330
4356
  id: components["schemas"]["EscrowId"];
4331
4357
  chain: components["schemas"]["ChainDescriptor"];
4332
4358
  fingerprint: string;
@@ -4342,6 +4368,7 @@ export interface components {
4342
4368
  on_chain_payment_id?: string | null;
4343
4369
  create_tx_hash?: string | null;
4344
4370
  release_tx_hash?: string | null;
4371
+ data_freshness?: components["schemas"]["DataFreshness"][];
4345
4372
  fee_accruals?: components["schemas"]["EscrowFeeAccrual"][];
4346
4373
  net_amount?: components["schemas"]["Money"] | null;
4347
4374
  claimed_at?: components["schemas"]["TimestampUtc"] | null;
@@ -4368,6 +4395,10 @@ export interface components {
4368
4395
  has_more: boolean;
4369
4396
  };
4370
4397
  PaymentRequestCreate: {
4398
+ notify_recipient?: boolean;
4399
+ require_member_payer?: boolean;
4400
+ /** Format: uri */
4401
+ notification_return_uri?: string;
4371
4402
  payer: {
4372
4403
  account_holder_id?: components["schemas"]["AccountHolderId"] | null;
4373
4404
  /** Format: email */
@@ -4380,6 +4411,8 @@ export interface components {
4380
4411
  memo?: string | null;
4381
4412
  };
4382
4413
  PaymentRequest: {
4414
+ /** @enum {string} */
4415
+ notification_status?: "queued" | "sent" | "failed" | "cancelled";
4383
4416
  id?: components["schemas"]["PaymentRequestId"];
4384
4417
  requester_id?: components["schemas"]["AccountHolderId"];
4385
4418
  payer_id?: components["schemas"]["AccountHolderId"] | null;
@@ -4466,6 +4499,9 @@ export interface components {
4466
4499
  amount: string;
4467
4500
  /** Format: uuid */
4468
4501
  token_deployment_id: string;
4502
+ token_address: string;
4503
+ token_symbol: string;
4504
+ chain: components["schemas"]["ChainDescriptor"];
4469
4505
  /** @enum {string} */
4470
4506
  status: "pending" | "submitted" | "settled" | "requires_review";
4471
4507
  transaction_hash: string | null;
@@ -4497,6 +4533,7 @@ export interface components {
4497
4533
  };
4498
4534
  AccountHolderSelfBalance: {
4499
4535
  balances: components["schemas"]["AccountHolderSelfBalanceEntry"][];
4536
+ data_freshness: components["schemas"]["DataFreshness"][];
4500
4537
  };
4501
4538
  MemberPaymentRecovery: {
4502
4539
  payment: components["schemas"]["Payment"];
@@ -5014,6 +5051,9 @@ export interface components {
5014
5051
  raw_contract: string | null;
5015
5052
  raw_function: string | null;
5016
5053
  raw_args: string[] | null;
5054
+ failure_reason_code: string | null;
5055
+ failure_reason_detail: string | null;
5056
+ failure_retryable: boolean;
5017
5057
  /** Format: date-time */
5018
5058
  created_at: string;
5019
5059
  /** Format: date-time */
@@ -5230,6 +5270,20 @@ export interface components {
5230
5270
  expires_at: components["schemas"]["TimestampUtc"];
5231
5271
  action_readiness: components["schemas"]["MemberActionReadiness"];
5232
5272
  };
5273
+ ExternalMemberEnrollment: {
5274
+ publishable_key: string;
5275
+ application_config_version: number;
5276
+ external_sub: string;
5277
+ };
5278
+ ExternalMemberEnrollmentResult: {
5279
+ account_holder_id: components["schemas"]["AccountHolderId"];
5280
+ /** Format: uuid */
5281
+ application_id: string;
5282
+ publishable_key: string;
5283
+ application_config_version: number;
5284
+ provider_namespace: string;
5285
+ created: boolean;
5286
+ };
5233
5287
  MemberAuthApplicationSummary: {
5234
5288
  /** Format: uuid */
5235
5289
  id: string;
@@ -5242,6 +5296,15 @@ export interface components {
5242
5296
  name: string;
5243
5297
  /** @enum {string} */
5244
5298
  provider_kind: "xkova_hosted" | "oidc" | "signed_launch";
5299
+ provider_namespace: string;
5300
+ /** Format: uri */
5301
+ issuer: string | null;
5302
+ audience: string | null;
5303
+ /** Format: uri */
5304
+ jwks_uri: string | null;
5305
+ allowed_jwt_algorithms: ("RS256" | "PS256" | "ES256" | "EdDSA")[];
5306
+ /** @enum {string} */
5307
+ proof_binding_mode: "authorization_nonce" | "server_assertion_jti";
5245
5308
  enrollment_mode: components["schemas"]["MemberAuthEnrollmentMode"];
5246
5309
  is_default: boolean;
5247
5310
  /** @enum {string} */
@@ -5460,6 +5523,17 @@ export interface components {
5460
5523
  };
5461
5524
  /** @example cact_1A2b3C4d5E6f7G8h9I0jK1 */
5462
5525
  CorporateActionId: string;
5526
+ LogoUploadLimits: {
5527
+ /** @example 5242880 */
5528
+ max_bytes: number;
5529
+ /** @example 4096 */
5530
+ max_width: number;
5531
+ /** @example 4096 */
5532
+ max_height: number;
5533
+ /** @example 32000000 */
5534
+ max_total_frame_pixels: number;
5535
+ content_types: ("image/png" | "image/jpeg" | "image/webp" | "image/gif")[];
5536
+ };
5463
5537
  TenantBrandingTheme: {
5464
5538
  brand_name: string | null;
5465
5539
  legal_name: string | null;
@@ -5633,6 +5707,10 @@ export interface components {
5633
5707
  user_op_hash?: string | null;
5634
5708
  counterparty?: string | null;
5635
5709
  };
5710
+ DataFreshness: {
5711
+ chain_id: number;
5712
+ through_block: string | null;
5713
+ };
5636
5714
  /** @example cnt_1A2b3C4d5E6f7G8h9I0jK1 */
5637
5715
  ContactId: string;
5638
5716
  /** @example fnd_1A2b3C4d5E6f7G8h9I0jK1 */
@@ -7101,6 +7179,7 @@ export interface operations {
7101
7179
  401: components["responses"]["Unauthorized"];
7102
7180
  403: components["responses"]["Forbidden"];
7103
7181
  404: components["responses"]["NotFound"];
7182
+ 503: components["responses"]["ServiceUnavailable"];
7104
7183
  };
7105
7184
  };
7106
7185
  getWalletBalance: {
@@ -7128,6 +7207,7 @@ export interface operations {
7128
7207
  401: components["responses"]["Unauthorized"];
7129
7208
  403: components["responses"]["Forbidden"];
7130
7209
  404: components["responses"]["NotFound"];
7210
+ 503: components["responses"]["ServiceUnavailable"];
7131
7211
  };
7132
7212
  };
7133
7213
  listContacts: {
@@ -8584,6 +8664,7 @@ export interface operations {
8584
8664
  };
8585
8665
  401: components["responses"]["Unauthorized"];
8586
8666
  403: components["responses"]["Forbidden"];
8667
+ 503: components["responses"]["ServiceUnavailable"];
8587
8668
  };
8588
8669
  };
8589
8670
  getMemberPaymentRecovery: {
@@ -9856,6 +9937,7 @@ export interface operations {
9856
9937
  "application/json": components["schemas"]["ErrorResponse"];
9857
9938
  };
9858
9939
  };
9940
+ 503: components["responses"]["ServiceUnavailable"];
9859
9941
  };
9860
9942
  };
9861
9943
  getRwaDocument: {
@@ -10078,6 +10160,7 @@ export interface operations {
10078
10160
  401: components["responses"]["Unauthorized"];
10079
10161
  403: components["responses"]["Forbidden"];
10080
10162
  404: components["responses"]["NotFound"];
10163
+ 503: components["responses"]["ServiceUnavailable"];
10081
10164
  };
10082
10165
  };
10083
10166
  listRwaTokenHolders: {
@@ -10105,6 +10188,7 @@ export interface operations {
10105
10188
  401: components["responses"]["Unauthorized"];
10106
10189
  403: components["responses"]["Forbidden"];
10107
10190
  404: components["responses"]["NotFound"];
10191
+ 503: components["responses"]["ServiceUnavailable"];
10108
10192
  };
10109
10193
  };
10110
10194
  listRwaTokenInstances: {
@@ -10133,6 +10217,7 @@ export interface operations {
10133
10217
  403: components["responses"]["Forbidden"];
10134
10218
  404: components["responses"]["NotFound"];
10135
10219
  422: components["responses"]["UnprocessableEntity"];
10220
+ 503: components["responses"]["ServiceUnavailable"];
10136
10221
  };
10137
10222
  };
10138
10223
  createRwaTokenDistribution: {
@@ -10164,6 +10249,7 @@ export interface operations {
10164
10249
  403: components["responses"]["Forbidden"];
10165
10250
  404: components["responses"]["NotFound"];
10166
10251
  422: components["responses"]["UnprocessableEntity"];
10252
+ 503: components["responses"]["ServiceUnavailable"];
10167
10253
  };
10168
10254
  };
10169
10255
  getRwaTokenDistribution: {
@@ -12366,6 +12452,37 @@ export interface operations {
12366
12452
  401: components["responses"]["Unauthorized"];
12367
12453
  };
12368
12454
  };
12455
+ enrollExternalMember: {
12456
+ parameters: {
12457
+ query?: never;
12458
+ header: {
12459
+ "Idempotency-Key": components["parameters"]["idempotencyKey"];
12460
+ "X-XKOVA-Publishable-Key": string;
12461
+ };
12462
+ path?: never;
12463
+ cookie?: never;
12464
+ };
12465
+ requestBody: {
12466
+ content: {
12467
+ "application/json": components["schemas"]["ExternalMemberEnrollment"];
12468
+ };
12469
+ };
12470
+ responses: {
12471
+ 200: {
12472
+ headers: {
12473
+ [name: string]: unknown;
12474
+ };
12475
+ content: {
12476
+ "application/json": components["schemas"]["ExternalMemberEnrollmentResult"];
12477
+ };
12478
+ };
12479
+ 401: components["responses"]["Unauthorized"];
12480
+ 403: components["responses"]["Forbidden"];
12481
+ 409: components["responses"]["Conflict"];
12482
+ 422: components["responses"]["UnprocessableEntity"];
12483
+ 429: components["responses"]["TooManyRequests"];
12484
+ };
12485
+ };
12369
12486
  listMemberAuthApplications: {
12370
12487
  parameters: {
12371
12488
  query?: {
@@ -13099,6 +13216,7 @@ export interface operations {
13099
13216
  "application/json": components["schemas"]["ErrorResponse"];
13100
13217
  };
13101
13218
  };
13219
+ 503: components["responses"]["ServiceUnavailable"];
13102
13220
  };
13103
13221
  };
13104
13222
  listRwaAttesters: {
@@ -13253,6 +13371,7 @@ export interface operations {
13253
13371
  "application/json": components["schemas"]["ErrorResponse"];
13254
13372
  };
13255
13373
  };
13374
+ 503: components["responses"]["ServiceUnavailable"];
13256
13375
  };
13257
13376
  };
13258
13377
  describeRwaTokenFields: {
@@ -13330,6 +13449,7 @@ export interface operations {
13330
13449
  "application/json": components["schemas"]["ErrorResponse"];
13331
13450
  };
13332
13451
  };
13452
+ 503: components["responses"]["ServiceUnavailable"];
13333
13453
  };
13334
13454
  };
13335
13455
  getRwaSchemaBinding: {
@@ -13625,6 +13745,7 @@ export interface operations {
13625
13745
  404: components["responses"]["NotFound"];
13626
13746
  409: components["responses"]["Conflict"];
13627
13747
  422: components["responses"]["UnprocessableEntity"];
13748
+ 503: components["responses"]["ServiceUnavailable"];
13628
13749
  };
13629
13750
  };
13630
13751
  getRwaCorporateAction: {
@@ -34,6 +34,14 @@ export interface XKOVAMemberSessionMaterial extends XKOVAMemberContext {
34
34
  /** Confidential refresh credential, BFF storage only. */
35
35
  readonly refreshToken: string;
36
36
  }
37
+ export interface XKOVAExternalMemberEnrollment {
38
+ readonly accountHolderId: string;
39
+ readonly applicationId: string;
40
+ readonly publishableKey: string;
41
+ readonly applicationConfigVersion: number;
42
+ readonly providerNamespace: string;
43
+ readonly created: boolean;
44
+ }
37
45
  export interface XKOVABrowserMemberProjection {
38
46
  readonly signedIn: true;
39
47
  readonly accountHolderId: string;
@@ -49,8 +57,12 @@ export interface StoredXKOVAMemberSession<TApplicationData = unknown> extends XK
49
57
  }
50
58
  /**
51
59
  * `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.
60
+ * across all BFF instances. Use a distributed or session-level database lock;
61
+ * a process-local mutex cannot coordinate multiple instances. This lock is not
62
+ * a surrounding rollback transaction: every awaited put/replace/delete must be
63
+ * durable before resolving and must survive a later error from work(). In
64
+ * particular, a pending refresh-attempt checkpoint cannot roll back after the
65
+ * provider/API has acted. replace is exact-existing-only, never an upsert.
54
66
  */
55
67
  export interface XKOVAMemberSessionStore<TApplicationData = unknown> {
56
68
  put(reference: string, session: StoredXKOVAMemberSession<TApplicationData>): Promise<void>;
@@ -169,6 +181,11 @@ export type XKOVAMemberAuthorizationTransaction<TApplicationData = unknown> = {
169
181
  * every BFF instance. `replace` and `delete` must target that exact state. A
170
182
  * production store must encrypt the transaction because it can briefly hold a
171
183
  * confidential provider proof while XKOVA session exchange is retried.
184
+ * Every awaited write must commit independently of a later work() exception.
185
+ * Never wrap the entire callback in a rollback transaction: that can erase the
186
+ * staged proof after its one-time provider code has already been consumed.
187
+ * On uncertain write acknowledgement, reconcile the same exact record; never
188
+ * re-exchange the provider code or invent a replacement operation identity.
172
189
  */
173
190
  export interface XKOVAMemberAuthorizationTransactionStore<TApplicationData = unknown> {
174
191
  put(stateHash: string, transaction: XKOVAMemberAuthorizationTransaction<TApplicationData>): Promise<void>;
@@ -198,6 +215,10 @@ export type XKOVAMemberProofExchangeTransaction<TApplicationData = unknown> = {
198
215
  * Durable transaction store for a confidential proof exchange that does not
199
216
  * use the browser callback helper. Pending records contain a provider proof
200
217
  * and customer application data, so production adapters must encrypt them.
218
+ * Serialize the exact reference across replicas without a surrounding rollback
219
+ * transaction. Awaited put/replace/delete checkpoints must remain committed if
220
+ * the later exchange fails. Reconcile an uncertain acknowledgement against the
221
+ * same reference and attempt identity before retrying.
201
222
  */
202
223
  export interface XKOVAMemberProofExchangeTransactionStore<TApplicationData = unknown> {
203
224
  put(exchangeHash: string, transaction: XKOVAMemberProofExchangeTransaction<TApplicationData>): Promise<void>;
@@ -321,6 +342,18 @@ export declare class XKOVAMemberBffClient {
321
342
  exchangeProof(proofToken: string, options: {
322
343
  readonly attemptId: string;
323
344
  }): Promise<XKOVAMemberSessionMaterial>;
345
+ /**
346
+ * BFF-only membership provisioning after validating the provider identity or
347
+ * resolving it from a trusted directory. This creates no login session or
348
+ * wallet and does not substitute for genuine provider-proof verification.
349
+ * Save one idempotencyKey before the first attempt and retain the same subject,
350
+ * selector and observed configuration version during response-loss recovery.
351
+ */
352
+ enrollExternalMember(input: {
353
+ readonly externalSub: string;
354
+ readonly applicationConfigVersion: number;
355
+ readonly idempotencyKey: string;
356
+ }): Promise<XKOVAExternalMemberEnrollment>;
324
357
  refresh(refreshToken: string, options: {
325
358
  readonly attemptId: string;
326
359
  }): Promise<XKOVAMemberSessionMaterial>;
@@ -426,6 +459,17 @@ export declare class XKOVAMemberBffClient {
426
459
  readonly transactionStore: XKOVAMemberAuthorizationTransactionStore<TApplicationData>;
427
460
  readonly sessionStore: XKOVAMemberSessionStore<TApplicationData>;
428
461
  readonly exchangeProviderCode: (input: XKOVAMemberProviderCodeExchangeInput) => XKOVAMemberProviderCodeExchangeResult | Promise<XKOVAMemberProviderCodeExchangeResult>;
462
+ /**
463
+ * Optional OIDC enrollment, invoked only after the validated provider proof
464
+ * is durably staged. Validate/extract its exact subject on your backend and
465
+ * call enrollExternalMember with this stable idempotency key. Do not perform
466
+ * enrollment RPC inside exchangeProviderCode: failure there can otherwise
467
+ * require reusing an already-consumed provider authorization code.
468
+ */
469
+ readonly ensureMemberEnrollment?: (input: {
470
+ readonly proofToken: string;
471
+ readonly idempotencyKey: string;
472
+ }) => Promise<XKOVAExternalMemberEnrollment>;
429
473
  readonly applicationData: TApplicationData;
430
474
  readonly applicationSessionSeconds?: number;
431
475
  }): Promise<EstablishedXKOVAMemberSession<TApplicationData>>;
@@ -79,6 +79,48 @@ export class XKOVAMemberBffClient {
79
79
  proof_token: proofToken,
80
80
  }), 'proof exchange');
81
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
+ }
82
124
  async refresh(refreshToken, options) {
83
125
  if (refreshToken.length < 20) {
84
126
  throw new TypeError('member refresh token is malformed');
@@ -759,6 +801,10 @@ export class XKOVAMemberBffClient {
759
801
  if (transaction.completionMode !== 'provider_proof') {
760
802
  throw new Error('member authorization transaction is not a provider-proof flow');
761
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');
762
808
  if (!sameCallback(callback, new URL(transaction.redirectUri))) {
763
809
  throw new Error('member authorization callback does not match its redirect URI');
764
810
  }
@@ -821,6 +867,14 @@ export class XKOVAMemberBffClient {
821
867
  },
822
868
  });
823
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');
824
878
  const established = await this.persistSessionWithAttempt({
825
879
  proofToken,
826
880
  attemptId: transaction.exchangeAttemptId,
@@ -828,6 +882,7 @@ export class XKOVAMemberBffClient {
828
882
  sessionStore: input.sessionStore,
829
883
  applicationData,
830
884
  applicationExpiresAt,
885
+ ...(enrollment !== undefined && { expectedAccountHolderId: enrollment.accountHolderId }),
831
886
  });
832
887
  await input.transactionStore.replace(stateHash, {
833
888
  status: 'completed',
@@ -1014,6 +1069,9 @@ export class XKOVAMemberBffClient {
1014
1069
  const material = await this.exchangeProof(input.proofToken, {
1015
1070
  attemptId: input.attemptId,
1016
1071
  });
1072
+ if (input.expectedAccountHolderId !== undefined &&
1073
+ material.accountHolderId !== input.expectedAccountHolderId)
1074
+ throw new Error('XKOVA proof exchange returned a different member from the enrolled identity');
1017
1075
  return this.persistSessionMaterial({
1018
1076
  material,
1019
1077
  reference: input.reference,
@@ -1064,8 +1122,7 @@ export class XKOVAMemberBffClient {
1064
1122
  ...(idempotencyKey !== undefined && { idempotencyKey }),
1065
1123
  });
1066
1124
  }
1067
- async recoverablePostJson(path, body, sessionId) {
1068
- const idempotencyKey = generateIdempotencyKey();
1125
+ async recoverablePostJson(path, body, sessionId, idempotencyKey = generateIdempotencyKey()) {
1069
1126
  try {
1070
1127
  return await this.postJson(path, body, sessionId, idempotencyKey);
1071
1128
  }
@@ -52,6 +52,9 @@ export declare class XKOVAMemberResources {
52
52
  credit: {
53
53
  amount: string;
54
54
  token_deployment_id: string;
55
+ token_address: string;
56
+ token_symbol: string;
57
+ chain: import("./generated/types.js").components["schemas"]["ChainDescriptor"];
55
58
  status: "pending" | "submitted" | "settled" | "requires_review";
56
59
  transaction_hash: string | null;
57
60
  } | null;
@@ -64,6 +67,9 @@ export declare class XKOVAMemberResources {
64
67
  credit: {
65
68
  amount: string;
66
69
  token_deployment_id: string;
70
+ token_address: string;
71
+ token_symbol: string;
72
+ chain: import("./generated/types.js").components["schemas"]["ChainDescriptor"];
67
73
  status: "pending" | "submitted" | "settled" | "requires_review";
68
74
  transaction_hash: string | null;
69
75
  } | null;
@@ -103,6 +109,7 @@ export declare class XKOVAMemberResources {
103
109
  }>;
104
110
  balance: (control?: XKOVARequestControl) => Promise<{
105
111
  balances: import("./generated/types.js").components["schemas"]["AccountHolderSelfBalanceEntry"][];
112
+ data_freshness: import("./generated/types.js").components["schemas"]["DataFreshness"][];
106
113
  }>;
107
114
  wallets: (control?: XKOVARequestControl) => Promise<{
108
115
  data: import("./generated/types.js").components["schemas"]["Wallet"][];
@@ -152,6 +152,9 @@ export declare class AccountHoldersResource extends Resource {
152
152
  credit: {
153
153
  amount: string;
154
154
  token_deployment_id: string;
155
+ token_address: string;
156
+ token_symbol: string;
157
+ chain: import("../generated/types.js").components["schemas"]["ChainDescriptor"];
155
158
  status: "pending" | "submitted" | "settled" | "requires_review";
156
159
  transaction_hash: string | null;
157
160
  } | null;
@@ -164,12 +167,16 @@ export declare class AccountHoldersResource extends Resource {
164
167
  credit: {
165
168
  amount: string;
166
169
  token_deployment_id: string;
170
+ token_address: string;
171
+ token_symbol: string;
172
+ chain: import("../generated/types.js").components["schemas"]["ChainDescriptor"];
167
173
  status: "pending" | "submitted" | "settled" | "requires_review";
168
174
  transaction_hash: string | null;
169
175
  } | null;
170
176
  }>;
171
177
  selfBalance(context: XKOVAMemberSessionContext, control?: XKOVARequestControl): Promise<{
172
178
  balances: import("../generated/types.js").components["schemas"]["AccountHolderSelfBalanceEntry"][];
179
+ data_freshness: import("../generated/types.js").components["schemas"]["DataFreshness"][];
173
180
  }>;
174
181
  listSelfWallets(context: XKOVAMemberSessionContext, control?: XKOVARequestControl): Promise<{
175
182
  data: import("../generated/types.js").components["schemas"]["Wallet"][];
@@ -2,6 +2,8 @@ import { Resource, type ReqBody, type Query } from './base.js';
2
2
  import type { XKOVAMutationOptions } from '../client.js';
3
3
  export declare class BrandingResource extends Resource {
4
4
  get(): Promise<{
5
+ logo_upload_limits: import("../generated/types.js").components["schemas"]["LogoUploadLimits"];
6
+ has_logo_override: boolean;
5
7
  resolved: {
6
8
  brand_name: string;
7
9
  legal_name: string;
@@ -28,6 +30,8 @@ export declare class BrandingResource extends Resource {
28
30
  theme: import("../generated/types.js").components["schemas"]["TenantBrandingTheme"];
29
31
  }>;
30
32
  upsert(body: ReqBody<'upsertBranding'>, opts?: XKOVAMutationOptions): Promise<{
33
+ logo_upload_limits: import("../generated/types.js").components["schemas"]["LogoUploadLimits"];
34
+ has_logo_override: boolean;
31
35
  resolved: {
32
36
  brand_name: string;
33
37
  legal_name: string;
@@ -94,12 +98,16 @@ export declare class BrandingResource extends Resource {
94
98
  last_synced_at: string;
95
99
  created_at: string;
96
100
  }>;
101
+ /** Upload bytes to the returned URL, then confirm its opaque key as the same tenant. */
97
102
  logoUploadUrl(body: ReqBody<'brandingLogoUploadUrl'>, opts?: XKOVAMutationOptions): Promise<{
98
103
  upload_url: string;
99
104
  object_key: string;
100
105
  expires_at: string;
101
106
  }>;
107
+ /** Publish a newly issued upload. Saved logo URLs or keys are not upload keys. */
102
108
  confirmLogo(body: ReqBody<'confirmBrandingLogo'>, opts?: XKOVAMutationOptions): Promise<{
109
+ logo_upload_limits: import("../generated/types.js").components["schemas"]["LogoUploadLimits"];
110
+ has_logo_override: boolean;
103
111
  resolved: {
104
112
  brand_name: string;
105
113
  legal_name: string;
@@ -126,6 +134,8 @@ export declare class BrandingResource extends Resource {
126
134
  theme: import("../generated/types.js").components["schemas"]["TenantBrandingTheme"];
127
135
  }>;
128
136
  deleteLogo(opts?: XKOVAMutationOptions): Promise<{
137
+ logo_upload_limits: import("../generated/types.js").components["schemas"]["LogoUploadLimits"];
138
+ has_logo_override: boolean;
129
139
  resolved: {
130
140
  brand_name: string;
131
141
  legal_name: string;
@@ -22,9 +22,11 @@ export class BrandingResource extends Resource {
22
22
  verifyEmailDomain(id, opts) {
23
23
  return this.postJson(`/branding/email-domains/${this.enc(id)}/verify`, undefined, opts);
24
24
  }
25
+ /** Upload bytes to the returned URL, then confirm its opaque key as the same tenant. */
25
26
  logoUploadUrl(body, opts) {
26
27
  return this.postJson('/branding/logo/upload-url', body, opts);
27
28
  }
29
+ /** Publish a newly issued upload. Saved logo URLs or keys are not upload keys. */
28
30
  confirmLogo(body, opts) {
29
31
  return this.postJson('/branding/logo', body, opts);
30
32
  }
@@ -7,6 +7,7 @@ export declare class EscrowsResource extends Resource {
7
7
  has_more: boolean;
8
8
  }>;
9
9
  create(body: ReqBody<'createEscrow'>, opts?: XKOVAMutationOptions): Promise<{
10
+ notification_status?: "queued" | "sent" | "failed" | "cancelled";
10
11
  escrow: import("../generated/types.js").components["schemas"]["Escrow"];
11
12
  claim_token: string;
12
13
  claim_url: string;
@@ -15,6 +16,7 @@ export declare class EscrowsResource extends Resource {
15
16
  signing_expires_at?: import("../generated/types.js").components["schemas"]["TimestampUtc"] | null;
16
17
  }>;
17
18
  retrieve(id: string, query?: Query<'getEscrow'>): Promise<{
19
+ notification_status?: "queued" | "sent" | "failed" | "cancelled";
18
20
  id: import("../generated/types.js").components["schemas"]["EscrowId"];
19
21
  chain: import("../generated/types.js").components["schemas"]["ChainDescriptor"];
20
22
  fingerprint: string;
@@ -28,6 +30,7 @@ export declare class EscrowsResource extends Resource {
28
30
  on_chain_payment_id?: string | null;
29
31
  create_tx_hash?: string | null;
30
32
  release_tx_hash?: string | null;
33
+ data_freshness?: import("../generated/types.js").components["schemas"]["DataFreshness"][];
31
34
  fee_accruals?: import("../generated/types.js").components["schemas"]["EscrowFeeAccrual"][];
32
35
  net_amount?: import("../generated/types.js").components["schemas"]["Money"] | null;
33
36
  claimed_at?: import("../generated/types.js").components["schemas"]["TimestampUtc"] | null;
@@ -11,6 +11,7 @@ export declare class PaymentRequestsResource extends Resource {
11
11
  has_more: boolean;
12
12
  }>;
13
13
  create(body: ReqBody<'createPaymentRequest'>, opts?: XKOVAMutationOptions): Promise<{
14
+ notification_status?: "queued" | "sent" | "failed" | "cancelled";
14
15
  id?: import("../generated/types.js").components["schemas"]["PaymentRequestId"];
15
16
  requester_id?: import("../generated/types.js").components["schemas"]["AccountHolderId"];
16
17
  payer_id?: import("../generated/types.js").components["schemas"]["AccountHolderId"] | null;
@@ -26,6 +27,7 @@ export declare class PaymentRequestsResource extends Resource {
26
27
  updated_at?: import("../generated/types.js").components["schemas"]["TimestampUtc"];
27
28
  }>;
28
29
  retrieve(id: string): Promise<{
30
+ notification_status?: "queued" | "sent" | "failed" | "cancelled";
29
31
  id?: import("../generated/types.js").components["schemas"]["PaymentRequestId"];
30
32
  requester_id?: import("../generated/types.js").components["schemas"]["AccountHolderId"];
31
33
  payer_id?: import("../generated/types.js").components["schemas"]["AccountHolderId"] | null;
@@ -82,6 +84,7 @@ export declare class PaymentRequestsResource extends Resource {
82
84
  finalized_at: import("../generated/types.js").components["schemas"]["TimestampUtc"] | null;
83
85
  }>;
84
86
  cancel(id: string, opts?: XKOVAMutationOptions): Promise<{
87
+ notification_status?: "queued" | "sent" | "failed" | "cancelled";
85
88
  id?: import("../generated/types.js").components["schemas"]["PaymentRequestId"];
86
89
  requester_id?: import("../generated/types.js").components["schemas"]["AccountHolderId"];
87
90
  payer_id?: import("../generated/types.js").components["schemas"]["AccountHolderId"] | null;
@@ -97,6 +100,7 @@ export declare class PaymentRequestsResource extends Resource {
97
100
  updated_at?: import("../generated/types.js").components["schemas"]["TimestampUtc"];
98
101
  }>;
99
102
  decline(id: string, body?: ReqBody<'declinePaymentRequest'>, opts?: XKOVAMutationOptions): Promise<{
103
+ notification_status?: "queued" | "sent" | "failed" | "cancelled";
100
104
  id?: import("../generated/types.js").components["schemas"]["PaymentRequestId"];
101
105
  requester_id?: import("../generated/types.js").components["schemas"]["AccountHolderId"];
102
106
  payer_id?: import("../generated/types.js").components["schemas"]["AccountHolderId"] | null;
@@ -47,6 +47,12 @@ export declare class TenantResource extends Resource {
47
47
  publishable_key: string;
48
48
  name: string;
49
49
  provider_kind: "xkova_hosted" | "oidc" | "signed_launch";
50
+ provider_namespace: string;
51
+ issuer: string | null;
52
+ audience: string | null;
53
+ jwks_uri: string | null;
54
+ allowed_jwt_algorithms: ("RS256" | "PS256" | "ES256" | "EdDSA")[];
55
+ proof_binding_mode: "authorization_nonce" | "server_assertion_jti";
50
56
  enrollment_mode: import("../generated/types.js").components["schemas"]["MemberAuthEnrollmentMode"];
51
57
  is_default: boolean;
52
58
  status: "active" | "principal_disabled";
@@ -73,6 +79,12 @@ export declare class TenantResource extends Resource {
73
79
  publishable_key: string;
74
80
  name: string;
75
81
  provider_kind: "xkova_hosted" | "oidc" | "signed_launch";
82
+ provider_namespace: string;
83
+ issuer: string | null;
84
+ audience: string | null;
85
+ jwks_uri: string | null;
86
+ allowed_jwt_algorithms: ("RS256" | "PS256" | "ES256" | "EdDSA")[];
87
+ proof_binding_mode: "authorization_nonce" | "server_assertion_jti";
76
88
  enrollment_mode: import("../generated/types.js").components["schemas"]["MemberAuthEnrollmentMode"];
77
89
  is_default: boolean;
78
90
  status: "active" | "principal_disabled";
@@ -90,6 +102,9 @@ export declare class TenantResource extends Resource {
90
102
  created_at: import("../generated/types.js").components["schemas"]["TimestampUtc"];
91
103
  updated_at: import("../generated/types.js").components["schemas"]["TimestampUtc"];
92
104
  }>;
105
+ /** Disables the exact Hosted Auth or OIDC member application. */
106
+ disableMemberAuthApplication(id: string, opts?: XKOVAMutationOptions): Promise<void>;
107
+ /** Existing Hosted Auth convenience name. */
93
108
  disableHostedApplication(id: string, opts?: XKOVAMutationOptions): Promise<void>;
94
109
  configureOidcApplication(body: ReqBody<'configureMemberAuthOidcApplication'>, opts?: XKOVAMutationOptions): Promise<{
95
110
  id: string;
@@ -15,9 +15,14 @@ export class TenantResource extends Resource {
15
15
  updateHostedApplication(id, body, opts) {
16
16
  return this.patchJson(`/tenant/member-auth/applications/${this.enc(id)}`, body, opts);
17
17
  }
18
- disableHostedApplication(id, opts) {
18
+ /** Disables the exact Hosted Auth or OIDC member application. */
19
+ disableMemberAuthApplication(id, opts) {
19
20
  return this.deleteJson(`/tenant/member-auth/applications/${this.enc(id)}`, opts);
20
21
  }
22
+ /** Existing Hosted Auth convenience name. */
23
+ disableHostedApplication(id, opts) {
24
+ return this.disableMemberAuthApplication(id, opts);
25
+ }
21
26
  configureOidcApplication(body, opts) {
22
27
  return this.putJson('/tenant/member-auth/application', body, opts);
23
28
  }
@@ -186,6 +186,9 @@ export declare class TokenizationResource extends Resource {
186
186
  raw_contract: string | null;
187
187
  raw_function: string | null;
188
188
  raw_args: string[] | null;
189
+ failure_reason_code: string | null;
190
+ failure_reason_detail: string | null;
191
+ failure_retryable: boolean;
189
192
  created_at: string;
190
193
  updated_at: string;
191
194
  }>;
@@ -222,6 +225,9 @@ export declare class TokenizationResource extends Resource {
222
225
  raw_contract: string | null;
223
226
  raw_function: string | null;
224
227
  raw_args: string[] | null;
228
+ failure_reason_code: string | null;
229
+ failure_reason_detail: string | null;
230
+ failure_retryable: boolean;
225
231
  created_at: string;
226
232
  updated_at: string;
227
233
  }>;
@@ -261,6 +267,9 @@ export declare class TokenizationResource extends Resource {
261
267
  raw_contract: string | null;
262
268
  raw_function: string | null;
263
269
  raw_args: string[] | null;
270
+ failure_reason_code: string | null;
271
+ failure_reason_detail: string | null;
272
+ failure_retryable: boolean;
264
273
  created_at: string;
265
274
  updated_at: string;
266
275
  }>;
@@ -292,6 +301,9 @@ export declare class TokenizationResource extends Resource {
292
301
  raw_contract: string | null;
293
302
  raw_function: string | null;
294
303
  raw_args: string[] | null;
304
+ failure_reason_code: string | null;
305
+ failure_reason_detail: string | null;
306
+ failure_retryable: boolean;
295
307
  created_at: string;
296
308
  updated_at: string;
297
309
  }>;
@@ -323,6 +335,9 @@ export declare class TokenizationResource extends Resource {
323
335
  raw_contract: string | null;
324
336
  raw_function: string | null;
325
337
  raw_args: string[] | null;
338
+ failure_reason_code: string | null;
339
+ failure_reason_detail: string | null;
340
+ failure_retryable: boolean;
326
341
  created_at: string;
327
342
  updated_at: string;
328
343
  }>;
@@ -16,6 +16,7 @@ export declare class WalletsResource extends Resource {
16
16
  updated_at: import("../generated/types.js").components["schemas"]["TimestampUtc"];
17
17
  }>;
18
18
  balance(id: string, query: Query<'getWalletBalance'>): Promise<{
19
+ data_freshness: import("../generated/types.js").components["schemas"]["DataFreshness"][];
19
20
  chain_id: number;
20
21
  wallet: string;
21
22
  token: string;
@@ -34,6 +35,7 @@ export declare class WalletsResource extends Resource {
34
35
  has_more: boolean;
35
36
  } & {
36
37
  data?: import("../generated/types.js").components["schemas"]["LedgerEntry"][];
38
+ data_freshness: import("../generated/types.js").components["schemas"]["DataFreshness"][];
37
39
  }>;
38
40
  decommission(id: string, opts?: XKOVAMutationOptions): Promise<{
39
41
  id: import("../generated/types.js").components["schemas"]["WalletId"];
@@ -0,0 +1,116 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
3
+
4
+ /** Install pg separately. The application owns the pool, schema, retention and key rotation. */
5
+ export class PostgresCheckpointStore {
6
+ #context = new AsyncLocalStorage();
7
+ #key;
8
+
9
+ constructor(pool, namespace, encryptionKey) {
10
+ if (!/^[a-z][a-z0-9_-]{0,63}$/.test(namespace)) throw new Error('Invalid store namespace');
11
+ if (!Buffer.isBuffer(encryptionKey) || encryptionKey.length !== 32)
12
+ throw new Error('A 32-byte application encryption key is required');
13
+ this.pool = pool;
14
+ this.namespace = namespace;
15
+ this.#key = Buffer.from(encryptionKey);
16
+ }
17
+
18
+ #binding(reference) {
19
+ return JSON.stringify([this.namespace, reference]);
20
+ }
21
+
22
+ #encrypt(reference, record) {
23
+ const iv = randomBytes(12);
24
+ const cipher = createCipheriv('aes-256-gcm', this.#key, iv);
25
+ cipher.setAAD(Buffer.from(this.#binding(reference)));
26
+ const bytes = Buffer.concat([cipher.update(JSON.stringify(record), 'utf8'), cipher.final()]);
27
+ return Buffer.concat([iv, cipher.getAuthTag(), bytes]);
28
+ }
29
+
30
+ #decrypt(reference, bytes) {
31
+ if (!Buffer.isBuffer(bytes) || bytes.length < 29) throw new Error('Invalid stored envelope');
32
+ const decipher = createDecipheriv('aes-256-gcm', this.#key, bytes.subarray(0, 12));
33
+ decipher.setAAD(Buffer.from(this.#binding(reference)));
34
+ decipher.setAuthTag(bytes.subarray(12, 28));
35
+ return JSON.parse(
36
+ Buffer.concat([decipher.update(bytes.subarray(28)), decipher.final()]).toString('utf8'),
37
+ );
38
+ }
39
+
40
+ #db() {
41
+ return this.#context.getStore()?.client ?? this.pool;
42
+ }
43
+
44
+ async put(reference, record) {
45
+ await this.#db().query(
46
+ 'INSERT INTO xkova_helper_checkpoints(namespace, reference, envelope, expires_at) VALUES ($1,$2,$3,$4)',
47
+ [this.namespace, reference, this.#encrypt(reference, record), this.#expiry(record)],
48
+ );
49
+ }
50
+
51
+ async get(reference) {
52
+ const result = await this.#db().query(
53
+ 'SELECT envelope FROM xkova_helper_checkpoints WHERE namespace=$1 AND reference=$2 AND expires_at > now()',
54
+ [this.namespace, reference],
55
+ );
56
+ return result.rowCount ? this.#decrypt(reference, result.rows[0].envelope) : null;
57
+ }
58
+
59
+ async replace(reference, record) {
60
+ const result = await this.#db().query(
61
+ 'UPDATE xkova_helper_checkpoints SET envelope=$3, expires_at=$4 WHERE namespace=$1 AND reference=$2',
62
+ [this.namespace, reference, this.#encrypt(reference, record), this.#expiry(record)],
63
+ );
64
+ if (result.rowCount !== 1) throw new Error('Cannot replace a missing checkpoint');
65
+ }
66
+
67
+ async delete(reference) {
68
+ await this.#db().query(
69
+ 'DELETE FROM xkova_helper_checkpoints WHERE namespace=$1 AND reference=$2',
70
+ [this.namespace, reference],
71
+ );
72
+ }
73
+
74
+ #expiry(record) {
75
+ const value = record.applicationExpiresAt ?? record.expiresAt;
76
+ if (!Number.isSafeInteger(value)) throw new Error('Invalid checkpoint expiry');
77
+ return new Date(value);
78
+ }
79
+
80
+ /** A session lock, with separate autocommit checkpoints, never an outer SQL transaction. */
81
+ async runExclusive(reference, work) {
82
+ const existing = this.#context.getStore();
83
+ if (existing) {
84
+ if (existing.reference !== reference) throw new Error('Nested references are unsupported');
85
+ return work();
86
+ }
87
+ const client = await this.pool.connect();
88
+ let locked = false;
89
+ let discard = false;
90
+ try {
91
+ await client.query("SET lock_timeout = '5s'");
92
+ await client.query("SET statement_timeout = '10s'");
93
+ await client.query('SELECT pg_advisory_lock(hashtextextended($1, 0))', [
94
+ this.#binding(reference),
95
+ ]);
96
+ locked = true;
97
+ return await this.#context.run({ client, reference }, work);
98
+ } finally {
99
+ try {
100
+ if (locked) {
101
+ const result = await client.query(
102
+ 'SELECT pg_advisory_unlock(hashtextextended($1, 0)) AS released',
103
+ [this.#binding(reference)],
104
+ );
105
+ if (result.rows[0]?.released !== true) throw new Error('Checkpoint lock was lost');
106
+ }
107
+ await client.query('RESET lock_timeout');
108
+ await client.query('RESET statement_timeout');
109
+ } catch {
110
+ // A connection with an uncertain lock must never return to the pool.
111
+ discard = true;
112
+ }
113
+ client.release(discard);
114
+ }
115
+ }
116
+ }
@@ -0,0 +1,9 @@
1
+ -- Apply through your application's normal migration process, to its own database.
2
+ CREATE TABLE xkova_helper_checkpoints (
3
+ namespace text NOT NULL,
4
+ reference text NOT NULL,
5
+ envelope bytea NOT NULL,
6
+ expires_at timestamptz NOT NULL,
7
+ PRIMARY KEY (namespace, reference)
8
+ );
9
+ CREATE INDEX xkova_helper_checkpoints_expiry ON xkova_helper_checkpoints (expires_at);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xkova/sdk",
3
- "version": "0.1.0-alpha.6",
3
+ "version": "0.1.0-alpha.7",
4
4
  "description": "TypeScript SDK for the XKOVA API.",
5
5
  "homepage": "https://docs.xkova.com",
6
6
  "repository": {
@@ -46,7 +46,8 @@
46
46
  },
47
47
  "files": [
48
48
  "dist",
49
- "llms.txt"
49
+ "llms.txt",
50
+ "examples"
50
51
  ],
51
52
  "dependencies": {
52
53
  "viem": "^2.21.0"