@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,462 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import { XKOVAClient } from './client.js';
3
+ import { XKOVAApiError } from './errors.js';
4
+ import { isAllowedProtocol } from './url.js';
5
+ import { WorkforceWalletsResource } from './resources/workforce-wallets.js';
6
+ // These helpers operate only on a customer's cookie/reference and CSRF proof.
7
+ // Reusing their cryptography does not reuse member identity or session authority.
8
+ export { serializeXKOVAMemberSessionCookie as serializeXKOVAWorkforceSessionCookie, serializeXKOVAMemberFlowCookie as serializeXKOVAWorkforceFlowCookie, serializeClearedXKOVAMemberCookie as serializeClearedXKOVAWorkforceCookie, readXKOVAMemberCookie as readXKOVAWorkforceCookie, readXKOVAMemberCallbackFlow as readXKOVAWorkforceCallbackFlow, xkovaMemberFlowCookieName as xkovaWorkforceFlowCookieName, createXKOVAMemberCsrfToken as createXKOVAWorkforceCsrfToken, verifyXKOVAMemberCsrfToken as verifyXKOVAWorkforceCsrfToken, assertXKOVAMemberRequestOrigin as assertXKOVAWorkforceRequestOrigin, } from './member-bff.js';
9
+ export class XKOVAWorkforceBffError extends Error {
10
+ code;
11
+ constructor(code) {
12
+ super('The workforce sign-in or session cannot safely continue. Start sign-in again.');
13
+ this.code = code;
14
+ this.name = 'XKOVAWorkforceBffError';
15
+ }
16
+ }
17
+ /** Confidential workforce protocol. It neither enrolls a subject nor creates local staff authority. */
18
+ export class XKOVAWorkforceBffClient {
19
+ options;
20
+ client;
21
+ fingerprint;
22
+ constructor(options) {
23
+ this.options = options;
24
+ if (!/^xkova_wf_[A-Za-z0-9_-]{43}$/.test(options.workforceKey)) {
25
+ throw new TypeError('Invalid workforce application selector');
26
+ }
27
+ this.client = XKOVAClient.forTenant(options);
28
+ const origin = new URL(options.baseUrl).origin;
29
+ this.fingerprint = digest(JSON.stringify([origin, options.apiKey, options.workforceKey]));
30
+ }
31
+ async beginAuthorization(input) {
32
+ const origin = new URL(input.browserOrigin);
33
+ const callback = exactCallback(input.redirectUri);
34
+ if (origin.origin !== input.browserOrigin ||
35
+ !isAllowedProtocol(origin) ||
36
+ callback.origin !== origin.origin) {
37
+ throw new TypeError('Workforce callback must belong to the exact application origin');
38
+ }
39
+ const state = random(), codeVerifier = random();
40
+ const transaction = {
41
+ clientFingerprint: this.fingerprint,
42
+ status: 'pending',
43
+ redirectUri: callback.href,
44
+ codeVerifier,
45
+ codeChallenge: digest(codeVerifier),
46
+ exchangeAttemptId: random(),
47
+ sessionReference: random(),
48
+ expiresAt: Date.now() + 300_000,
49
+ };
50
+ const key = digest(state);
51
+ await input.transactionStore.put(key, transaction);
52
+ const result = object(await this.postJson('/workforce-auth/transactions', {
53
+ browser_origin: origin.origin,
54
+ redirect_uri: callback.href,
55
+ code_challenge: transaction.codeChallenge,
56
+ code_challenge_method: 'S256',
57
+ }, transaction.exchangeAttemptId));
58
+ const expiresAt = timestamp(result, 'expires_at');
59
+ if (expiresAt <= Date.now() || expiresAt > transaction.expiresAt + 5_000)
60
+ throw new Error('Invalid workforce transaction lifetime');
61
+ const issuer = text(result, 'issuer');
62
+ secureUrl(issuer);
63
+ const authorizationEndpoint = secureUrl(text(result, 'authorization_endpoint')).href;
64
+ const audience = text(result, 'audience'), nonce = text(result, 'nonce');
65
+ opaque(nonce);
66
+ const maxAuthAgeSeconds = result.max_auth_age_seconds;
67
+ if (!Number.isSafeInteger(maxAuthAgeSeconds) || Number(maxAuthAgeSeconds) < 1)
68
+ throw new Error('Invalid workforce authentication policy');
69
+ const requiredAcrValues = strings(result.required_acr_values);
70
+ const bound = {
71
+ ...transaction,
72
+ transactionId: text(result, 'transaction_id'),
73
+ nonce,
74
+ issuer,
75
+ audience,
76
+ expiresAt,
77
+ };
78
+ await input.transactionStore.replace(key, bound);
79
+ return {
80
+ state,
81
+ nonce,
82
+ redirectUri: callback.href,
83
+ codeChallenge: transaction.codeChallenge,
84
+ codeChallengeMethod: 'S256',
85
+ issuer,
86
+ audience,
87
+ authorizationEndpoint,
88
+ maxAuthAgeSeconds: Number(maxAuthAgeSeconds),
89
+ requiredAcrValues,
90
+ expiresAt: new Date(expiresAt).toISOString(),
91
+ };
92
+ }
93
+ async completeAuthorizationCallback(input) {
94
+ const callback = secureUrl(input.callbackUrl), states = callback.searchParams.getAll('state');
95
+ opaque(input.expectedState);
96
+ if (states.length !== 1 ||
97
+ !equal(states[0], input.expectedState) ||
98
+ callback.searchParams.has('error')) {
99
+ throw new XKOVAWorkforceBffError('binding_mismatch');
100
+ }
101
+ const codes = callback.searchParams.getAll('code');
102
+ if (codes.length !== 1 || !codes[0] || codes[0].length > 8192)
103
+ throw new XKOVAWorkforceBffError('binding_mismatch');
104
+ const code = codes[0], codeHash = digest(code), key = digest(input.expectedState);
105
+ return input.transactionStore.runExclusive(key, async () => {
106
+ const initial = await input.transactionStore.get(key);
107
+ if (!initial ||
108
+ initial.expiresAt <= Date.now() ||
109
+ initial.clientFingerprint !== this.fingerprint ||
110
+ callback.origin + callback.pathname !== initial.redirectUri) {
111
+ throw new XKOVAWorkforceBffError('transaction_unavailable');
112
+ }
113
+ if (initial.providerExchangeStarted &&
114
+ initial.providerExchangeStarted.codeHash !== codeHash) {
115
+ throw new XKOVAWorkforceBffError('binding_mismatch');
116
+ }
117
+ const reference = initial.sessionReference;
118
+ return input.sessionStore.runExclusive(reference, async () => {
119
+ let transaction = initial;
120
+ if (transaction.stagedProofToken && !transaction.providerExchangeStarted) {
121
+ throw new XKOVAWorkforceBffError('transaction_unavailable');
122
+ }
123
+ const existing = await input.sessionStore.get(reference);
124
+ if (existing) {
125
+ this.assertSession(existing);
126
+ if (existing.authorizationCodeHash !== codeHash || existing.logoutPending) {
127
+ throw new XKOVAWorkforceBffError('session_unavailable');
128
+ }
129
+ await input.transactionStore.replace(key, { ...transaction, status: 'completed' });
130
+ return { reference, session: existing };
131
+ }
132
+ if (transaction.status === 'completed' ||
133
+ !transaction.transactionId ||
134
+ !transaction.nonce ||
135
+ !transaction.issuer ||
136
+ !transaction.audience)
137
+ throw new XKOVAWorkforceBffError('session_unavailable');
138
+ if (!transaction.stagedProofToken) {
139
+ if (transaction.providerExchangeStarted)
140
+ throw new XKOVAWorkforceBffError('provider_exchange_outcome_unknown');
141
+ // Freeze application data before consuming a code. A later retry cannot replace it.
142
+ transaction = {
143
+ ...transaction,
144
+ providerExchangeStarted: {
145
+ codeHash,
146
+ applicationData: JSON.parse(JSON.stringify({ value: input.applicationData })).value,
147
+ },
148
+ };
149
+ await input.transactionStore.replace(key, transaction);
150
+ const proof = await input.exchangeProviderCode({
151
+ code,
152
+ codeVerifier: transaction.codeVerifier,
153
+ redirectUri: transaction.redirectUri,
154
+ expectedNonce: transaction.nonce,
155
+ issuer: transaction.issuer,
156
+ audience: transaction.audience,
157
+ });
158
+ if (typeof proof.proofToken !== 'string' ||
159
+ !proof.proofToken ||
160
+ proof.proofToken.length > 65536) {
161
+ throw new Error('Invalid workforce provider proof result');
162
+ }
163
+ transaction = { ...transaction, stagedProofToken: proof.proofToken };
164
+ await input.transactionStore.replace(key, transaction);
165
+ }
166
+ const material = parseMaterial(await this.postJson('/workforce-auth/exchange', {
167
+ transaction_id: transaction.transactionId,
168
+ nonce: transaction.nonce,
169
+ proof_token: transaction.stagedProofToken,
170
+ exchange_attempt_id: transaction.exchangeAttemptId,
171
+ }, transaction.exchangeAttemptId));
172
+ const stored = {
173
+ clientFingerprint: this.fingerprint,
174
+ authorizationCodeHash: codeHash,
175
+ material,
176
+ applicationExpiresAt: timestamp(material.session, 'absolute_expires_at'),
177
+ applicationData: transaction.providerExchangeStarted.applicationData,
178
+ };
179
+ await input.sessionStore.put(reference, stored);
180
+ await input.transactionStore.replace(key, { ...transaction, status: 'completed' });
181
+ return { reference, session: stored };
182
+ });
183
+ });
184
+ }
185
+ async getSession(sessionToken) {
186
+ credential(sessionToken, 'xwfs');
187
+ return parseSession(await this.getJson('/workforce-auth/session', sessionToken));
188
+ }
189
+ async resolveSession(input) {
190
+ opaque(input.reference);
191
+ return input.sessionStore.runExclusive(input.reference, async () => {
192
+ let stored = await input.sessionStore.get(input.reference);
193
+ if (!stored || stored.logoutPending)
194
+ return null;
195
+ this.assertSession(stored);
196
+ if (stored.applicationExpiresAt <= Date.now())
197
+ return null;
198
+ try {
199
+ if (timestamp(stored.material.session, 'expires_at') <= Date.now() + 30_000) {
200
+ const material = parseMaterial(await this.postJson('/workforce-auth/refresh', {
201
+ refresh_token: stored.material.refresh_token,
202
+ refresh_attempt_token: stored.material.refresh_attempt_token,
203
+ }));
204
+ assertSameHuman(stored.material.session, material.session);
205
+ stored = { ...stored, material };
206
+ await input.sessionStore.replace(input.reference, stored);
207
+ }
208
+ const session = await this.getSession(stored.material.session_token);
209
+ assertSameHuman(stored.material.session, session);
210
+ stored = { ...stored, material: { ...stored.material, session } };
211
+ await input.sessionStore.replace(input.reference, stored);
212
+ return stored;
213
+ }
214
+ catch (error) {
215
+ if (error instanceof XKOVAApiError && (error.status === 401 || error.status === 403)) {
216
+ await input.sessionStore.replace(input.reference, { ...stored, logoutPending: true });
217
+ return null;
218
+ }
219
+ throw error;
220
+ }
221
+ });
222
+ }
223
+ /** Request-scoped resources only. Check the browser's Origin/CSRF before invoking writes. */
224
+ async resolveResources(input) {
225
+ const session = await this.resolveSession(input);
226
+ if (!session)
227
+ return null;
228
+ if (input.actionGrant) {
229
+ opaque(input.actionGrant.grant_token);
230
+ if (timestamp(input.actionGrant, 'expires_at') <= Date.now())
231
+ throw new XKOVAWorkforceBffError('session_unavailable');
232
+ }
233
+ const bound = XKOVAClient.forWorkforceSession(this.options, {
234
+ workforceKey: this.options.workforceKey,
235
+ sessionToken: session.material.session_token,
236
+ browserOrigin: input.browserOrigin,
237
+ ...(input.actionGrant && { humanAuthorization: input.actionGrant.grant_token }),
238
+ });
239
+ return {
240
+ session,
241
+ resources: {
242
+ treasury: bound.treasury,
243
+ tokenization: bound.tokenization,
244
+ wallets: new WorkforceWalletsResource(bound),
245
+ },
246
+ };
247
+ }
248
+ /** Review the locator returned by human_authorization_required. Never send it to a third-party host. */
249
+ async inspectAction(input) {
250
+ opaque(input.authorizationToken);
251
+ const raw = object(await this.getJson(`/workforce-auth/actions/${encodeURIComponent(input.authorizationToken)}`, input.sessionToken, input.browserOrigin));
252
+ text(raw, 'operation');
253
+ text(raw, 'proof_method');
254
+ object(raw.review);
255
+ validateReview(raw);
256
+ return raw;
257
+ }
258
+ /** Starts a real provider ceremony after the user reviews the exact request. No action is executed. */
259
+ async beginActionProof(input) {
260
+ opaque(input.authorizationToken);
261
+ const raw = object(await this.postJson(`/workforce-auth/actions/${encodeURIComponent(input.authorizationToken)}/begin`, undefined, undefined, input));
262
+ if (text(raw, 'nonce') !== input.authorizationToken)
263
+ throw new XKOVAWorkforceBffError('binding_mismatch');
264
+ secureUrl(text(raw, 'issuer'));
265
+ secureUrl(text(raw, 'authorization_endpoint'));
266
+ text(raw, 'audience');
267
+ const maxAge = raw.max_auth_age_seconds;
268
+ if (!Number.isSafeInteger(maxAge) || Number(maxAge) < 1 || Number(maxAge) > 300)
269
+ throw new Error('Invalid workforce action policy');
270
+ strings(raw.required_acr_values);
271
+ strings(raw.required_amr_values);
272
+ object(raw.review);
273
+ validateReview(raw);
274
+ return raw;
275
+ }
276
+ /** Submit the real, freshly verified provider token. A lost response is never automatically retried. */
277
+ async completeActionProof(input) {
278
+ opaque(input.authorizationToken);
279
+ if (input.nonce !== input.authorizationToken)
280
+ throw new XKOVAWorkforceBffError('binding_mismatch');
281
+ if (!input.proofToken || input.proofToken.length > 16384)
282
+ throw new TypeError('Invalid workforce action proof');
283
+ const raw = object(await this.postJson(`/workforce-auth/actions/${encodeURIComponent(input.authorizationToken)}/complete`, { nonce: input.nonce, proof_token: input.proofToken }, undefined, input));
284
+ opaque(text(raw, 'grant_token'));
285
+ if (timestamp(raw, 'expires_at') <= Date.now())
286
+ throw new XKOVAWorkforceBffError('session_unavailable');
287
+ return raw;
288
+ }
289
+ async logoutSession(input) {
290
+ opaque(input.reference);
291
+ await input.sessionStore.runExclusive(input.reference, async () => {
292
+ const stored = await input.sessionStore.get(input.reference);
293
+ if (!stored)
294
+ return;
295
+ this.assertSession(stored);
296
+ if (!stored.logoutPending)
297
+ await input.sessionStore.replace(input.reference, { ...stored, logoutPending: true });
298
+ await this.postJson('/workforce-auth/sign-out', {
299
+ session_token: stored.material.session_token,
300
+ });
301
+ await input.sessionStore.delete(input.reference);
302
+ });
303
+ }
304
+ assertSession(session) {
305
+ if (session.clientFingerprint !== this.fingerprint)
306
+ throw new XKOVAWorkforceBffError('binding_mismatch');
307
+ }
308
+ postJson(path, body, idempotencyKey, context) {
309
+ const client = context
310
+ ? XKOVAClient.forWorkforceSession(this.options, {
311
+ ...context,
312
+ workforceKey: this.options.workforceKey,
313
+ })
314
+ : this.client;
315
+ return client.requestJson({
316
+ method: 'POST',
317
+ path,
318
+ ...(body !== undefined && { body }),
319
+ headers: { 'X-XKOVA-Workforce-Key': this.options.workforceKey },
320
+ ...(idempotencyKey !== undefined && { idempotencyKey }),
321
+ });
322
+ }
323
+ getJson(path, sessionToken, browserOrigin) {
324
+ const client = browserOrigin
325
+ ? XKOVAClient.forWorkforceSession(this.options, {
326
+ sessionToken,
327
+ browserOrigin,
328
+ workforceKey: this.options.workforceKey,
329
+ })
330
+ : this.client;
331
+ return client.requestJson({
332
+ method: 'GET',
333
+ path,
334
+ headers: {
335
+ 'X-XKOVA-Workforce-Key': this.options.workforceKey,
336
+ 'X-Workforce-Session': sessionToken,
337
+ },
338
+ });
339
+ }
340
+ }
341
+ /** Advisory browser UI data only; provider identity, credentials and grants stay confidential. */
342
+ export function projectXKOVAWorkforceSession(value) {
343
+ return {
344
+ signedIn: true,
345
+ staffId: value.material.session.staff_id,
346
+ roles: [...value.material.session.roles],
347
+ expiresAt: value.material.session.expires_at,
348
+ };
349
+ }
350
+ function parseMaterial(value) {
351
+ const raw = object(value);
352
+ const sessionToken = text(raw, 'session_token'), refreshToken = text(raw, 'refresh_token'), attempt = text(raw, 'refresh_attempt_token');
353
+ credential(sessionToken, 'xwfs');
354
+ credential(refreshToken, 'xwfr');
355
+ credential(attempt, 'xwfa');
356
+ return {
357
+ session_token: sessionToken,
358
+ refresh_token: refreshToken,
359
+ refresh_attempt_token: attempt,
360
+ session: parseSession(raw.session),
361
+ };
362
+ }
363
+ function parseSession(value) {
364
+ const raw = object(value), grants = object(raw.resource_grants);
365
+ for (const name of ['session_id', 'application_id', 'person_id', 'tenant_id', 'workspace_id']) {
366
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text(raw, name))) {
367
+ throw new Error('Invalid workforce session identity');
368
+ }
369
+ }
370
+ if (!/^stf_[0-9A-Za-z]{22}$/.test(text(raw, 'staff_id')))
371
+ throw new Error('Invalid workforce staff identity');
372
+ const expires = timestamp(raw, 'expires_at'), absolute = timestamp(raw, 'absolute_expires_at');
373
+ if (expires > absolute || typeof raw.action_authorization_enabled !== 'boolean')
374
+ throw new Error('Invalid workforce session policy');
375
+ text(raw, 'provider_namespace');
376
+ text(raw, 'provider_subject');
377
+ strings(raw.roles);
378
+ strings(raw.scopes);
379
+ strings(grants.treasury_wallet_ids);
380
+ strings(grants.customer_token_ids);
381
+ if (!strings(grants.treasury_wallet_ids).every((id) => /^vlt_[0-9A-Za-z]{22}$/.test(id)) ||
382
+ !strings(grants.customer_token_ids).every((id) => /^ctk_[0-9A-Za-z]{22}$/.test(id))) {
383
+ throw new Error('Invalid workforce resource identity');
384
+ }
385
+ return raw;
386
+ }
387
+ function assertSameHuman(before, after) {
388
+ for (const name of [
389
+ 'session_id',
390
+ 'application_id',
391
+ 'staff_id',
392
+ 'person_id',
393
+ 'tenant_id',
394
+ 'workspace_id',
395
+ 'provider_namespace',
396
+ 'provider_subject',
397
+ ]) {
398
+ if (before[name] !== after[name])
399
+ throw new XKOVAWorkforceBffError('binding_mismatch');
400
+ }
401
+ }
402
+ function random() {
403
+ return randomBytes(32).toString('base64url');
404
+ }
405
+ function digest(value) {
406
+ return createHash('sha256').update(value).digest('base64url');
407
+ }
408
+ function opaque(value) {
409
+ if (!/^[A-Za-z0-9_-]{43}$/.test(value))
410
+ throw new TypeError('Invalid opaque workforce reference');
411
+ }
412
+ function credential(value, prefix) {
413
+ if (!new RegExp(`^${prefix}_[A-Za-z0-9_-]{43}$`).test(value))
414
+ throw new TypeError('Invalid workforce credential');
415
+ }
416
+ function equal(a, b) {
417
+ const left = Buffer.from(a), right = Buffer.from(b);
418
+ return left.length === right.length && timingSafeEqual(left, right);
419
+ }
420
+ function secureUrl(value) {
421
+ const url = new URL(value);
422
+ if (!isAllowedProtocol(url) || url.username || url.password || url.hash)
423
+ throw new TypeError('Invalid workforce URL');
424
+ return url;
425
+ }
426
+ function exactCallback(value) {
427
+ const url = secureUrl(value);
428
+ if (url.search)
429
+ throw new TypeError('Workforce callback cannot contain a query');
430
+ return url;
431
+ }
432
+ function object(value) {
433
+ if (!value || typeof value !== 'object' || Array.isArray(value))
434
+ throw new Error('Invalid workforce API response');
435
+ return value;
436
+ }
437
+ function text(value, key) {
438
+ const field = value[key];
439
+ if (typeof field !== 'string' || !field || field.length > 65536)
440
+ throw new Error('Invalid workforce API response field');
441
+ return field;
442
+ }
443
+ function strings(value) {
444
+ if (!Array.isArray(value) ||
445
+ value.length > 1000 ||
446
+ !value.every((item) => typeof item === 'string' && item.length > 0 && item.length <= 512)) {
447
+ throw new Error('Invalid workforce API response list');
448
+ }
449
+ return value;
450
+ }
451
+ function timestamp(value, key) {
452
+ const result = Date.parse(text(value, key));
453
+ if (!Number.isFinite(result))
454
+ throw new Error('Invalid workforce API expiry');
455
+ return result;
456
+ }
457
+ function validateReview(raw) {
458
+ if (!/^[a-f0-9]{64}$/i.test(text(raw, 'review_digest')) ||
459
+ timestamp(raw, 'expires_at') <= Date.now()) {
460
+ throw new XKOVAWorkforceBffError('session_unavailable');
461
+ }
462
+ }
@@ -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);