dataspace-client-sdk-node 0.1.1 → 0.2.0

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 (37) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +44 -1
  3. package/dist/client.d.ts +153 -33
  4. package/dist/client.js +619 -93
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.js +1 -0
  7. package/dist/types.d.ts +112 -1
  8. package/dist/vp-token.d.ts +37 -0
  9. package/dist/vp-token.js +56 -0
  10. package/docs/API.md +19 -4
  11. package/docs/BACKEND_NODE_INTEGRATION.md +249 -0
  12. package/docs/CONTROLLER_FLOW_STEP_BY_STEP.md +283 -0
  13. package/docs/DATA_MODEL_ALIGNMENT.md +37 -13
  14. package/docs/DEVELOPER_USE_CASES.md +10 -2
  15. package/docs/E2E_LOCAL_GW_UC5.md +49 -0
  16. package/docs/ENDPOINT_ID_CATALOG.md +90 -0
  17. package/docs/LEGAL_ORGANIZATION_FLOW_STEP_BY_STEP.md +84 -0
  18. package/docs/PERSONAL_FLOW_STEP_BY_STEP.md +70 -0
  19. package/docs/PORTAL_BACKEND_INTEGRATION_HANDOVER.md +343 -0
  20. package/docs/PRACTITIONER_FLOW_STEP_BY_STEP.md +182 -0
  21. package/docs/REACT_WEB_INTEGRATION.md +72 -0
  22. package/examples/e2e-bootstrap-tenant.mjs +3 -2
  23. package/examples/e2e-individual-flow.mjs +13 -8
  24. package/examples/host-activate-and-employee.mjs +3 -2
  25. package/examples/smoke-legal-org-local.mjs +40 -0
  26. package/package.json +4 -3
  27. package/src/client.ts +784 -132
  28. package/src/index.ts +1 -0
  29. package/src/types.ts +123 -1
  30. package/src/vp-token.ts +91 -0
  31. package/tests/client.test.mjs +491 -0
  32. package/tests/fixtures/ica-vp-minimal.json +67 -0
  33. package/tests/helpers/vp-token-fixture.mjs +23 -0
  34. package/tests/live-gw-uc5.e2e.test.mjs +108 -0
  35. package/SDK_PARITY_MAP.md +0 -120
  36. package/TODO_PROMPT_NEXT_STEPS.md +0 -185
  37. package/artifacts/update-smart-wallet.js +0 -1016
package/dist/client.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { createDidcommPlainMessage } from './builders.js';
3
3
  import { buildConsentClaimsSimpleWithCid } from 'gdc-common-utils-ts/utils/consent';
4
+ import { generateServiceId } from 'gdc-common-utils-ts/utils/did';
5
+ import { submitDidcomm } from 'gdc-common-utils-ts/utils/didcomm-submit';
6
+ import { ClaimsOfferSchemaorg, ClaimsPersonSchemaorg } from 'gdc-common-utils-ts/constants/schemaorg';
4
7
  import { MedicationStatementClaimsFhirApi, MedicationStatementClaimsFhirApiExtended, } from 'gdc-common-utils-ts/models/interoperable-claims/medication-statement-claims';
5
8
  function trimTrailingSlash(value) {
6
9
  return value.replace(/\/+$/, '');
@@ -8,21 +11,144 @@ function trimTrailingSlash(value) {
8
11
  function encode(value) {
9
12
  return encodeURIComponent(value);
10
13
  }
14
+ function toDidWebFromUrlOrHost(raw) {
15
+ const v = String(raw || '').trim();
16
+ if (!v)
17
+ return undefined;
18
+ if (v.startsWith('did:web:'))
19
+ return v;
20
+ const host = v
21
+ .replace(/^https?:\/\//i, '')
22
+ .replace(/\/.*$/, '')
23
+ .trim()
24
+ .toLowerCase();
25
+ if (!host)
26
+ return undefined;
27
+ return `did:web:${host}`;
28
+ }
29
+ function parseRetryAfterMs(header) {
30
+ if (!header)
31
+ return undefined;
32
+ const raw = header.trim();
33
+ if (!raw)
34
+ return undefined;
35
+ const seconds = Number(raw);
36
+ if (Number.isFinite(seconds) && seconds >= 0) {
37
+ return Math.floor(seconds * 1000);
38
+ }
39
+ const epochMs = Date.parse(raw);
40
+ if (Number.isFinite(epochMs)) {
41
+ const delta = epochMs - Date.now();
42
+ return delta > 0 ? delta : 0;
43
+ }
44
+ return undefined;
45
+ }
11
46
  export class DataspaceNodeClient {
12
47
  baseUrl;
13
48
  bearerToken;
14
49
  defaultHeaders;
15
50
  wallet;
51
+ defaultCtx;
52
+ defaultTimeoutMs;
53
+ defaultIntervalMs;
16
54
  _tokenCache = new Map();
17
55
  constructor(options) {
18
56
  this.baseUrl = trimTrailingSlash(options.baseUrl);
19
57
  this.bearerToken = options.bearerToken;
20
58
  this.defaultHeaders = options.defaultHeaders ?? {};
21
59
  this.wallet = options.wallet;
60
+ this.defaultCtx = options.ctx;
22
61
  }
23
62
  getWallet() {
24
63
  return this.wallet;
25
64
  }
65
+ /**
66
+ * Set default route context for subsequent calls.
67
+ */
68
+ setContext(ctx) {
69
+ this.defaultCtx = { ...ctx };
70
+ return this;
71
+ }
72
+ /**
73
+ * Preferred alias for organization/tenant integration context.
74
+ */
75
+ setContextOrg(ctx) {
76
+ return this.setContext(ctx);
77
+ }
78
+ setTenantId(tenantId) {
79
+ const current = this.defaultCtx ?? { tenantId: '', jurisdiction: '', sector: '' };
80
+ this.defaultCtx = { ...current, tenantId };
81
+ return this;
82
+ }
83
+ setJurisdiction(jurisdiction) {
84
+ const current = this.defaultCtx ?? { tenantId: '', jurisdiction: '', sector: '' };
85
+ this.defaultCtx = { ...current, jurisdiction };
86
+ return this;
87
+ }
88
+ setSector(sector) {
89
+ const current = this.defaultCtx ?? { tenantId: '', jurisdiction: '', sector: '' };
90
+ this.defaultCtx = { ...current, sector };
91
+ return this;
92
+ }
93
+ setDefaultTimeoutSeconds(seconds) {
94
+ if (Number.isFinite(Number(seconds))) {
95
+ this.defaultTimeoutMs = Math.max(1, Math.floor(Number(seconds) * 1000));
96
+ }
97
+ return this;
98
+ }
99
+ setDefaultIntervalSeconds(seconds) {
100
+ if (Number.isFinite(Number(seconds))) {
101
+ this.defaultIntervalMs = Math.max(1, Math.floor(Number(seconds) * 1000));
102
+ }
103
+ return this;
104
+ }
105
+ /**
106
+ * Builds a deterministic endpoint id for token cache and auth/session reuse.
107
+ * If `providerDid` is provided, returns a full DID service id:
108
+ * did:web:...#section:format:resourceType:action
109
+ * Otherwise returns the canonical fragment without '#':
110
+ * section:format:resourceType:action
111
+ */
112
+ getEndpointId(selector, providerDid) {
113
+ const fragment = generateServiceId(selector); // #section:format:resourceType:action
114
+ if (providerDid)
115
+ return `${providerDid}${fragment}`;
116
+ return fragment.replace(/^#/, '');
117
+ }
118
+ resolveSimplePollOptions(timeoutSeconds, intervalSeconds) {
119
+ const pollOptions = {};
120
+ if (Number.isFinite(Number(timeoutSeconds))) {
121
+ pollOptions.timeoutMs = Math.max(1, Math.floor(Number(timeoutSeconds) * 1000));
122
+ }
123
+ else if (this.defaultTimeoutMs) {
124
+ pollOptions.timeoutMs = this.defaultTimeoutMs;
125
+ }
126
+ if (Number.isFinite(Number(intervalSeconds))) {
127
+ pollOptions.intervalMs = Math.max(1, Math.floor(Number(intervalSeconds) * 1000));
128
+ }
129
+ else if (this.defaultIntervalMs) {
130
+ pollOptions.intervalMs = this.defaultIntervalMs;
131
+ }
132
+ return Object.keys(pollOptions).length ? pollOptions : undefined;
133
+ }
134
+ requireRouteContext(ctx) {
135
+ const resolved = ctx ?? this.defaultCtx;
136
+ const tenantId = String(resolved?.tenantId || '').trim();
137
+ const jurisdiction = String(resolved?.jurisdiction || '').trim();
138
+ const sector = String(resolved?.sector || '').trim();
139
+ if (!tenantId || !jurisdiction || !sector) {
140
+ throw new Error('Route context is required. Provide `ctx` in method call or constructor options.');
141
+ }
142
+ return { tenantId, jurisdiction, sector };
143
+ }
144
+ requireHostRouteContext(ctx) {
145
+ const jurisdiction = String(ctx?.jurisdiction || this.defaultCtx?.jurisdiction || '').trim();
146
+ const sector = String(ctx?.sector || this.defaultCtx?.sector || '').trim();
147
+ if (jurisdiction && sector) {
148
+ return { jurisdiction, sector };
149
+ }
150
+ throw new Error('Host route context is required. Provide `ctx` in method call or constructor options.ctx.');
151
+ }
26
152
  // ---- Path helpers -------------------------------------------------------
27
153
  /**
28
154
  * Generic GW v1 tenant route builder.
@@ -36,7 +162,8 @@ export class DataspaceNodeClient {
36
162
  * // → /acme/cds-ES/v1/health-care/individual/org.schema/Organization/_batch
37
163
  */
38
164
  v1Path(ctx, section, format, resourceType, action) {
39
- return `/${encode(ctx.tenantId)}/cds-${encode(ctx.jurisdiction)}/v1/${encode(ctx.sector)}/${encode(section)}/${encode(format)}/${encode(resourceType)}/${encode(action)}`;
165
+ const routeCtx = this.requireRouteContext(ctx);
166
+ return `/${encode(routeCtx.tenantId)}/cds-${encode(routeCtx.jurisdiction)}/v1/${encode(routeCtx.sector)}/${encode(section)}/${encode(format)}/${encode(resourceType)}/${encode(action)}`;
40
167
  }
41
168
  /**
42
169
  * Generic tenant-scoped identity route builder.
@@ -46,7 +173,8 @@ export class DataspaceNodeClient {
46
173
  * Dedicated path methods in this SDK use `host` (GW convention).
47
174
  */
48
175
  tenantIdentityPath(ctx, prefix, action) {
49
- return `/${encode(prefix)}/cds-${encode(ctx.jurisdiction)}/v1/${encode(ctx.sector)}/${encode(ctx.tenantId)}/identity/auth/${encode(action)}`;
176
+ const routeCtx = this.requireRouteContext(ctx);
177
+ return `/${encode(prefix)}/cds-${encode(routeCtx.jurisdiction)}/v1/${encode(routeCtx.sector)}/${encode(routeCtx.tenantId)}/identity/auth/${encode(action)}`;
50
178
  }
51
179
  /**
52
180
  * Generic host registry route builder (tenant-agnostic, `host/` prefix).
@@ -55,7 +183,8 @@ export class DataspaceNodeClient {
55
183
  * Pattern: `/host/cds-{jurisdiction}/v1/{sector}/registry/org.schema/{resourceType}/{action}`
56
184
  */
57
185
  hostRegistryPath(ctx, resourceType, action) {
58
- return `/host/cds-${encode(ctx.jurisdiction)}/v1/${encode(ctx.sector)}/registry/org.schema/${encode(resourceType)}/${encode(action)}`;
186
+ const hostCtx = this.requireHostRouteContext(ctx);
187
+ return `/host/cds-${encode(hostCtx.jurisdiction)}/v1/${encode(hostCtx.sector)}/registry/org.schema/${encode(resourceType)}/${encode(action)}`;
59
188
  }
60
189
  /** Submit path: host registry Organization batch (controller-level org registration). */
61
190
  hostRegistryOrganizationBatchPath(ctx) {
@@ -86,11 +215,13 @@ export class DataspaceNodeClient {
86
215
  * Use for `family-registration/_create-or-resume` DIDComm payloads.
87
216
  */
88
217
  individualFamilyOrganizationBatchPath(ctx) {
89
- return `/${encode(ctx.tenantId)}/cds-${encode(ctx.jurisdiction)}/v1/${encode(ctx.sector)}/individual/org.schema/Organization/_batch`;
218
+ const routeCtx = this.requireRouteContext(ctx);
219
+ return `/${encode(routeCtx.tenantId)}/cds-${encode(routeCtx.jurisdiction)}/v1/${encode(routeCtx.sector)}/individual/org.schema/Organization/_batch`;
90
220
  }
91
221
  /** Poll path: individual/family Organization. Pair with `individualFamilyOrganizationBatchPath`. */
92
222
  individualFamilyOrganizationPollPath(ctx) {
93
- return `/${encode(ctx.tenantId)}/cds-${encode(ctx.jurisdiction)}/v1/${encode(ctx.sector)}/individual/org.schema/Organization/_batch-response`;
223
+ const routeCtx = this.requireRouteContext(ctx);
224
+ return `/${encode(routeCtx.tenantId)}/cds-${encode(routeCtx.jurisdiction)}/v1/${encode(routeCtx.sector)}/individual/org.schema/Organization/_batch-response`;
94
225
  }
95
226
  /** Submit path: individual/family Organization search (`org.schema/Organization/_search`). */
96
227
  individualFamilyOrganizationSearchPath(ctx) {
@@ -102,11 +233,13 @@ export class DataspaceNodeClient {
102
233
  }
103
234
  /** Submit path: individual/family Order batch (`org.schema/Order/_batch`). */
104
235
  individualFamilyOrderBatchPath(ctx) {
105
- return `/${encode(ctx.tenantId)}/cds-${encode(ctx.jurisdiction)}/v1/${encode(ctx.sector)}/individual/org.schema/Order/_batch`;
236
+ const routeCtx = this.requireRouteContext(ctx);
237
+ return `/${encode(routeCtx.tenantId)}/cds-${encode(routeCtx.jurisdiction)}/v1/${encode(routeCtx.sector)}/individual/org.schema/Order/_batch`;
106
238
  }
107
239
  /** Poll path: individual/family Order. Pair with `individualFamilyOrderBatchPath`. */
108
240
  individualFamilyOrderPollPath(ctx) {
109
- return `/${encode(ctx.tenantId)}/cds-${encode(ctx.jurisdiction)}/v1/${encode(ctx.sector)}/individual/org.schema/Order/_batch-response`;
241
+ const routeCtx = this.requireRouteContext(ctx);
242
+ return `/${encode(routeCtx.tenantId)}/cds-${encode(routeCtx.jurisdiction)}/v1/${encode(routeCtx.sector)}/individual/org.schema/Order/_batch-response`;
110
243
  }
111
244
  /** Submit path: individual RelatedPerson (FHIR R4 API, `org.hl7.fhir.api/RelatedPerson/_batch`). */
112
245
  individualRelatedPersonBatchPath(ctx) {
@@ -269,10 +402,11 @@ export class DataspaceNodeClient {
269
402
  * Results are cached in memory; re-runs automatically on expiry.
270
403
  */
271
404
  async authenticateBackendPkceAndExchange(options) {
272
- const { ctx, apiKey, scopes, endpointId = `pkce:${apiKey.slice(0, 8)}`, codeVerifier = randomUUID(), pollOptions, } = options;
273
- const cached = this._tokenCache.get(endpointId);
405
+ const { ctx, apiKey, scopes, tokenCacheKey = `pkce:${apiKey.slice(0, 8)}`, endpointId, codeVerifier = randomUUID(), pollOptions, } = options;
406
+ const cacheKey = String(tokenCacheKey || endpointId || '').trim() || `pkce:${apiKey.slice(0, 8)}`;
407
+ const cached = this._tokenCache.get(cacheKey);
274
408
  if (cached && cached.expiresAt > Date.now() + 30_000) {
275
- return { status: 'cached', endpointId, accessToken: cached.accessToken, tokenType: cached.tokenType, scopes: cached.scopes };
409
+ return { status: 'cached', tokenCacheKey: cacheKey, endpointId: cacheKey, accessToken: cached.accessToken, tokenType: cached.tokenType, scopes: cached.scopes };
276
410
  }
277
411
  const controllerPublicJwk = await this.resolveControllerPublicJwk(options);
278
412
  // Step 1: DCR – bind API key to service public key
@@ -285,7 +419,7 @@ export class DataspaceNodeClient {
285
419
  await this.submitBatch(this.identityDeviceDcrPath(ctx), dcrPayload);
286
420
  const dcrPoll = await this.pollUntilComplete(this.identityDeviceDcrPollPath(ctx), { thid: String(dcrPayload['thid']) }, pollOptions);
287
421
  if (dcrPoll.status !== 200) {
288
- return { status: 'failed', step: '_dcr', endpointId, accessToken: '', tokenType: 'Bearer', scopes };
422
+ return { status: 'failed', step: '_dcr', tokenCacheKey: cacheKey, endpointId: cacheKey, accessToken: '', tokenType: 'Bearer', scopes };
289
423
  }
290
424
  // Step 2: Code – PKCE S256 challenge
291
425
  const codeChallenge = this._pkceS256Challenge(codeVerifier);
@@ -301,7 +435,7 @@ export class DataspaceNodeClient {
301
435
  const codeBody = codePoll.body ?? {};
302
436
  const code = String(codeBody['code'] ?? '').trim();
303
437
  if (codePoll.status !== 200 || !code) {
304
- return { status: 'failed', step: '_code', endpointId, accessToken: '', tokenType: 'Bearer', scopes };
438
+ return { status: 'failed', step: '_code', tokenCacheKey: cacheKey, endpointId: cacheKey, accessToken: '', tokenType: 'Bearer', scopes };
305
439
  }
306
440
  // Step 3: Token – exchange code + verifier for id_token
307
441
  const tokenPayload = this._buildAuthDIDCommRequest({
@@ -316,7 +450,7 @@ export class DataspaceNodeClient {
316
450
  const tokenBody = tokenPoll.body ?? {};
317
451
  const idToken = String(tokenBody['id_token'] ?? '').trim();
318
452
  if (tokenPoll.status !== 200 || !idToken) {
319
- return { status: 'failed', step: '_token', endpointId, accessToken: '', tokenType: 'Bearer', scopes };
453
+ return { status: 'failed', step: '_token', tokenCacheKey: cacheKey, endpointId: cacheKey, accessToken: '', tokenType: 'Bearer', scopes };
320
454
  }
321
455
  // Step 4: Exchange – id_token → SMART bearer
322
456
  const exchangeThid = `exchange-${randomUUID()}`;
@@ -334,26 +468,26 @@ export class DataspaceNodeClient {
334
468
  const exchangeBody = exchangePoll.body ?? {};
335
469
  const accessToken = String(exchangeBody['access_token'] ?? '').trim();
336
470
  if (exchangePoll.status !== 200 || !accessToken) {
337
- return { status: 'failed', step: '_exchange', endpointId, accessToken: '', tokenType: 'Bearer', scopes };
471
+ return { status: 'failed', step: '_exchange', tokenCacheKey: cacheKey, endpointId: cacheKey, accessToken: '', tokenType: 'Bearer', scopes };
338
472
  }
339
473
  const tokenType = String(exchangeBody['token_type'] ?? 'Bearer');
340
474
  const grantedScope = String(exchangeBody['scope'] ?? '').trim();
341
475
  const grantedScopes = grantedScope ? grantedScope.split(' ').filter(Boolean) : scopes;
342
476
  const expiresIn = Number(exchangeBody['expires_in'] ?? 0);
343
- this._tokenCache.set(endpointId, {
477
+ this._tokenCache.set(cacheKey, {
344
478
  accessToken,
345
479
  tokenType,
346
480
  scopes: grantedScopes,
347
481
  expiresAt: Date.now() + expiresIn * 1000,
348
482
  });
349
- return { status: 'fetched', endpointId, accessToken, tokenType, scopes: grantedScopes };
483
+ return { status: 'fetched', tokenCacheKey: cacheKey, endpointId: cacheKey, accessToken, tokenType, scopes: grantedScopes };
350
484
  }
351
485
  /**
352
486
  * Returns the cached SMART bearer for the given endpointId if still valid (>30s remaining).
353
487
  * Returns `undefined` if not cached or expired.
354
488
  */
355
- getCachedBearerToken(endpointId) {
356
- const cached = this._tokenCache.get(endpointId);
489
+ getCachedBearerToken(tokenCacheKey) {
490
+ const cached = this._tokenCache.get(tokenCacheKey);
357
491
  if (cached && cached.expiresAt > Date.now() + 30_000) {
358
492
  return cached.accessToken;
359
493
  }
@@ -363,13 +497,15 @@ export class DataspaceNodeClient {
363
497
  * smart-backend.v1: obtain an OAuth2 backend token using client_credentials + private_key_jwt.
364
498
  */
365
499
  async authenticateBackendSmartStandard(options) {
366
- const { clientId, scopes, endpointId = `smart-backend:${clientId}`, tokenUrl, tokenPath = '/token', audience, assertionTtlSeconds = 300, additionalTokenFields, } = options;
367
- const cached = this._tokenCache.get(endpointId);
500
+ const { clientId, scopes, tokenCacheKey = `smart-backend:${clientId}`, endpointId, tokenUrl, tokenPath = '/token', audience, assertionTtlSeconds = 300, additionalTokenFields, } = options;
501
+ const cacheKey = String(tokenCacheKey || endpointId || '').trim() || `smart-backend:${clientId}`;
502
+ const cached = this._tokenCache.get(cacheKey);
368
503
  if (cached && cached.expiresAt > Date.now() + 30_000) {
369
504
  return {
370
505
  status: 'cached',
371
506
  profile: 'smart-backend.v1',
372
- endpointId,
507
+ tokenCacheKey: cacheKey,
508
+ endpointId: cacheKey,
373
509
  accessToken: cached.accessToken,
374
510
  tokenType: cached.tokenType,
375
511
  scopes: cached.scopes,
@@ -400,7 +536,8 @@ export class DataspaceNodeClient {
400
536
  return {
401
537
  status: 'failed',
402
538
  profile: 'smart-backend.v1',
403
- endpointId,
539
+ tokenCacheKey: cacheKey,
540
+ endpointId: cacheKey,
404
541
  statusCode: response.status,
405
542
  response,
406
543
  };
@@ -410,7 +547,7 @@ export class DataspaceNodeClient {
410
547
  const grantedScopes = grantedScope ? grantedScope.split(' ').filter(Boolean) : scopes;
411
548
  const expiresIn = Number(body.expires_in ?? 0);
412
549
  const expiresAt = Date.now() + expiresIn * 1000;
413
- this._tokenCache.set(endpointId, {
550
+ this._tokenCache.set(cacheKey, {
414
551
  accessToken,
415
552
  tokenType,
416
553
  scopes: grantedScopes,
@@ -419,7 +556,8 @@ export class DataspaceNodeClient {
419
556
  return {
420
557
  status: 'fetched',
421
558
  profile: 'smart-backend.v1',
422
- endpointId,
559
+ tokenCacheKey: cacheKey,
560
+ endpointId: cacheKey,
423
561
  statusCode: response.status,
424
562
  accessToken,
425
563
  tokenType,
@@ -432,12 +570,12 @@ export class DataspaceNodeClient {
432
570
  * Exchange token payload against gateway token endpoint and cache the result.
433
571
  */
434
572
  async requestSmartToken(input) {
435
- const endpointId = String(input.endpointId || '').trim();
436
- if (!endpointId) {
437
- throw new Error('requestSmartToken requires endpointId.');
573
+ const tokenCacheKey = String(input.tokenCacheKey || input.endpointId || '').trim();
574
+ if (!tokenCacheKey) {
575
+ throw new Error('requestSmartToken requires tokenCacheKey.');
438
576
  }
439
577
  const normalizedScopes = Array.from(new Set((input.scopes || []).filter(Boolean))).sort();
440
- const cached = this._tokenCache.get(endpointId);
578
+ const cached = this._tokenCache.get(tokenCacheKey);
441
579
  if (cached && cached.expiresAt > Date.now() + 30_000) {
442
580
  return {
443
581
  status: 'cached',
@@ -462,7 +600,7 @@ export class DataspaceNodeClient {
462
600
  : String(body.scope ?? '').trim().split(' ').filter(Boolean);
463
601
  const resolvedScopes = grantedScopes.length ? grantedScopes : normalizedScopes;
464
602
  const expiresIn = Number(body.expires_in ?? 0);
465
- this._tokenCache.set(endpointId, {
603
+ this._tokenCache.set(tokenCacheKey, {
466
604
  accessToken,
467
605
  tokenType,
468
606
  scopes: resolvedScopes,
@@ -477,6 +615,58 @@ export class DataspaceNodeClient {
477
615
  response,
478
616
  };
479
617
  }
618
+ /**
619
+ * Friendly wrapper for SMART token request via GW identity/auth token-exchange route.
620
+ * Uses one object, seconds-based polling, and constructor ctx fallback.
621
+ */
622
+ async requestSmartTokenSimple(input) {
623
+ const routeCtx = this.requireRouteContext(input.tenantId && input.jurisdiction && input.sector
624
+ ? { tenantId: input.tenantId, jurisdiction: input.jurisdiction, sector: input.sector }
625
+ : undefined);
626
+ const normalizedScopes = Array.from(new Set((input.scopes || []).filter(Boolean))).sort();
627
+ const tokenCacheKey = String(input.tokenCacheKey || input.endpointId || `smart:${routeCtx.tenantId}:${normalizedScopes.join(',')}`).trim();
628
+ if (!tokenCacheKey) {
629
+ throw new Error('requestSmartTokenSimple requires tokenCacheKey (or non-empty scopes).');
630
+ }
631
+ const pollOptions = this.resolveSimplePollOptions(input.timeoutSeconds, input.intervalSeconds);
632
+ const payload = {
633
+ thid: `exchange-${randomUUID()}`,
634
+ grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
635
+ subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
636
+ subject_token: input.idToken,
637
+ scope: normalizedScopes.join(' '),
638
+ organization: routeCtx.tenantId,
639
+ ...(input.additionalClaims || {}),
640
+ };
641
+ const exchange = await this.submitAndPoll(this.identityTokenExchangePath(routeCtx), this.identityTokenExchangePollPath(routeCtx), payload, pollOptions);
642
+ const exchangeBody = exchange.poll.body ?? {};
643
+ const accessToken = String(exchangeBody.access_token || '').trim();
644
+ if (exchange.poll.status >= 400 || !accessToken) {
645
+ return {
646
+ status: 'failed',
647
+ statusCode: exchange.poll.status,
648
+ response: exchange.poll.body,
649
+ };
650
+ }
651
+ const tokenType = String(exchangeBody.token_type || 'Bearer');
652
+ const grantedScopes = String(exchangeBody.scope || '').trim().split(' ').filter(Boolean);
653
+ const resolvedScopes = grantedScopes.length ? grantedScopes : normalizedScopes;
654
+ const expiresIn = Number(exchangeBody.expires_in ?? 0);
655
+ this._tokenCache.set(tokenCacheKey, {
656
+ accessToken,
657
+ tokenType,
658
+ scopes: resolvedScopes,
659
+ expiresAt: Date.now() + expiresIn * 1000,
660
+ });
661
+ return {
662
+ status: 'fetched',
663
+ accessToken,
664
+ tokenType,
665
+ scopes: resolvedScopes,
666
+ statusCode: exchange.poll.status,
667
+ response: exchange.poll.body,
668
+ };
669
+ }
480
670
  // ---- Private auth helpers ----------------------------------------------
481
671
  _pkceS256Challenge(verifier) {
482
672
  return createHash('sha256').update(verifier).digest().toString('base64url');
@@ -593,6 +783,25 @@ export class DataspaceNodeClient {
593
783
  }
594
784
  // ---- Generic batch API --------------------------------------------------
595
785
  /**
786
+ * POST a DIDComm bundle payload.
787
+ * This is the preferred high-level method for DIDComm submission of
788
+ * FHIR/API bundles (batch, transaction, message, etc.).
789
+ *
790
+ * Returns immediately with the HTTP response — pair with `pollUntilComplete` or use `submitAndPoll`.
791
+ */
792
+ async submitBundle(path, payload, options) {
793
+ const mode = options?.mode ?? 'plain';
794
+ if (mode === 'strict') {
795
+ if (!options?.recipientEncryptionJwk || !options?.walletContext) {
796
+ throw new Error('submitBundle strict mode requires recipientEncryptionJwk and walletContext.');
797
+ }
798
+ return this.submitBatchEncrypted(path, payload, options.recipientEncryptionJwk, options.walletContext);
799
+ }
800
+ return this.submitBatch(path, payload);
801
+ }
802
+ /**
803
+ * @deprecated Use `submitBundle` instead.
804
+ *
596
805
  * POST a DIDComm plaintext payload to a batch submit path.
597
806
  * Use this for all `_batch` routes (family registration, observations, tasks, etc.).
598
807
  * Content-Type: `application/didcomm-plaintext+json`.
@@ -600,13 +809,18 @@ export class DataspaceNodeClient {
600
809
  * Returns immediately with the HTTP response — pair with `pollUntilComplete` or use `submitAndPoll`.
601
810
  */
602
811
  async submitBatch(path, payload) {
603
- const response = await this.doPost(path, payload, 'application/didcomm-plaintext+json');
604
- const body = await this.parseResponseBody(response);
605
- return {
606
- status: response.status,
607
- location: response.headers.get('location') ?? undefined,
608
- body,
609
- };
812
+ const url = /^https?:\/\//.test(path)
813
+ ? path
814
+ : `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
815
+ const result = await submitDidcomm({
816
+ mode: 'plain',
817
+ url,
818
+ payload: payload,
819
+ defaultHeaders: this.defaultHeaders,
820
+ bearerToken: this.bearerToken,
821
+ fetcher: (requestUrl, init) => fetch(requestUrl, init),
822
+ });
823
+ return { status: result.status, location: result.location, body: result.body };
610
824
  }
611
825
  /**
612
826
  * Sign and encrypt a DIDComm payload (nested JWS-in-JWE) and POST to the given path.
@@ -623,40 +837,32 @@ export class DataspaceNodeClient {
623
837
  }
624
838
  const publicJwks = await this.wallet.getPublicJwks(walletContext);
625
839
  const signingJwk = publicJwks.find((jwk) => jwk.use === 'sig' || jwk.alg === 'ES384') ?? publicJwks[0];
626
- // Step 1: sign payload claims as compact JWS
627
- const compactJws = await this.wallet.signCompactJws(walletContext, {
628
- header: {
629
- typ: 'application/didcomm-signed+json',
630
- alg: 'ES384',
631
- ...(signingJwk?.kid ? { kid: signingJwk.kid } : {}),
632
- },
633
- claims: payload,
634
- });
635
- // Step 2: encrypt JWS string as compact JWE (RSA-OAEP-256 + A256GCM, cty: JWS)
636
- const compactJwe = await this.wallet.buildCompactJwe(walletContext, {
637
- plaintext: compactJws,
638
- recipientJwk: recipientEncryptionJwk,
639
- contentType: 'JWS',
640
- });
641
- // Step 3: POST the JWE token directly (not JSON-serialised)
642
840
  const url = /^https?:\/\//.test(path)
643
841
  ? path
644
842
  : `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
645
- const headers = {
646
- ...this.defaultHeaders,
647
- 'Content-Type': 'application/didcomm-encrypted+json',
648
- Accept: 'application/json, application/didcomm-plaintext+json, */*',
649
- };
650
- if (this.bearerToken) {
651
- headers.Authorization = `Bearer ${this.bearerToken}`;
652
- }
653
- const response = await fetch(url, { method: 'POST', headers, body: compactJwe });
654
- const body = await this.parseResponseBody(response);
655
- return {
656
- status: response.status,
657
- location: response.headers.get('location') ?? undefined,
658
- body,
659
- };
843
+ const result = await submitDidcomm({
844
+ mode: 'strict',
845
+ url,
846
+ payload,
847
+ defaultHeaders: this.defaultHeaders,
848
+ bearerToken: this.bearerToken,
849
+ recipientEncryptionJwk,
850
+ signCompactJws: async (claims) => this.wallet.signCompactJws(walletContext, {
851
+ header: {
852
+ typ: 'application/didcomm-signed+json',
853
+ alg: 'ES384',
854
+ ...(signingJwk?.kid ? { kid: signingJwk.kid } : {}),
855
+ },
856
+ claims,
857
+ }),
858
+ encryptCompactJwe: async (compactJws, recipientJwk) => this.wallet.buildCompactJwe(walletContext, {
859
+ plaintext: compactJws,
860
+ recipientJwk: recipientJwk,
861
+ contentType: 'JWS',
862
+ }),
863
+ fetcher: (requestUrl, init) => fetch(requestUrl, init),
864
+ });
865
+ return { status: result.status, location: result.location, body: result.body };
660
866
  }
661
867
  /**
662
868
  * POST a plain JSON payload.
@@ -672,6 +878,13 @@ export class DataspaceNodeClient {
672
878
  body,
673
879
  };
674
880
  }
881
+ /**
882
+ * Legacy JSON submit for non-bundle payloads (openid/token/resource JSON bodies).
883
+ * Keeps JSON flows explicit and semantically separated from DIDComm bundle flows.
884
+ */
885
+ async submitLegacyJson(path, payload) {
886
+ return this.postJson(path, payload);
887
+ }
675
888
  /**
676
889
  * POST a multipart/form-data payload.
677
890
  * Use for file upload endpoints. Prefer `uploadConversionFile` for DataConversion uploads.
@@ -729,6 +942,7 @@ export class DataspaceNodeClient {
729
942
  return {
730
943
  status: response.status,
731
944
  body: await this.parseResponseBody(response),
945
+ retryAfterMs: parseRetryAfterMs(response.headers.get('retry-after')),
732
946
  };
733
947
  }
734
948
  /**
@@ -766,6 +980,7 @@ export class DataspaceNodeClient {
766
980
  * (appointment, medication schedule, or another event), mapped to `based-on-display`.
767
981
  */
768
982
  async createPhoneReminderTasks(ctx, input, options) {
983
+ const routeCtx = this.requireRouteContext(ctx);
769
984
  const windows = Array.isArray(input.windows) ? input.windows : [];
770
985
  if (!windows.length) {
771
986
  throw new Error('createPhoneReminderTasks requires at least one reminder window.');
@@ -785,9 +1000,9 @@ export class DataspaceNodeClient {
785
1000
  throw new Error('createPhoneReminderTasks requires remindAt in every window.');
786
1001
  }
787
1002
  const taskIdSeed = [
788
- ctx.tenantId,
789
- ctx.jurisdiction,
790
- ctx.sector,
1003
+ routeCtx.tenantId,
1004
+ routeCtx.jurisdiction,
1005
+ routeCtx.sector,
791
1006
  input.subjectRef,
792
1007
  input.ownerRef,
793
1008
  input.focusRef,
@@ -832,12 +1047,12 @@ export class DataspaceNodeClient {
832
1047
  };
833
1048
  });
834
1049
  const payload = createDidcommPlainMessage({
835
- iss: ctx.tenantId,
836
- aud: ctx.tenantId,
1050
+ iss: routeCtx.tenantId,
1051
+ aud: routeCtx.tenantId,
837
1052
  thid,
838
1053
  body: { data },
839
1054
  });
840
- return this.submitAndPoll(this.individualTaskBatchPath(ctx), this.individualTaskPollPath(ctx), payload, options ?? { timeoutMs: 20_000, intervalMs: 1_000 });
1055
+ return this.submitAndPoll(this.individualTaskBatchPath(routeCtx), this.individualTaskPollPath(routeCtx), payload, options ?? { timeoutMs: 20_000, intervalMs: 1_000 });
841
1056
  }
842
1057
  /** Endpoint path for medication overlap pre-check (planned GW contract). */
843
1058
  individualMedicationOverlapCheckPath(ctx) {
@@ -934,11 +1149,12 @@ export class DataspaceNodeClient {
934
1149
  * Returns `null` when no matching registration exists.
935
1150
  */
936
1151
  async searchFamilyOrganization(ctx, filters, options) {
1152
+ const routeCtx = this.requireRouteContext(ctx);
937
1153
  const thid = `search-${randomUUID()}`;
938
1154
  const claims = {
939
1155
  'org.schema.Organization.owner.telephone': filters.controllerPhone,
940
1156
  'org.schema.Organization.alternateName': filters.usualname,
941
- 'org.schema.Service.category': ctx.sector,
1157
+ 'org.schema.Service.category': routeCtx.sector,
942
1158
  };
943
1159
  if (filters.birthDate) {
944
1160
  claims['org.schema.Organization.foundingDate'] = filters.birthDate;
@@ -946,8 +1162,8 @@ export class DataspaceNodeClient {
946
1162
  const payload = {
947
1163
  jti: randomUUID(),
948
1164
  thid,
949
- iss: ctx.tenantId,
950
- aud: ctx.tenantId,
1165
+ iss: routeCtx.tenantId,
1166
+ aud: routeCtx.tenantId,
951
1167
  type: 'application/api+json',
952
1168
  body: {
953
1169
  data: [{
@@ -957,7 +1173,7 @@ export class DataspaceNodeClient {
957
1173
  }],
958
1174
  },
959
1175
  };
960
- const result = await this.submitAndPoll(this.individualFamilyOrganizationSearchPath(ctx), this.individualFamilyOrganizationSearchPollPath(ctx), payload, options ?? { timeoutMs: 20_000, intervalMs: 1_000 });
1176
+ const result = await this.submitAndPoll(this.individualFamilyOrganizationSearchPath(routeCtx), this.individualFamilyOrganizationSearchPollPath(routeCtx), payload, options ?? { timeoutMs: 20_000, intervalMs: 1_000 });
961
1177
  if (result.poll.status !== 200)
962
1178
  return null;
963
1179
  const entry = result.poll.body?.body?.data?.[0];
@@ -992,6 +1208,11 @@ export class DataspaceNodeClient {
992
1208
  vp_token: input.vpToken,
993
1209
  ...(input.additionalClaims || {}),
994
1210
  };
1211
+ const requestedMembers = Number.isFinite(Number(input.numberOfMembers))
1212
+ ? Math.max(1, Math.floor(Number(input.numberOfMembers)))
1213
+ : 2;
1214
+ // Keep gateway-facing claim stable while exposing a generic SDK input.
1215
+ claims['org.schema.Organization.numberOfEmployees'] = requestedMembers;
995
1216
  if (input.organizationVc)
996
1217
  claims['org.schema.OrganizationCredential.jwt'] = input.organizationVc;
997
1218
  if (input.legalRepresentativeVc) {
@@ -1003,6 +1224,10 @@ export class DataspaceNodeClient {
1003
1224
  iss: 'did:web:controller.example.com',
1004
1225
  aud: 'did:web:host.example.com',
1005
1226
  body: {
1227
+ // GW activation parser expects proof material at top-level DIDComm body.
1228
+ vp_token: input.vpToken,
1229
+ ...(input.organizationVc ? { organizationCredential: input.organizationVc } : {}),
1230
+ ...(input.legalRepresentativeVc ? { representativeCredential: input.legalRepresentativeVc } : {}),
1006
1231
  data: [
1007
1232
  {
1008
1233
  type: 'Organization-activation-request-v1.0',
@@ -1014,6 +1239,180 @@ export class DataspaceNodeClient {
1014
1239
  });
1015
1240
  return this.submitAndPoll(this.hostRegistryOrganizationActivatePath(ctx), this.hostRegistryOrganizationActivatePollPath(ctx), payload, options);
1016
1241
  }
1242
+ /**
1243
+ * Friendly wrapper for legal organization activation.
1244
+ * Accepts one object and seconds-based polling options for integrator ergonomics.
1245
+ */
1246
+ async activateOrganizationInGatewaySimple(input) {
1247
+ const serviceProviderDidWeb = String(input.serviceProviderDidWeb || '').trim();
1248
+ const serviceProviderUrl = String(input.serviceProviderUrl || '').trim();
1249
+ const controllerEmail = String(input.controllerEmail || '').trim();
1250
+ const controllerTelephone = String(input.controllerTelephone || '').trim();
1251
+ const controllerRole = String(input.controllerRole || '').trim();
1252
+ const resolvedServiceDid = toDidWebFromUrlOrHost(serviceProviderDidWeb || serviceProviderUrl);
1253
+ if (!resolvedServiceDid) {
1254
+ throw new Error('activateOrganizationInGatewaySimple requires serviceProviderDidWeb or serviceProviderUrl.');
1255
+ }
1256
+ if (!controllerEmail && !controllerTelephone) {
1257
+ throw new Error('activateOrganizationInGatewaySimple requires controllerEmail or controllerTelephone.');
1258
+ }
1259
+ if (!controllerRole) {
1260
+ throw new Error('activateOrganizationInGatewaySimple requires controllerRole.');
1261
+ }
1262
+ const pollOptions = this.resolveSimplePollOptions(input.timeoutSeconds, input.intervalSeconds);
1263
+ const hostCtx = this.requireHostRouteContext(input.jurisdiction && input.sector
1264
+ ? { jurisdiction: input.jurisdiction, sector: input.sector }
1265
+ : undefined);
1266
+ const implicitClaims = {
1267
+ 'org.schema.Service.category': hostCtx.sector,
1268
+ 'org.schema.Service.identifier': resolvedServiceDid,
1269
+ ...(serviceProviderUrl ? { 'org.schema.Service.url': serviceProviderUrl } : {}),
1270
+ [ClaimsPersonSchemaorg.hasOccupation]: controllerRole,
1271
+ ...(controllerEmail ? { [ClaimsPersonSchemaorg.email]: controllerEmail } : {}),
1272
+ ...(controllerTelephone ? { [ClaimsPersonSchemaorg.telephone]: controllerTelephone } : {}),
1273
+ };
1274
+ const activation = await this.activateOrganizationInGatewayFromIcaProof(hostCtx, {
1275
+ vpToken: input.vpToken,
1276
+ numberOfMembers: input.numberOfMembers,
1277
+ organizationVc: input.organizationVc,
1278
+ legalRepresentativeVc: input.legalRepresentativeVc,
1279
+ regulatoryEvidence: input.regulatoryEvidence,
1280
+ additionalClaims: { ...implicitClaims, ...(input.additionalClaims || {}) },
1281
+ }, pollOptions);
1282
+ this.assertFirstDidcommEntrySuccess(activation, 'activateOrganizationInGatewaySimple');
1283
+ return activation;
1284
+ }
1285
+ /**
1286
+ * Friendly wrapper for legal organization Order confirmation.
1287
+ * Accepts one object and builds payload/paths internally.
1288
+ */
1289
+ async confirmLegalOrganizationOrderSimple(input) {
1290
+ if (!String(input.offerId || '').trim()) {
1291
+ throw new Error('confirmLegalOrganizationOrderSimple requires offerId.');
1292
+ }
1293
+ const pollOptions = this.resolveSimplePollOptions(input.timeoutSeconds, input.intervalSeconds);
1294
+ const hostCtx = this.requireHostRouteContext(input.jurisdiction && input.sector
1295
+ ? { jurisdiction: input.jurisdiction, sector: input.sector }
1296
+ : undefined);
1297
+ const claims = {
1298
+ '@context': 'org.schema',
1299
+ 'Order.acceptedOffer.identifier': input.offerId,
1300
+ ...(input.additionalClaims || {}),
1301
+ };
1302
+ const payload = createDidcommPlainMessage({
1303
+ iss: 'did:web:controller.example.com',
1304
+ aud: 'did:web:host.example.com',
1305
+ thid: `order-${randomUUID()}`,
1306
+ body: {
1307
+ data: [{
1308
+ type: input.dataType || 'Organization-order-request-v1.0',
1309
+ meta: { claims }, // legacy compatibility
1310
+ resource: { meta: { claims } },
1311
+ }],
1312
+ },
1313
+ });
1314
+ const order = await this.submitAndPoll(this.hostRegistryOrderBatchPath(hostCtx), this.hostRegistryOrderPollPath(hostCtx), payload, pollOptions);
1315
+ this.assertFirstDidcommEntrySuccess(order, 'confirmLegalOrganizationOrderSimple');
1316
+ return order;
1317
+ }
1318
+ /**
1319
+ * Normalize GW async response into DIDComm message body.
1320
+ *
1321
+ * Transport note:
1322
+ * - GW poll responses are HTTP JSON envelopes
1323
+ * - business payload lives inside DIDComm `body`
1324
+ *
1325
+ * This helper abstracts envelope differences so consumers do not depend on
1326
+ * raw `poll.body.body` paths.
1327
+ */
1328
+ getDidcommMessageBodyFromResponse(result) {
1329
+ const pollBody = result?.poll?.body ?? result?.body ?? result;
1330
+ const didcommBody = pollBody?.body;
1331
+ if (didcommBody && typeof didcommBody === 'object')
1332
+ return didcommBody;
1333
+ if (pollBody && typeof pollBody === 'object' && Array.isArray(pollBody?.data)) {
1334
+ return pollBody;
1335
+ }
1336
+ return undefined;
1337
+ }
1338
+ /**
1339
+ * Return first DIDComm business entry from a submit/poll result.
1340
+ */
1341
+ getFirstDidcommDataEntryFromResponse(result) {
1342
+ const body = this.getDidcommMessageBodyFromResponse(result);
1343
+ const entry = body?.data?.[0];
1344
+ return entry && typeof entry === 'object' ? entry : undefined;
1345
+ }
1346
+ /**
1347
+ * Extract `org.schema.Offer.identifier` from a submit/poll result.
1348
+ *
1349
+ * This helper normalizes canonical and legacy claim locations.
1350
+ */
1351
+ getOfferIdFromResponse(result) {
1352
+ const entry = this.getFirstDidcommDataEntryFromResponse(result);
1353
+ const offerId = String(entry?.meta?.claims?.[ClaimsOfferSchemaorg.identifier]
1354
+ || entry?.resource?.meta?.claims?.[ClaimsOfferSchemaorg.identifier]
1355
+ || '').trim();
1356
+ return offerId || undefined;
1357
+ }
1358
+ /**
1359
+ * Extract a UI-ready Offer preview from activation/registration responses.
1360
+ */
1361
+ getOfferPreviewFromResponse(result) {
1362
+ const entry = this.getFirstDidcommDataEntryFromResponse(result);
1363
+ const claims = entry?.meta?.claims || entry?.resource?.meta?.claims || {};
1364
+ const seatsRaw = claims[ClaimsOfferSchemaorg.eligibleQuantityValue];
1365
+ const seats = typeof seatsRaw === 'number'
1366
+ ? seatsRaw
1367
+ : (typeof seatsRaw === 'string' && seatsRaw.trim() ? Number(seatsRaw) : undefined);
1368
+ return {
1369
+ offerId: this.getOfferIdFromResponse(result),
1370
+ amount: claims[ClaimsOfferSchemaorg.price],
1371
+ currency: claims[ClaimsOfferSchemaorg.priceCurrency],
1372
+ seats: Number.isFinite(seats) ? seats : undefined,
1373
+ planName: claims[ClaimsOfferSchemaorg.itemOfferedName],
1374
+ sku: claims[ClaimsOfferSchemaorg.itemOfferedSku],
1375
+ paymentMethod: claims[ClaimsOfferSchemaorg.acceptedPaymentMethod],
1376
+ checkoutUrl: claims[ClaimsOfferSchemaorg.checkoutPageURLTemplate],
1377
+ };
1378
+ }
1379
+ /**
1380
+ * Alias of `getOfferPreviewFromResponse` with business naming.
1381
+ */
1382
+ getOfferInfoFromResponse(result) {
1383
+ return this.getOfferPreviewFromResponse(result);
1384
+ }
1385
+ /**
1386
+ * Extract activation code from response payload or claims.
1387
+ * Supports common response shapes used in onboarding and license issuance flows.
1388
+ */
1389
+ getActivationCodeFromResponse(result) {
1390
+ const root = result?.poll?.body || result?.body || {};
1391
+ const byBody = String(root?.activationCode || root?.body?.activationCode || '').trim();
1392
+ if (byBody)
1393
+ return byBody;
1394
+ const entry = this.getFirstDidcommDataEntryFromResponse(result);
1395
+ const claims = entry?.meta?.claims || entry?.resource?.meta?.claims || {};
1396
+ const byClaims = String(claims['org.schema.IndividualProduct.serialNumber']
1397
+ || claims['org.schema.Offer.serialNumber']
1398
+ || claims['activationCode']
1399
+ || '').trim();
1400
+ return byClaims || undefined;
1401
+ }
1402
+ /**
1403
+ * Throws when first DIDComm entry contains a business-level error status.
1404
+ */
1405
+ assertFirstDidcommEntrySuccess(result, contextLabel) {
1406
+ const entry = this.getFirstDidcommDataEntryFromResponse(result);
1407
+ const responseStatusRaw = entry?.response?.status;
1408
+ const responseStatus = Number(responseStatusRaw);
1409
+ if (!Number.isFinite(responseStatus) || responseStatus < 400)
1410
+ return;
1411
+ const diagnostics = String(entry?.response?.outcome?.issue?.[0]?.diagnostics
1412
+ || entry?.response?.outcome?.issue?.[0]?.details?.text
1413
+ || '').trim();
1414
+ throw new Error(`${contextLabel} failed (business status=${responseStatus})${diagnostics ? `: ${diagnostics}` : ''}`);
1415
+ }
1017
1416
  /**
1018
1417
  * Activate employee/member device by activation code exchange + DCR registration.
1019
1418
  *
@@ -1054,13 +1453,30 @@ export class DataspaceNodeClient {
1054
1453
  dcr,
1055
1454
  };
1056
1455
  }
1456
+ /**
1457
+ * Friendly wrapper for employee/member device activation.
1458
+ * Uses one object, seconds-based polling, and constructor ctx fallback.
1459
+ */
1460
+ async activateEmployeeDeviceWithActivationCodeSimple(input) {
1461
+ const routeCtx = this.requireRouteContext(input.tenantId && input.jurisdiction && input.sector
1462
+ ? { tenantId: input.tenantId, jurisdiction: input.jurisdiction, sector: input.sector }
1463
+ : undefined);
1464
+ const pollOptions = this.resolveSimplePollOptions(input.timeoutSeconds, input.intervalSeconds);
1465
+ return this.activateEmployeeDeviceWithActivationCode(routeCtx, {
1466
+ activationCode: input.activationCode,
1467
+ idToken: input.idToken,
1468
+ dcrPayload: input.dcrPayload,
1469
+ pollOptions,
1470
+ });
1471
+ }
1057
1472
  /**
1058
1473
  * UC 5.3 wrapper: create organization employee in entity Employee batch route.
1059
1474
  */
1060
1475
  async createOrganizationEmployee(ctx, input, options) {
1476
+ const routeCtx = this.requireRouteContext(ctx);
1061
1477
  const payload = createDidcommPlainMessage({
1062
- iss: ctx.tenantId,
1063
- aud: ctx.tenantId,
1478
+ iss: routeCtx.tenantId,
1479
+ aud: routeCtx.tenantId,
1064
1480
  thid: `employee-${randomUUID()}`,
1065
1481
  body: {
1066
1482
  data: [
@@ -1072,7 +1488,7 @@ export class DataspaceNodeClient {
1072
1488
  ],
1073
1489
  },
1074
1490
  });
1075
- return this.submitAndPoll(this.employeeBatchPath(ctx), this.employeePollPath(ctx), payload, options);
1491
+ return this.submitAndPoll(this.employeeBatchPath(routeCtx), this.employeePollPath(routeCtx), payload, options);
1076
1492
  }
1077
1493
  /**
1078
1494
  * UC 5.1 wrapper: bootstrap subject organization context via registration + optional order confirmation.
@@ -1093,20 +1509,120 @@ export class DataspaceNodeClient {
1093
1509
  const confirmation = await this.submitAndPoll(this.individualFamilyOrderBatchPath(ctx), this.individualFamilyOrderPollPath(ctx), confirmationPayload, input.pollOptions);
1094
1510
  return { registration, confirmation };
1095
1511
  }
1512
+ /**
1513
+ * Friendly wrapper (recommended step 1): register individual organization and return Offer.
1514
+ */
1515
+ async startIndividualOrganizationSimple(input) {
1516
+ const routeCtx = this.requireRouteContext(input.tenantId && input.jurisdiction && input.sector
1517
+ ? { tenantId: input.tenantId, jurisdiction: input.jurisdiction, sector: input.sector }
1518
+ : undefined);
1519
+ const alternateName = String(input.alternateName || '').trim();
1520
+ if (!alternateName) {
1521
+ throw new Error('bootstrapIndividualOrganizationSimple requires alternateName.');
1522
+ }
1523
+ const controllerEmail = String(input.controllerEmail || '').trim();
1524
+ const controllerTelephone = String(input.controllerTelephone || '').trim();
1525
+ if (!controllerEmail && !controllerTelephone) {
1526
+ throw new Error('bootstrapIndividualOrganizationSimple requires controllerEmail or controllerTelephone.');
1527
+ }
1528
+ const controllerRole = String(input.controllerRole || 'org.hl7.v3.RoleCode|RESPRSN').trim();
1529
+ const claims = {
1530
+ '@context': 'org.schema',
1531
+ 'org.schema.Organization.alternateName': alternateName,
1532
+ 'org.schema.Service.category': routeCtx.sector,
1533
+ [ClaimsPersonSchemaorg.hasOccupation]: controllerRole,
1534
+ ...(controllerEmail ? { [ClaimsPersonSchemaorg.email]: controllerEmail } : {}),
1535
+ ...(controllerTelephone ? { [ClaimsPersonSchemaorg.telephone]: controllerTelephone } : {}),
1536
+ ...(input.additionalClaims || {}),
1537
+ };
1538
+ const pollOptions = this.resolveSimplePollOptions(input.timeoutSeconds, input.intervalSeconds);
1539
+ const registrationPayload = createDidcommPlainMessage({
1540
+ iss: routeCtx.tenantId,
1541
+ aud: routeCtx.tenantId,
1542
+ thid: `family-org-${randomUUID()}`,
1543
+ body: {
1544
+ data: [{
1545
+ type: 'SubjectOrg-registration-form-v1.0',
1546
+ meta: { claims },
1547
+ resource: { meta: { claims } },
1548
+ }],
1549
+ },
1550
+ });
1551
+ const registration = await this.submitAndPoll(this.individualFamilyOrganizationBatchPath(routeCtx), this.individualFamilyOrganizationPollPath(routeCtx), registrationPayload, pollOptions);
1552
+ this.assertFirstDidcommEntrySuccess(registration, 'startIndividualOrganizationSimple.registration');
1553
+ const offerId = this.getOfferIdFromResponse(registration);
1554
+ if (!offerId) {
1555
+ throw new Error('startIndividualOrganizationSimple failed: missing offerId in registration response.');
1556
+ }
1557
+ return { registration, offerId, offerPreview: this.getOfferPreviewFromResponse(registration) };
1558
+ }
1559
+ /**
1560
+ * Friendly wrapper (recommended step 2): confirm individual/family order from accepted offerId.
1561
+ */
1562
+ async confirmIndividualOrganizationOrderSimple(input) {
1563
+ const routeCtx = this.requireRouteContext(input.tenantId && input.jurisdiction && input.sector
1564
+ ? { tenantId: input.tenantId, jurisdiction: input.jurisdiction, sector: input.sector }
1565
+ : undefined);
1566
+ const offerId = String(input.offerId || '').trim();
1567
+ if (!offerId) {
1568
+ throw new Error('confirmIndividualOrganizationOrderSimple requires offerId.');
1569
+ }
1570
+ const pollOptions = this.resolveSimplePollOptions(input.timeoutSeconds, input.intervalSeconds);
1571
+ const orderClaims = {
1572
+ '@context': 'org.schema',
1573
+ 'Order.acceptedOffer.identifier': offerId,
1574
+ };
1575
+ const confirmationPayload = createDidcommPlainMessage({
1576
+ iss: routeCtx.tenantId,
1577
+ aud: routeCtx.tenantId,
1578
+ thid: `family-order-${randomUUID()}`,
1579
+ body: {
1580
+ data: [{
1581
+ type: 'Family-order-request-v1.0',
1582
+ meta: { claims: orderClaims },
1583
+ resource: { meta: { claims: orderClaims } },
1584
+ }],
1585
+ },
1586
+ });
1587
+ const confirmation = await this.submitAndPoll(this.individualFamilyOrderBatchPath(routeCtx), this.individualFamilyOrderPollPath(routeCtx), confirmationPayload, pollOptions);
1588
+ this.assertFirstDidcommEntrySuccess(confirmation, 'confirmIndividualOrganizationOrderSimple');
1589
+ return confirmation;
1590
+ }
1591
+ /**
1592
+ * Friendly wrapper (provisional): register + auto-confirm individual order.
1593
+ * Prefer `startIndividualOrganizationSimple` + `confirmIndividualOrganizationOrderSimple`.
1594
+ */
1595
+ async bootstrapIndividualOrganizationSimple(input) {
1596
+ const started = await this.startIndividualOrganizationSimple(input);
1597
+ const confirmation = await this.confirmIndividualOrganizationOrderSimple({
1598
+ tenantId: input.tenantId,
1599
+ jurisdiction: input.jurisdiction,
1600
+ sector: input.sector,
1601
+ offerId: started.offerId,
1602
+ timeoutSeconds: input.timeoutSeconds,
1603
+ intervalSeconds: input.intervalSeconds,
1604
+ });
1605
+ return {
1606
+ registration: started.registration,
1607
+ offerId: started.offerId,
1608
+ confirmation,
1609
+ };
1610
+ }
1096
1611
  /**
1097
1612
  * UC 5.5 wrapper: import IPS/FHIR composition and update subject index context.
1098
1613
  */
1099
1614
  async importIpsOrFhirAndUpdateIndex(ctx, input) {
1615
+ const routeCtx = this.requireRouteContext(ctx);
1100
1616
  const payload = {
1101
1617
  thid: input.compositionPayload.thid || `composition-${randomUUID()}`,
1102
1618
  ...input.compositionPayload,
1103
1619
  };
1104
1620
  const submitPath = (input.format || 'r4') === 'api'
1105
- ? this.individualCompositionR4BatchPath(ctx).replace('/org.hl7.fhir.r4/', '/org.hl7.fhir.api/')
1106
- : this.individualCompositionR4BatchPath(ctx);
1621
+ ? this.individualCompositionR4BatchPath(routeCtx).replace('/org.hl7.fhir.r4/', '/org.hl7.fhir.api/')
1622
+ : this.individualCompositionR4BatchPath(routeCtx);
1107
1623
  const pollPath = (input.format || 'r4') === 'api'
1108
- ? this.individualCompositionR4PollPath(ctx).replace('/org.hl7.fhir.r4/', '/org.hl7.fhir.api/')
1109
- : this.individualCompositionR4PollPath(ctx);
1624
+ ? this.individualCompositionR4PollPath(routeCtx).replace('/org.hl7.fhir.r4/', '/org.hl7.fhir.api/')
1625
+ : this.individualCompositionR4PollPath(routeCtx);
1110
1626
  return this.submitAndPoll(submitPath, pollPath, payload, input.pollOptions);
1111
1627
  }
1112
1628
  /**
@@ -1114,6 +1630,7 @@ export class DataspaceNodeClient {
1114
1630
  * Builds canonical Consent claims and submits/polls the Consent batch.
1115
1631
  */
1116
1632
  async grantProfessionalAccessSimple(ctx, input) {
1633
+ const routeCtx = this.requireRouteContext(ctx);
1117
1634
  const built = buildConsentClaimsSimpleWithCid({
1118
1635
  subjectDid: input.subjectDid,
1119
1636
  subjectPhone: input.subjectPhone,
@@ -1143,7 +1660,7 @@ export class DataspaceNodeClient {
1143
1660
  ],
1144
1661
  },
1145
1662
  };
1146
- const consent = await this.submitAndPoll(this.individualConsentR4BatchPath(ctx), this.individualConsentR4PollPath(ctx), consentPayload, input.pollOptions);
1663
+ const consent = await this.submitAndPoll(this.individualConsentR4BatchPath(routeCtx), this.individualConsentR4PollPath(routeCtx), consentPayload, input.pollOptions);
1147
1664
  return {
1148
1665
  thid,
1149
1666
  consent,
@@ -1157,16 +1674,17 @@ export class DataspaceNodeClient {
1157
1674
  * UC 5.7 wrapper: generate digital twin composition from subject data.
1158
1675
  */
1159
1676
  async generateDigitalTwinFromSubjectData(ctx, input) {
1677
+ const routeCtx = this.requireRouteContext(ctx);
1160
1678
  const payload = {
1161
1679
  thid: input.compositionPayload.thid || `digital-twin-${randomUUID()}`,
1162
1680
  ...input.compositionPayload,
1163
1681
  };
1164
1682
  const submitPath = (input.format || 'r4') === 'api'
1165
- ? this.digitalTwinCompositionApiBatchPath(ctx)
1166
- : this.digitalTwinCompositionR4BatchPath(ctx);
1683
+ ? this.digitalTwinCompositionApiBatchPath(routeCtx)
1684
+ : this.digitalTwinCompositionR4BatchPath(routeCtx);
1167
1685
  const pollPath = (input.format || 'r4') === 'api'
1168
- ? this.digitalTwinCompositionApiPollPath(ctx)
1169
- : this.digitalTwinCompositionR4PollPath(ctx);
1686
+ ? this.digitalTwinCompositionApiPollPath(routeCtx)
1687
+ : this.digitalTwinCompositionR4PollPath(routeCtx);
1170
1688
  return this.submitAndPoll(submitPath, pollPath, payload, input.pollOptions);
1171
1689
  }
1172
1690
  /**
@@ -1196,7 +1714,8 @@ export class DataspaceNodeClient {
1196
1714
  if (Date.now() - startedAt > timeoutMs) {
1197
1715
  throw new Error(`Polling timeout after ${attempts} attempts (${timeoutMs}ms).`);
1198
1716
  }
1199
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
1717
+ const waitMs = options?.intervalMs ?? result.retryAfterMs ?? intervalMs;
1718
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
1200
1719
  }
1201
1720
  }
1202
1721
  // ---- Internal HTTP helpers ---------------------------------------------
@@ -1220,10 +1739,17 @@ export class DataspaceNodeClient {
1220
1739
  }
1221
1740
  async parseResponseBody(response) {
1222
1741
  const contentType = response.headers.get('content-type') || '';
1742
+ const raw = await response.text();
1743
+ if (!raw)
1744
+ return {};
1223
1745
  if (contentType.includes('application/json') || contentType.includes('application/didcomm-plaintext+json')) {
1224
- return response.json().catch(() => ({}));
1746
+ try {
1747
+ return JSON.parse(raw);
1748
+ }
1749
+ catch {
1750
+ return {};
1751
+ }
1225
1752
  }
1226
- const text = await response.text();
1227
- return text;
1753
+ return raw;
1228
1754
  }
1229
1755
  }