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/src/client.ts 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, type DidcommFetchInit } from 'gdc-common-utils-ts/utils/didcomm-submit';
6
+ import { ClaimsOfferSchemaorg, ClaimsPersonSchemaorg } from 'gdc-common-utils-ts/constants/schemaorg';
4
7
  import {
5
8
  MedicationStatementClaimsFhirApi,
6
9
  MedicationStatementClaimsFhirApiExtended,
@@ -17,10 +20,13 @@ import type {
17
20
  GrantProfessionalAccessSimpleResult,
18
21
  DigitalTwinGenerationInput,
19
22
  EmployeeDeviceActivationInput,
23
+ EndpointSelector,
24
+ EmployeeDeviceActivationSimpleInput,
20
25
  EmployeeDeviceActivationResult,
21
26
  FamilyOrganizationSummary,
22
27
  FamilyRegistrationStatus,
23
28
  GatewayOrganizationActivationInput,
29
+ GatewayOrganizationActivationSimpleInput,
24
30
  HostRouteContext,
25
31
  IpsOrFhirImportInput,
26
32
  MedicationOverlapCheckInput,
@@ -28,11 +34,19 @@ import type {
28
34
  OrganizationEmployeeCreationInput,
29
35
  PollOptions,
30
36
  PollResult,
37
+ OfferPreview,
38
+ OfferInfo,
31
39
  RouteContext,
32
40
  SmartTokenExchangeInput,
33
41
  SmartTokenExchangeResult,
42
+ SmartTokenRequestSimpleInput,
43
+ LegalOrganizationOrderSimpleInput,
34
44
  SubjectOrganizationBootstrapInput,
35
45
  SubjectOrganizationBootstrapResult,
46
+ IndividualOrganizationBootstrapSimpleInput,
47
+ IndividualOrganizationBootstrapSimpleResult,
48
+ IndividualOrganizationStartSimpleResult,
49
+ IndividualOrganizationConfirmOrderSimpleInput,
36
50
  SubmitAndPollResult,
37
51
  SubmitResponse,
38
52
  V1Action,
@@ -49,6 +63,35 @@ function encode(value: string): string {
49
63
  return encodeURIComponent(value);
50
64
  }
51
65
 
66
+ function toDidWebFromUrlOrHost(raw: string): string | undefined {
67
+ const v = String(raw || '').trim();
68
+ if (!v) return undefined;
69
+ if (v.startsWith('did:web:')) return v;
70
+ const host = v
71
+ .replace(/^https?:\/\//i, '')
72
+ .replace(/\/.*$/, '')
73
+ .trim()
74
+ .toLowerCase();
75
+ if (!host) return undefined;
76
+ return `did:web:${host}`;
77
+ }
78
+
79
+ function parseRetryAfterMs(header: string | null): number | undefined {
80
+ if (!header) return undefined;
81
+ const raw = header.trim();
82
+ if (!raw) return undefined;
83
+ const seconds = Number(raw);
84
+ if (Number.isFinite(seconds) && seconds >= 0) {
85
+ return Math.floor(seconds * 1000);
86
+ }
87
+ const epochMs = Date.parse(raw);
88
+ if (Number.isFinite(epochMs)) {
89
+ const delta = epochMs - Date.now();
90
+ return delta > 0 ? delta : 0;
91
+ }
92
+ return undefined;
93
+ }
94
+
52
95
  type CachedToken = {
53
96
  accessToken: string;
54
97
  tokenType: string;
@@ -61,6 +104,9 @@ export class DataspaceNodeClient {
61
104
  private readonly bearerToken?: string;
62
105
  private readonly defaultHeaders: Record<string, string>;
63
106
  private readonly wallet?: WalletProvider;
107
+ private defaultCtx?: RouteContext;
108
+ private defaultTimeoutMs?: number;
109
+ private defaultIntervalMs?: number;
64
110
  private readonly _tokenCache = new Map<string, CachedToken>();
65
111
 
66
112
  constructor(options: ClientOptions) {
@@ -68,12 +114,108 @@ export class DataspaceNodeClient {
68
114
  this.bearerToken = options.bearerToken;
69
115
  this.defaultHeaders = options.defaultHeaders ?? {};
70
116
  this.wallet = options.wallet;
117
+ this.defaultCtx = options.ctx;
71
118
  }
72
119
 
73
120
  public getWallet(): WalletProvider | undefined {
74
121
  return this.wallet;
75
122
  }
76
123
 
124
+ /**
125
+ * Set default route context for subsequent calls.
126
+ */
127
+ public setContext(ctx: RouteContext): this {
128
+ this.defaultCtx = { ...ctx };
129
+ return this;
130
+ }
131
+
132
+ /**
133
+ * Preferred alias for organization/tenant integration context.
134
+ */
135
+ public setContextOrg(ctx: RouteContext): this {
136
+ return this.setContext(ctx);
137
+ }
138
+
139
+ public setTenantId(tenantId: string): this {
140
+ const current = this.defaultCtx ?? { tenantId: '', jurisdiction: '', sector: '' };
141
+ this.defaultCtx = { ...current, tenantId };
142
+ return this;
143
+ }
144
+
145
+ public setJurisdiction(jurisdiction: string): this {
146
+ const current = this.defaultCtx ?? { tenantId: '', jurisdiction: '', sector: '' };
147
+ this.defaultCtx = { ...current, jurisdiction };
148
+ return this;
149
+ }
150
+
151
+ public setSector(sector: string): this {
152
+ const current = this.defaultCtx ?? { tenantId: '', jurisdiction: '', sector: '' };
153
+ this.defaultCtx = { ...current, sector };
154
+ return this;
155
+ }
156
+
157
+ public setDefaultTimeoutSeconds(seconds: number): this {
158
+ if (Number.isFinite(Number(seconds))) {
159
+ this.defaultTimeoutMs = Math.max(1, Math.floor(Number(seconds) * 1000));
160
+ }
161
+ return this;
162
+ }
163
+
164
+ public setDefaultIntervalSeconds(seconds: number): this {
165
+ if (Number.isFinite(Number(seconds))) {
166
+ this.defaultIntervalMs = Math.max(1, Math.floor(Number(seconds) * 1000));
167
+ }
168
+ return this;
169
+ }
170
+
171
+ /**
172
+ * Builds a deterministic endpoint id for token cache and auth/session reuse.
173
+ * If `providerDid` is provided, returns a full DID service id:
174
+ * did:web:...#section:format:resourceType:action
175
+ * Otherwise returns the canonical fragment without '#':
176
+ * section:format:resourceType:action
177
+ */
178
+ public getEndpointId(selector: EndpointSelector, providerDid?: string): string {
179
+ const fragment = generateServiceId(selector); // #section:format:resourceType:action
180
+ if (providerDid) return `${providerDid}${fragment}`;
181
+ return fragment.replace(/^#/, '');
182
+ }
183
+
184
+ private resolveSimplePollOptions(timeoutSeconds?: number, intervalSeconds?: number): PollOptions | undefined {
185
+ const pollOptions: PollOptions = {};
186
+ if (Number.isFinite(Number(timeoutSeconds))) {
187
+ pollOptions.timeoutMs = Math.max(1, Math.floor(Number(timeoutSeconds) * 1000));
188
+ } else if (this.defaultTimeoutMs) {
189
+ pollOptions.timeoutMs = this.defaultTimeoutMs;
190
+ }
191
+ if (Number.isFinite(Number(intervalSeconds))) {
192
+ pollOptions.intervalMs = Math.max(1, Math.floor(Number(intervalSeconds) * 1000));
193
+ } else if (this.defaultIntervalMs) {
194
+ pollOptions.intervalMs = this.defaultIntervalMs;
195
+ }
196
+ return Object.keys(pollOptions).length ? pollOptions : undefined;
197
+ }
198
+
199
+ private requireRouteContext(ctx?: RouteContext): RouteContext {
200
+ const resolved = ctx ?? this.defaultCtx;
201
+ const tenantId = String(resolved?.tenantId || '').trim();
202
+ const jurisdiction = String(resolved?.jurisdiction || '').trim();
203
+ const sector = String(resolved?.sector || '').trim();
204
+ if (!tenantId || !jurisdiction || !sector) {
205
+ throw new Error('Route context is required. Provide `ctx` in method call or constructor options.');
206
+ }
207
+ return { tenantId, jurisdiction, sector };
208
+ }
209
+
210
+ private requireHostRouteContext(ctx?: HostRouteContext): HostRouteContext {
211
+ const jurisdiction = String(ctx?.jurisdiction || this.defaultCtx?.jurisdiction || '').trim();
212
+ const sector = String(ctx?.sector || this.defaultCtx?.sector || '').trim();
213
+ if (jurisdiction && sector) {
214
+ return { jurisdiction, sector };
215
+ }
216
+ throw new Error('Host route context is required. Provide `ctx` in method call or constructor options.ctx.');
217
+ }
218
+
77
219
  // ---- Path helpers -------------------------------------------------------
78
220
 
79
221
  /**
@@ -88,13 +230,14 @@ export class DataspaceNodeClient {
88
230
  * // → /acme/cds-ES/v1/health-care/individual/org.schema/Organization/_batch
89
231
  */
90
232
  public v1Path(
91
- ctx: RouteContext,
233
+ ctx: RouteContext | undefined,
92
234
  section: V1Section,
93
235
  format: string,
94
236
  resourceType: string,
95
237
  action: V1Action,
96
238
  ): string {
97
- return `/${encode(ctx.tenantId)}/cds-${encode(ctx.jurisdiction)}/v1/${encode(ctx.sector)}/${encode(section)}/${encode(format)}/${encode(resourceType)}/${encode(action)}`;
239
+ const routeCtx = this.requireRouteContext(ctx);
240
+ return `/${encode(routeCtx.tenantId)}/cds-${encode(routeCtx.jurisdiction)}/v1/${encode(routeCtx.sector)}/${encode(section)}/${encode(format)}/${encode(resourceType)}/${encode(action)}`;
98
241
  }
99
242
 
100
243
  /**
@@ -104,8 +247,9 @@ export class DataspaceNodeClient {
104
247
  * The `prefix` is service-specific: `host` for GW, `publisher` for DataConv, `ica` for ICA.
105
248
  * Dedicated path methods in this SDK use `host` (GW convention).
106
249
  */
107
- public tenantIdentityPath(ctx: RouteContext, prefix: string, action: string): string {
108
- return `/${encode(prefix)}/cds-${encode(ctx.jurisdiction)}/v1/${encode(ctx.sector)}/${encode(ctx.tenantId)}/identity/auth/${encode(action)}`;
250
+ public tenantIdentityPath(ctx: RouteContext | undefined, prefix: string, action: string): string {
251
+ const routeCtx = this.requireRouteContext(ctx);
252
+ return `/${encode(prefix)}/cds-${encode(routeCtx.jurisdiction)}/v1/${encode(routeCtx.sector)}/${encode(routeCtx.tenantId)}/identity/auth/${encode(action)}`;
109
253
  }
110
254
 
111
255
  /**
@@ -115,40 +259,41 @@ export class DataspaceNodeClient {
115
259
  * Pattern: `/host/cds-{jurisdiction}/v1/{sector}/registry/org.schema/{resourceType}/{action}`
116
260
  */
117
261
  public hostRegistryPath(
118
- ctx: HostRouteContext,
262
+ ctx: HostRouteContext | undefined,
119
263
  resourceType: string,
120
264
  action: V1Action,
121
265
  ): string {
122
- return `/host/cds-${encode(ctx.jurisdiction)}/v1/${encode(ctx.sector)}/registry/org.schema/${encode(resourceType)}/${encode(action)}`;
266
+ const hostCtx = this.requireHostRouteContext(ctx);
267
+ return `/host/cds-${encode(hostCtx.jurisdiction)}/v1/${encode(hostCtx.sector)}/registry/org.schema/${encode(resourceType)}/${encode(action)}`;
123
268
  }
124
269
 
125
270
  /** Submit path: host registry Organization batch (controller-level org registration). */
126
- public hostRegistryOrganizationBatchPath(ctx: HostRouteContext): string {
271
+ public hostRegistryOrganizationBatchPath(ctx?: HostRouteContext): string {
127
272
  return this.hostRegistryPath(ctx, 'Organization', '_batch');
128
273
  }
129
274
 
130
275
  /** Poll path: host registry Organization batch. Pair with `hostRegistryOrganizationBatchPath`. */
131
- public hostRegistryOrganizationPollPath(ctx: HostRouteContext): string {
276
+ public hostRegistryOrganizationPollPath(ctx?: HostRouteContext): string {
132
277
  return this.hostRegistryPath(ctx, 'Organization', '_batch-response');
133
278
  }
134
279
 
135
280
  /** Submit path: activate a tenant Organization in the GW registry using a VC from ICA. */
136
- public hostRegistryOrganizationActivatePath(ctx: HostRouteContext): string {
281
+ public hostRegistryOrganizationActivatePath(ctx?: HostRouteContext): string {
137
282
  return this.hostRegistryPath(ctx, 'Organization', '_activate');
138
283
  }
139
284
 
140
285
  /** Poll path: `_activate` response. Pair with `hostRegistryOrganizationActivatePath`. */
141
- public hostRegistryOrganizationActivatePollPath(ctx: HostRouteContext): string {
286
+ public hostRegistryOrganizationActivatePollPath(ctx?: HostRouteContext): string {
142
287
  return this.hostRegistryPath(ctx, 'Organization', '_activate-response');
143
288
  }
144
289
 
145
290
  /** Submit path: host registry Order batch (controller-level order submission). */
146
- public hostRegistryOrderBatchPath(ctx: HostRouteContext): string {
291
+ public hostRegistryOrderBatchPath(ctx?: HostRouteContext): string {
147
292
  return this.hostRegistryPath(ctx, 'Order', '_batch');
148
293
  }
149
294
 
150
295
  /** Poll path: host registry Order batch. Pair with `hostRegistryOrderBatchPath`. */
151
- public hostRegistryOrderPollPath(ctx: HostRouteContext): string {
296
+ public hostRegistryOrderPollPath(ctx?: HostRouteContext): string {
152
297
  return this.hostRegistryPath(ctx, 'Order', '_batch-response');
153
298
  }
154
299
 
@@ -156,33 +301,37 @@ export class DataspaceNodeClient {
156
301
  * Submit path: individual/family Organization onboarding (`org.schema/Organization/_batch`).
157
302
  * Use for `family-registration/_create-or-resume` DIDComm payloads.
158
303
  */
159
- public individualFamilyOrganizationBatchPath(ctx: RouteContext): string {
160
- return `/${encode(ctx.tenantId)}/cds-${encode(ctx.jurisdiction)}/v1/${encode(ctx.sector)}/individual/org.schema/Organization/_batch`;
304
+ public individualFamilyOrganizationBatchPath(ctx?: RouteContext): string {
305
+ const routeCtx = this.requireRouteContext(ctx);
306
+ return `/${encode(routeCtx.tenantId)}/cds-${encode(routeCtx.jurisdiction)}/v1/${encode(routeCtx.sector)}/individual/org.schema/Organization/_batch`;
161
307
  }
162
308
 
163
309
  /** Poll path: individual/family Organization. Pair with `individualFamilyOrganizationBatchPath`. */
164
- public individualFamilyOrganizationPollPath(ctx: RouteContext): string {
165
- return `/${encode(ctx.tenantId)}/cds-${encode(ctx.jurisdiction)}/v1/${encode(ctx.sector)}/individual/org.schema/Organization/_batch-response`;
310
+ public individualFamilyOrganizationPollPath(ctx?: RouteContext): string {
311
+ const routeCtx = this.requireRouteContext(ctx);
312
+ return `/${encode(routeCtx.tenantId)}/cds-${encode(routeCtx.jurisdiction)}/v1/${encode(routeCtx.sector)}/individual/org.schema/Organization/_batch-response`;
166
313
  }
167
314
 
168
315
  /** Submit path: individual/family Organization search (`org.schema/Organization/_search`). */
169
- public individualFamilyOrganizationSearchPath(ctx: RouteContext): string {
316
+ public individualFamilyOrganizationSearchPath(ctx?: RouteContext): string {
170
317
  return this.v1Path(ctx, 'individual', 'org.schema', 'Organization', '_search');
171
318
  }
172
319
 
173
320
  /** Poll path: individual/family Organization search. Pair with `individualFamilyOrganizationSearchPath`. */
174
- public individualFamilyOrganizationSearchPollPath(ctx: RouteContext): string {
321
+ public individualFamilyOrganizationSearchPollPath(ctx?: RouteContext): string {
175
322
  return this.v1Path(ctx, 'individual', 'org.schema', 'Organization', '_search-response');
176
323
  }
177
324
 
178
325
  /** Submit path: individual/family Order batch (`org.schema/Order/_batch`). */
179
- public individualFamilyOrderBatchPath(ctx: RouteContext): string {
180
- return `/${encode(ctx.tenantId)}/cds-${encode(ctx.jurisdiction)}/v1/${encode(ctx.sector)}/individual/org.schema/Order/_batch`;
326
+ public individualFamilyOrderBatchPath(ctx?: RouteContext): string {
327
+ const routeCtx = this.requireRouteContext(ctx);
328
+ return `/${encode(routeCtx.tenantId)}/cds-${encode(routeCtx.jurisdiction)}/v1/${encode(routeCtx.sector)}/individual/org.schema/Order/_batch`;
181
329
  }
182
330
 
183
331
  /** Poll path: individual/family Order. Pair with `individualFamilyOrderBatchPath`. */
184
- public individualFamilyOrderPollPath(ctx: RouteContext): string {
185
- return `/${encode(ctx.tenantId)}/cds-${encode(ctx.jurisdiction)}/v1/${encode(ctx.sector)}/individual/org.schema/Order/_batch-response`;
332
+ public individualFamilyOrderPollPath(ctx?: RouteContext): string {
333
+ const routeCtx = this.requireRouteContext(ctx);
334
+ return `/${encode(routeCtx.tenantId)}/cds-${encode(routeCtx.jurisdiction)}/v1/${encode(routeCtx.sector)}/individual/org.schema/Order/_batch-response`;
186
335
  }
187
336
 
188
337
  /** Submit path: individual RelatedPerson (FHIR R4 API, `org.hl7.fhir.api/RelatedPerson/_batch`). */
@@ -226,12 +375,12 @@ export class DataspaceNodeClient {
226
375
  }
227
376
 
228
377
  /** Submit path: entity Employee (`entity/org.schema/Employee/_batch`). */
229
- public employeeBatchPath(ctx: RouteContext): string {
378
+ public employeeBatchPath(ctx?: RouteContext): string {
230
379
  return this.v1Path(ctx, 'entity', 'org.schema', 'Employee', '_batch');
231
380
  }
232
381
 
233
382
  /** Poll path: entity Employee. Pair with `employeeBatchPath`. */
234
- public employeePollPath(ctx: RouteContext): string {
383
+ public employeePollPath(ctx?: RouteContext): string {
235
384
  return this.v1Path(ctx, 'entity', 'org.schema', 'Employee', '_batch-response');
236
385
  }
237
386
 
@@ -284,12 +433,12 @@ export class DataspaceNodeClient {
284
433
  * Submit path: identity DCR step — binds API key to service public JWK.
285
434
  * Used internally by `authenticateBackendPkceAndExchange` (step 1 of identity-exchange.v1).
286
435
  */
287
- public identityDeviceDcrPath(ctx: RouteContext): string {
436
+ public identityDeviceDcrPath(ctx?: RouteContext): string {
288
437
  return this.tenantIdentityPath(ctx, 'host', '_dcr');
289
438
  }
290
439
 
291
440
  /** Poll path: identity DCR. Pair with `identityDeviceDcrPath`. */
292
- public identityDeviceDcrPollPath(ctx: RouteContext): string {
441
+ public identityDeviceDcrPollPath(ctx?: RouteContext): string {
293
442
  return this.tenantIdentityPath(ctx, 'host', '_dcr-response');
294
443
  }
295
444
 
@@ -297,17 +446,17 @@ export class DataspaceNodeClient {
297
446
  * Submit path: identity token exchange — id_token → SMART bearer.
298
447
  * Used internally by `authenticateBackendPkceAndExchange` (step 4 of identity-exchange.v1).
299
448
  */
300
- public identityTokenExchangePath(ctx: RouteContext): string {
449
+ public identityTokenExchangePath(ctx?: RouteContext): string {
301
450
  return this.tenantIdentityPath(ctx, 'host', '_exchange');
302
451
  }
303
452
 
304
453
  /** Poll path: identity token exchange. Pair with `identityTokenExchangePath`. */
305
- public identityTokenExchangePollPath(ctx: RouteContext): string {
454
+ public identityTokenExchangePollPath(ctx?: RouteContext): string {
306
455
  return this.tenantIdentityPath(ctx, 'host', '_exchange-response');
307
456
  }
308
457
 
309
458
  /** Submit path: identity license issue (`identity/auth/_issue`). */
310
- public identityLicenseIssuePath(ctx: RouteContext): string {
459
+ public identityLicenseIssuePath(ctx?: RouteContext): string {
311
460
  return this.tenantIdentityPath(ctx, 'host', '_issue');
312
461
  }
313
462
 
@@ -387,14 +536,16 @@ export class DataspaceNodeClient {
387
536
  ctx,
388
537
  apiKey,
389
538
  scopes,
390
- endpointId = `pkce:${apiKey.slice(0, 8)}`,
539
+ tokenCacheKey = `pkce:${apiKey.slice(0, 8)}`,
540
+ endpointId,
391
541
  codeVerifier = randomUUID(),
392
542
  pollOptions,
393
543
  } = options;
394
544
 
395
- const cached = this._tokenCache.get(endpointId);
545
+ const cacheKey = String(tokenCacheKey || endpointId || '').trim() || `pkce:${apiKey.slice(0, 8)}`;
546
+ const cached = this._tokenCache.get(cacheKey);
396
547
  if (cached && cached.expiresAt > Date.now() + 30_000) {
397
- return { status: 'cached', endpointId, accessToken: cached.accessToken, tokenType: cached.tokenType, scopes: cached.scopes };
548
+ return { status: 'cached', tokenCacheKey: cacheKey, endpointId: cacheKey, accessToken: cached.accessToken, tokenType: cached.tokenType, scopes: cached.scopes };
398
549
  }
399
550
 
400
551
  const controllerPublicJwk = await this.resolveControllerPublicJwk(options);
@@ -413,7 +564,7 @@ export class DataspaceNodeClient {
413
564
  pollOptions,
414
565
  );
415
566
  if (dcrPoll.status !== 200) {
416
- return { status: 'failed', step: '_dcr', endpointId, accessToken: '', tokenType: 'Bearer', scopes };
567
+ return { status: 'failed', step: '_dcr', tokenCacheKey: cacheKey, endpointId: cacheKey, accessToken: '', tokenType: 'Bearer', scopes };
417
568
  }
418
569
 
419
570
  // Step 2: Code – PKCE S256 challenge
@@ -434,7 +585,7 @@ export class DataspaceNodeClient {
434
585
  const codeBody = (codePoll.body as Record<string, unknown>) ?? {};
435
586
  const code = String(codeBody['code'] ?? '').trim();
436
587
  if (codePoll.status !== 200 || !code) {
437
- return { status: 'failed', step: '_code', endpointId, accessToken: '', tokenType: 'Bearer', scopes };
588
+ return { status: 'failed', step: '_code', tokenCacheKey: cacheKey, endpointId: cacheKey, accessToken: '', tokenType: 'Bearer', scopes };
438
589
  }
439
590
 
440
591
  // Step 3: Token – exchange code + verifier for id_token
@@ -454,7 +605,7 @@ export class DataspaceNodeClient {
454
605
  const tokenBody = (tokenPoll.body as Record<string, unknown>) ?? {};
455
606
  const idToken = String(tokenBody['id_token'] ?? '').trim();
456
607
  if (tokenPoll.status !== 200 || !idToken) {
457
- return { status: 'failed', step: '_token', endpointId, accessToken: '', tokenType: 'Bearer', scopes };
608
+ return { status: 'failed', step: '_token', tokenCacheKey: cacheKey, endpointId: cacheKey, accessToken: '', tokenType: 'Bearer', scopes };
458
609
  }
459
610
 
460
611
  // Step 4: Exchange – id_token → SMART bearer
@@ -477,7 +628,7 @@ export class DataspaceNodeClient {
477
628
  const exchangeBody = (exchangePoll.body as Record<string, unknown>) ?? {};
478
629
  const accessToken = String(exchangeBody['access_token'] ?? '').trim();
479
630
  if (exchangePoll.status !== 200 || !accessToken) {
480
- return { status: 'failed', step: '_exchange', endpointId, accessToken: '', tokenType: 'Bearer', scopes };
631
+ return { status: 'failed', step: '_exchange', tokenCacheKey: cacheKey, endpointId: cacheKey, accessToken: '', tokenType: 'Bearer', scopes };
481
632
  }
482
633
 
483
634
  const tokenType = String(exchangeBody['token_type'] ?? 'Bearer');
@@ -485,22 +636,22 @@ export class DataspaceNodeClient {
485
636
  const grantedScopes = grantedScope ? grantedScope.split(' ').filter(Boolean) : scopes;
486
637
  const expiresIn = Number(exchangeBody['expires_in'] ?? 0);
487
638
 
488
- this._tokenCache.set(endpointId, {
639
+ this._tokenCache.set(cacheKey, {
489
640
  accessToken,
490
641
  tokenType,
491
642
  scopes: grantedScopes,
492
643
  expiresAt: Date.now() + expiresIn * 1000,
493
644
  });
494
645
 
495
- return { status: 'fetched', endpointId, accessToken, tokenType, scopes: grantedScopes };
646
+ return { status: 'fetched', tokenCacheKey: cacheKey, endpointId: cacheKey, accessToken, tokenType, scopes: grantedScopes };
496
647
  }
497
648
 
498
649
  /**
499
650
  * Returns the cached SMART bearer for the given endpointId if still valid (>30s remaining).
500
651
  * Returns `undefined` if not cached or expired.
501
652
  */
502
- public getCachedBearerToken(endpointId: string): string | undefined {
503
- const cached = this._tokenCache.get(endpointId);
653
+ public getCachedBearerToken(tokenCacheKey: string): string | undefined {
654
+ const cached = this._tokenCache.get(tokenCacheKey);
504
655
  if (cached && cached.expiresAt > Date.now() + 30_000) {
505
656
  return cached.accessToken;
506
657
  }
@@ -516,7 +667,8 @@ export class DataspaceNodeClient {
516
667
  const {
517
668
  clientId,
518
669
  scopes,
519
- endpointId = `smart-backend:${clientId}`,
670
+ tokenCacheKey = `smart-backend:${clientId}`,
671
+ endpointId,
520
672
  tokenUrl,
521
673
  tokenPath = '/token',
522
674
  audience,
@@ -524,12 +676,14 @@ export class DataspaceNodeClient {
524
676
  additionalTokenFields,
525
677
  } = options;
526
678
 
527
- const cached = this._tokenCache.get(endpointId);
679
+ const cacheKey = String(tokenCacheKey || endpointId || '').trim() || `smart-backend:${clientId}`;
680
+ const cached = this._tokenCache.get(cacheKey);
528
681
  if (cached && cached.expiresAt > Date.now() + 30_000) {
529
682
  return {
530
683
  status: 'cached',
531
684
  profile: 'smart-backend.v1',
532
- endpointId,
685
+ tokenCacheKey: cacheKey,
686
+ endpointId: cacheKey,
533
687
  accessToken: cached.accessToken,
534
688
  tokenType: cached.tokenType,
535
689
  scopes: cached.scopes,
@@ -563,7 +717,8 @@ export class DataspaceNodeClient {
563
717
  return {
564
718
  status: 'failed',
565
719
  profile: 'smart-backend.v1',
566
- endpointId,
720
+ tokenCacheKey: cacheKey,
721
+ endpointId: cacheKey,
567
722
  statusCode: response.status,
568
723
  response,
569
724
  };
@@ -575,7 +730,7 @@ export class DataspaceNodeClient {
575
730
  const expiresIn = Number(body.expires_in ?? 0);
576
731
  const expiresAt = Date.now() + expiresIn * 1000;
577
732
 
578
- this._tokenCache.set(endpointId, {
733
+ this._tokenCache.set(cacheKey, {
579
734
  accessToken,
580
735
  tokenType,
581
736
  scopes: grantedScopes,
@@ -585,7 +740,8 @@ export class DataspaceNodeClient {
585
740
  return {
586
741
  status: 'fetched',
587
742
  profile: 'smart-backend.v1',
588
- endpointId,
743
+ tokenCacheKey: cacheKey,
744
+ endpointId: cacheKey,
589
745
  statusCode: response.status,
590
746
  accessToken,
591
747
  tokenType,
@@ -599,13 +755,13 @@ export class DataspaceNodeClient {
599
755
  * Exchange token payload against gateway token endpoint and cache the result.
600
756
  */
601
757
  public async requestSmartToken(input: SmartTokenExchangeInput): Promise<SmartTokenExchangeResult> {
602
- const endpointId = String(input.endpointId || '').trim();
603
- if (!endpointId) {
604
- throw new Error('requestSmartToken requires endpointId.');
758
+ const tokenCacheKey = String(input.tokenCacheKey || input.endpointId || '').trim();
759
+ if (!tokenCacheKey) {
760
+ throw new Error('requestSmartToken requires tokenCacheKey.');
605
761
  }
606
762
 
607
763
  const normalizedScopes = Array.from(new Set((input.scopes || []).filter(Boolean))).sort();
608
- const cached = this._tokenCache.get(endpointId);
764
+ const cached = this._tokenCache.get(tokenCacheKey);
609
765
  if (cached && cached.expiresAt > Date.now() + 30_000) {
610
766
  return {
611
767
  status: 'cached',
@@ -633,7 +789,7 @@ export class DataspaceNodeClient {
633
789
  : String(body.scope ?? '').trim().split(' ').filter(Boolean);
634
790
  const resolvedScopes = grantedScopes.length ? grantedScopes : normalizedScopes;
635
791
  const expiresIn = Number(body.expires_in ?? 0);
636
- this._tokenCache.set(endpointId, {
792
+ this._tokenCache.set(tokenCacheKey, {
637
793
  accessToken,
638
794
  tokenType,
639
795
  scopes: resolvedScopes,
@@ -650,6 +806,73 @@ export class DataspaceNodeClient {
650
806
  };
651
807
  }
652
808
 
809
+ /**
810
+ * Friendly wrapper for SMART token request via GW identity/auth token-exchange route.
811
+ * Uses one object, seconds-based polling, and constructor ctx fallback.
812
+ */
813
+ public async requestSmartTokenSimple(
814
+ input: SmartTokenRequestSimpleInput,
815
+ ): Promise<SmartTokenExchangeResult> {
816
+ const routeCtx = this.requireRouteContext(
817
+ input.tenantId && input.jurisdiction && input.sector
818
+ ? { tenantId: input.tenantId, jurisdiction: input.jurisdiction, sector: input.sector }
819
+ : undefined,
820
+ );
821
+ const normalizedScopes = Array.from(new Set((input.scopes || []).filter(Boolean))).sort();
822
+ const tokenCacheKey = String(input.tokenCacheKey || input.endpointId || `smart:${routeCtx.tenantId}:${normalizedScopes.join(',')}`).trim();
823
+ if (!tokenCacheKey) {
824
+ throw new Error('requestSmartTokenSimple requires tokenCacheKey (or non-empty scopes).');
825
+ }
826
+ const pollOptions = this.resolveSimplePollOptions(input.timeoutSeconds, input.intervalSeconds);
827
+
828
+ const payload: Record<string, unknown> = {
829
+ thid: `exchange-${randomUUID()}`,
830
+ grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
831
+ subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
832
+ subject_token: input.idToken,
833
+ scope: normalizedScopes.join(' '),
834
+ organization: routeCtx.tenantId,
835
+ ...(input.additionalClaims || {}),
836
+ };
837
+
838
+ const exchange = await this.submitAndPoll(
839
+ this.identityTokenExchangePath(routeCtx),
840
+ this.identityTokenExchangePollPath(routeCtx),
841
+ payload,
842
+ pollOptions,
843
+ );
844
+
845
+ const exchangeBody = (exchange.poll.body as Record<string, unknown>) ?? {};
846
+ const accessToken = String(exchangeBody.access_token || '').trim();
847
+ if (exchange.poll.status >= 400 || !accessToken) {
848
+ return {
849
+ status: 'failed',
850
+ statusCode: exchange.poll.status,
851
+ response: exchange.poll.body,
852
+ };
853
+ }
854
+
855
+ const tokenType = String(exchangeBody.token_type || 'Bearer');
856
+ const grantedScopes = String(exchangeBody.scope || '').trim().split(' ').filter(Boolean);
857
+ const resolvedScopes = grantedScopes.length ? grantedScopes : normalizedScopes;
858
+ const expiresIn = Number(exchangeBody.expires_in ?? 0);
859
+ this._tokenCache.set(tokenCacheKey, {
860
+ accessToken,
861
+ tokenType,
862
+ scopes: resolvedScopes,
863
+ expiresAt: Date.now() + expiresIn * 1000,
864
+ });
865
+
866
+ return {
867
+ status: 'fetched',
868
+ accessToken,
869
+ tokenType,
870
+ scopes: resolvedScopes,
871
+ statusCode: exchange.poll.status,
872
+ response: exchange.poll.body,
873
+ };
874
+ }
875
+
653
876
  // ---- Private auth helpers ----------------------------------------------
654
877
 
655
878
  private _pkceS256Challenge(verifier: string): string {
@@ -801,6 +1024,34 @@ export class DataspaceNodeClient {
801
1024
  // ---- Generic batch API --------------------------------------------------
802
1025
 
803
1026
  /**
1027
+ * POST a DIDComm bundle payload.
1028
+ * This is the preferred high-level method for DIDComm submission of
1029
+ * FHIR/API bundles (batch, transaction, message, etc.).
1030
+ *
1031
+ * Returns immediately with the HTTP response — pair with `pollUntilComplete` or use `submitAndPoll`.
1032
+ */
1033
+ public async submitBundle(
1034
+ path: string,
1035
+ payload: { thid?: string } & Record<string, unknown>,
1036
+ options?: {
1037
+ mode?: 'plain' | 'strict';
1038
+ recipientEncryptionJwk?: PublicJwk;
1039
+ walletContext?: WalletContext;
1040
+ },
1041
+ ): Promise<SubmitResponse> {
1042
+ const mode = options?.mode ?? 'plain';
1043
+ if (mode === 'strict') {
1044
+ if (!options?.recipientEncryptionJwk || !options?.walletContext) {
1045
+ throw new Error('submitBundle strict mode requires recipientEncryptionJwk and walletContext.');
1046
+ }
1047
+ return this.submitBatchEncrypted(path, payload, options.recipientEncryptionJwk, options.walletContext);
1048
+ }
1049
+ return this.submitBatch(path, payload);
1050
+ }
1051
+
1052
+ /**
1053
+ * @deprecated Use `submitBundle` instead.
1054
+ *
804
1055
  * POST a DIDComm plaintext payload to a batch submit path.
805
1056
  * Use this for all `_batch` routes (family registration, observations, tasks, etc.).
806
1057
  * Content-Type: `application/didcomm-plaintext+json`.
@@ -808,14 +1059,18 @@ export class DataspaceNodeClient {
808
1059
  * Returns immediately with the HTTP response — pair with `pollUntilComplete` or use `submitAndPoll`.
809
1060
  */
810
1061
  public async submitBatch(path: string, payload: unknown): Promise<SubmitResponse> {
811
- const response = await this.doPost(path, payload, 'application/didcomm-plaintext+json');
812
- const body = await this.parseResponseBody(response);
813
-
814
- return {
815
- status: response.status,
816
- location: response.headers.get('location') ?? undefined,
817
- body,
818
- };
1062
+ const url = /^https?:\/\//.test(path)
1063
+ ? path
1064
+ : `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
1065
+ const result = await submitDidcomm({
1066
+ mode: 'plain',
1067
+ url,
1068
+ payload: payload as Record<string, unknown>,
1069
+ defaultHeaders: this.defaultHeaders,
1070
+ bearerToken: this.bearerToken,
1071
+ fetcher: (requestUrl: string, init: DidcommFetchInit) => fetch(requestUrl, init),
1072
+ });
1073
+ return { status: result.status, location: result.location, body: result.body };
819
1074
  }
820
1075
 
821
1076
  /**
@@ -840,42 +1095,34 @@ export class DataspaceNodeClient {
840
1095
  const publicJwks = await this.wallet.getPublicJwks(walletContext);
841
1096
  const signingJwk = publicJwks.find((jwk) => jwk.use === 'sig' || jwk.alg === 'ES384') ?? publicJwks[0];
842
1097
 
843
- // Step 1: sign payload claims as compact JWS
844
- const compactJws = await this.wallet.signCompactJws(walletContext, {
845
- header: {
846
- typ: 'application/didcomm-signed+json',
847
- alg: 'ES384',
848
- ...(signingJwk?.kid ? { kid: signingJwk.kid } : {}),
849
- },
850
- claims: payload as Record<string, unknown>,
851
- });
852
-
853
- // Step 2: encrypt JWS string as compact JWE (RSA-OAEP-256 + A256GCM, cty: JWS)
854
- const compactJwe = await this.wallet.buildCompactJwe(walletContext, {
855
- plaintext: compactJws,
856
- recipientJwk: recipientEncryptionJwk,
857
- contentType: 'JWS',
858
- });
859
-
860
- // Step 3: POST the JWE token directly (not JSON-serialised)
861
1098
  const url = /^https?:\/\//.test(path)
862
1099
  ? path
863
1100
  : `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
864
- const headers: Record<string, string> = {
865
- ...this.defaultHeaders,
866
- 'Content-Type': 'application/didcomm-encrypted+json',
867
- Accept: 'application/json, application/didcomm-plaintext+json, */*',
868
- };
869
- if (this.bearerToken) {
870
- headers.Authorization = `Bearer ${this.bearerToken}`;
871
- }
872
- const response = await fetch(url, { method: 'POST', headers, body: compactJwe });
873
- const body = await this.parseResponseBody(response);
874
- return {
875
- status: response.status,
876
- location: response.headers.get('location') ?? undefined,
877
- body,
878
- };
1101
+ const result = await submitDidcomm({
1102
+ mode: 'strict',
1103
+ url,
1104
+ payload,
1105
+ defaultHeaders: this.defaultHeaders,
1106
+ bearerToken: this.bearerToken,
1107
+ recipientEncryptionJwk,
1108
+ signCompactJws: async (claims: Record<string, unknown>) =>
1109
+ this.wallet!.signCompactJws(walletContext, {
1110
+ header: {
1111
+ typ: 'application/didcomm-signed+json',
1112
+ alg: 'ES384',
1113
+ ...(signingJwk?.kid ? { kid: signingJwk.kid } : {}),
1114
+ },
1115
+ claims,
1116
+ }),
1117
+ encryptCompactJwe: async (compactJws: string, recipientJwk: unknown) =>
1118
+ this.wallet!.buildCompactJwe(walletContext, {
1119
+ plaintext: compactJws,
1120
+ recipientJwk: recipientJwk as PublicJwk,
1121
+ contentType: 'JWS',
1122
+ }),
1123
+ fetcher: (requestUrl: string, init: DidcommFetchInit) => fetch(requestUrl, init),
1124
+ });
1125
+ return { status: result.status, location: result.location, body: result.body };
879
1126
  }
880
1127
 
881
1128
  /**
@@ -893,6 +1140,14 @@ export class DataspaceNodeClient {
893
1140
  };
894
1141
  }
895
1142
 
1143
+ /**
1144
+ * Legacy JSON submit for non-bundle payloads (openid/token/resource JSON bodies).
1145
+ * Keeps JSON flows explicit and semantically separated from DIDComm bundle flows.
1146
+ */
1147
+ public async submitLegacyJson(path: string, payload: unknown): Promise<SubmitResponse> {
1148
+ return this.postJson(path, payload);
1149
+ }
1150
+
896
1151
  /**
897
1152
  * POST a multipart/form-data payload.
898
1153
  * Use for file upload endpoints. Prefer `uploadConversionFile` for DataConversion uploads.
@@ -959,11 +1214,15 @@ export class DataspaceNodeClient {
959
1214
  * Returns HTTP 202 while the job is still processing, 200 (or other) when done.
960
1215
  * Prefer `pollUntilComplete` for automatic retry loops.
961
1216
  */
962
- public async pollBatchResponse(path: string, request: AsyncPollRequest): Promise<{ status: number; body: unknown }> {
1217
+ public async pollBatchResponse(
1218
+ path: string,
1219
+ request: AsyncPollRequest,
1220
+ ): Promise<{ status: number; body: unknown; retryAfterMs?: number }> {
963
1221
  const response = await this.doPost(path, request, 'application/json');
964
1222
  return {
965
1223
  status: response.status,
966
1224
  body: await this.parseResponseBody(response),
1225
+ retryAfterMs: parseRetryAfterMs(response.headers.get('retry-after')),
967
1226
  };
968
1227
  }
969
1228
 
@@ -1010,10 +1269,11 @@ export class DataspaceNodeClient {
1010
1269
  * (appointment, medication schedule, or another event), mapped to `based-on-display`.
1011
1270
  */
1012
1271
  public async createPhoneReminderTasks(
1013
- ctx: RouteContext,
1272
+ ctx: RouteContext | undefined,
1014
1273
  input: CreatePhoneReminderTasksInput,
1015
1274
  options?: PollOptions,
1016
1275
  ): Promise<SubmitAndPollResult> {
1276
+ const routeCtx = this.requireRouteContext(ctx);
1017
1277
  const windows = Array.isArray(input.windows) ? input.windows : [];
1018
1278
  if (!windows.length) {
1019
1279
  throw new Error('createPhoneReminderTasks requires at least one reminder window.');
@@ -1036,9 +1296,9 @@ export class DataspaceNodeClient {
1036
1296
  }
1037
1297
 
1038
1298
  const taskIdSeed = [
1039
- ctx.tenantId,
1040
- ctx.jurisdiction,
1041
- ctx.sector,
1299
+ routeCtx.tenantId,
1300
+ routeCtx.jurisdiction,
1301
+ routeCtx.sector,
1042
1302
  input.subjectRef,
1043
1303
  input.ownerRef,
1044
1304
  input.focusRef,
@@ -1081,15 +1341,15 @@ export class DataspaceNodeClient {
1081
1341
  });
1082
1342
 
1083
1343
  const payload = createDidcommPlainMessage({
1084
- iss: ctx.tenantId,
1085
- aud: ctx.tenantId,
1344
+ iss: routeCtx.tenantId,
1345
+ aud: routeCtx.tenantId,
1086
1346
  thid,
1087
1347
  body: { data },
1088
1348
  });
1089
1349
 
1090
1350
  return this.submitAndPoll(
1091
- this.individualTaskBatchPath(ctx),
1092
- this.individualTaskPollPath(ctx),
1351
+ this.individualTaskBatchPath(routeCtx),
1352
+ this.individualTaskPollPath(routeCtx),
1093
1353
  payload,
1094
1354
  options ?? { timeoutMs: 20_000, intervalMs: 1_000 },
1095
1355
  );
@@ -1218,15 +1478,16 @@ export class DataspaceNodeClient {
1218
1478
  * Returns `null` when no matching registration exists.
1219
1479
  */
1220
1480
  public async searchFamilyOrganization(
1221
- ctx: RouteContext,
1481
+ ctx: RouteContext | undefined,
1222
1482
  filters: { controllerPhone: string; usualname: string; birthDate?: string },
1223
1483
  options?: PollOptions,
1224
1484
  ): Promise<FamilyOrganizationSummary | null> {
1485
+ const routeCtx = this.requireRouteContext(ctx);
1225
1486
  const thid = `search-${randomUUID()}`;
1226
1487
  const claims: Record<string, unknown> = {
1227
1488
  'org.schema.Organization.owner.telephone': filters.controllerPhone,
1228
1489
  'org.schema.Organization.alternateName': filters.usualname,
1229
- 'org.schema.Service.category': ctx.sector,
1490
+ 'org.schema.Service.category': routeCtx.sector,
1230
1491
  };
1231
1492
  if (filters.birthDate) {
1232
1493
  claims['org.schema.Organization.foundingDate'] = filters.birthDate;
@@ -1235,8 +1496,8 @@ export class DataspaceNodeClient {
1235
1496
  const payload = {
1236
1497
  jti: randomUUID(),
1237
1498
  thid,
1238
- iss: ctx.tenantId,
1239
- aud: ctx.tenantId,
1499
+ iss: routeCtx.tenantId,
1500
+ aud: routeCtx.tenantId,
1240
1501
  type: 'application/api+json',
1241
1502
  body: {
1242
1503
  data: [{
@@ -1248,8 +1509,8 @@ export class DataspaceNodeClient {
1248
1509
  };
1249
1510
 
1250
1511
  const result = await this.submitAndPoll(
1251
- this.individualFamilyOrganizationSearchPath(ctx),
1252
- this.individualFamilyOrganizationSearchPollPath(ctx),
1512
+ this.individualFamilyOrganizationSearchPath(routeCtx),
1513
+ this.individualFamilyOrganizationSearchPollPath(routeCtx),
1253
1514
  payload,
1254
1515
  options ?? { timeoutMs: 20_000, intervalMs: 1_000 },
1255
1516
  );
@@ -1281,7 +1542,7 @@ export class DataspaceNodeClient {
1281
1542
  * Activate tenant organization in GW from ICA-derived proof.
1282
1543
  */
1283
1544
  public async activateOrganizationInGatewayFromIcaProof(
1284
- ctx: HostRouteContext,
1545
+ ctx: HostRouteContext | undefined,
1285
1546
  input: GatewayOrganizationActivationInput,
1286
1547
  options?: PollOptions,
1287
1548
  ): Promise<SubmitAndPollResult> {
@@ -1294,6 +1555,11 @@ export class DataspaceNodeClient {
1294
1555
  vp_token: input.vpToken,
1295
1556
  ...(input.additionalClaims || {}),
1296
1557
  };
1558
+ const requestedMembers = Number.isFinite(Number(input.numberOfMembers))
1559
+ ? Math.max(1, Math.floor(Number(input.numberOfMembers)))
1560
+ : 2;
1561
+ // Keep gateway-facing claim stable while exposing a generic SDK input.
1562
+ claims['org.schema.Organization.numberOfEmployees'] = requestedMembers;
1297
1563
  if (input.organizationVc) claims['org.schema.OrganizationCredential.jwt'] = input.organizationVc;
1298
1564
  if (input.legalRepresentativeVc) {
1299
1565
  claims['org.schema.LegalRepresentativeCredential.jwt'] = input.legalRepresentativeVc;
@@ -1304,6 +1570,10 @@ export class DataspaceNodeClient {
1304
1570
  iss: 'did:web:controller.example.com',
1305
1571
  aud: 'did:web:host.example.com',
1306
1572
  body: {
1573
+ // GW activation parser expects proof material at top-level DIDComm body.
1574
+ vp_token: input.vpToken,
1575
+ ...(input.organizationVc ? { organizationCredential: input.organizationVc } : {}),
1576
+ ...(input.legalRepresentativeVc ? { representativeCredential: input.legalRepresentativeVc } : {}),
1307
1577
  data: [
1308
1578
  {
1309
1579
  type: 'Organization-activation-request-v1.0',
@@ -1322,6 +1592,227 @@ export class DataspaceNodeClient {
1322
1592
  );
1323
1593
  }
1324
1594
 
1595
+ /**
1596
+ * Friendly wrapper for legal organization activation.
1597
+ * Accepts one object and seconds-based polling options for integrator ergonomics.
1598
+ */
1599
+ public async activateOrganizationInGatewaySimple(
1600
+ input: GatewayOrganizationActivationSimpleInput,
1601
+ ): Promise<SubmitAndPollResult> {
1602
+ const serviceProviderDidWeb = String(input.serviceProviderDidWeb || '').trim();
1603
+ const serviceProviderUrl = String(input.serviceProviderUrl || '').trim();
1604
+ const controllerEmail = String(input.controllerEmail || '').trim();
1605
+ const controllerTelephone = String(input.controllerTelephone || '').trim();
1606
+ const controllerRole = String(input.controllerRole || '').trim();
1607
+ const resolvedServiceDid = toDidWebFromUrlOrHost(serviceProviderDidWeb || serviceProviderUrl);
1608
+ if (!resolvedServiceDid) {
1609
+ throw new Error('activateOrganizationInGatewaySimple requires serviceProviderDidWeb or serviceProviderUrl.');
1610
+ }
1611
+ if (!controllerEmail && !controllerTelephone) {
1612
+ throw new Error('activateOrganizationInGatewaySimple requires controllerEmail or controllerTelephone.');
1613
+ }
1614
+ if (!controllerRole) {
1615
+ throw new Error('activateOrganizationInGatewaySimple requires controllerRole.');
1616
+ }
1617
+ const pollOptions = this.resolveSimplePollOptions(input.timeoutSeconds, input.intervalSeconds);
1618
+
1619
+ const hostCtx = this.requireHostRouteContext(
1620
+ input.jurisdiction && input.sector
1621
+ ? { jurisdiction: input.jurisdiction, sector: input.sector }
1622
+ : undefined,
1623
+ );
1624
+ const implicitClaims: Record<string, unknown> = {
1625
+ 'org.schema.Service.category': hostCtx.sector,
1626
+ 'org.schema.Service.identifier': resolvedServiceDid,
1627
+ ...(serviceProviderUrl ? { 'org.schema.Service.url': serviceProviderUrl } : {}),
1628
+ [ClaimsPersonSchemaorg.hasOccupation]: controllerRole,
1629
+ ...(controllerEmail ? { [ClaimsPersonSchemaorg.email]: controllerEmail } : {}),
1630
+ ...(controllerTelephone ? { [ClaimsPersonSchemaorg.telephone]: controllerTelephone } : {}),
1631
+ };
1632
+
1633
+ const activation = await this.activateOrganizationInGatewayFromIcaProof(
1634
+ hostCtx,
1635
+ {
1636
+ vpToken: input.vpToken,
1637
+ numberOfMembers: input.numberOfMembers,
1638
+ organizationVc: input.organizationVc,
1639
+ legalRepresentativeVc: input.legalRepresentativeVc,
1640
+ regulatoryEvidence: input.regulatoryEvidence,
1641
+ additionalClaims: { ...implicitClaims, ...(input.additionalClaims || {}) },
1642
+ },
1643
+ pollOptions,
1644
+ );
1645
+ this.assertFirstDidcommEntrySuccess(activation, 'activateOrganizationInGatewaySimple');
1646
+ return activation;
1647
+ }
1648
+
1649
+ /**
1650
+ * Friendly wrapper for legal organization Order confirmation.
1651
+ * Accepts one object and builds payload/paths internally.
1652
+ */
1653
+ public async confirmLegalOrganizationOrderSimple(
1654
+ input: LegalOrganizationOrderSimpleInput,
1655
+ ): Promise<SubmitAndPollResult> {
1656
+ if (!String(input.offerId || '').trim()) {
1657
+ throw new Error('confirmLegalOrganizationOrderSimple requires offerId.');
1658
+ }
1659
+ const pollOptions = this.resolveSimplePollOptions(input.timeoutSeconds, input.intervalSeconds);
1660
+ const hostCtx = this.requireHostRouteContext(
1661
+ input.jurisdiction && input.sector
1662
+ ? { jurisdiction: input.jurisdiction, sector: input.sector }
1663
+ : undefined,
1664
+ );
1665
+
1666
+ const claims: Record<string, unknown> = {
1667
+ '@context': 'org.schema',
1668
+ 'Order.acceptedOffer.identifier': input.offerId,
1669
+ ...(input.additionalClaims || {}),
1670
+ };
1671
+ const payload = createDidcommPlainMessage({
1672
+ iss: 'did:web:controller.example.com',
1673
+ aud: 'did:web:host.example.com',
1674
+ thid: `order-${randomUUID()}`,
1675
+ body: {
1676
+ data: [{
1677
+ type: input.dataType || 'Organization-order-request-v1.0',
1678
+ meta: { claims }, // legacy compatibility
1679
+ resource: { meta: { claims } },
1680
+ }],
1681
+ },
1682
+ });
1683
+
1684
+ const order = await this.submitAndPoll(
1685
+ this.hostRegistryOrderBatchPath(hostCtx),
1686
+ this.hostRegistryOrderPollPath(hostCtx),
1687
+ payload,
1688
+ pollOptions,
1689
+ );
1690
+ this.assertFirstDidcommEntrySuccess(order, 'confirmLegalOrganizationOrderSimple');
1691
+ return order;
1692
+ }
1693
+
1694
+ /**
1695
+ * Normalize GW async response into DIDComm message body.
1696
+ *
1697
+ * Transport note:
1698
+ * - GW poll responses are HTTP JSON envelopes
1699
+ * - business payload lives inside DIDComm `body`
1700
+ *
1701
+ * This helper abstracts envelope differences so consumers do not depend on
1702
+ * raw `poll.body.body` paths.
1703
+ */
1704
+ public getDidcommMessageBodyFromResponse(
1705
+ result: SubmitAndPollResult | PollResult | unknown,
1706
+ ): Record<string, unknown> | undefined {
1707
+ const pollBody = (result as any)?.poll?.body ?? (result as any)?.body ?? result;
1708
+ const didcommBody = (pollBody as any)?.body;
1709
+ if (didcommBody && typeof didcommBody === 'object') return didcommBody as Record<string, unknown>;
1710
+ if (pollBody && typeof pollBody === 'object' && Array.isArray((pollBody as any)?.data)) {
1711
+ return pollBody as Record<string, unknown>;
1712
+ }
1713
+ return undefined;
1714
+ }
1715
+
1716
+ /**
1717
+ * Return first DIDComm business entry from a submit/poll result.
1718
+ */
1719
+ public getFirstDidcommDataEntryFromResponse(
1720
+ result: SubmitAndPollResult | PollResult | unknown,
1721
+ ): Record<string, unknown> | undefined {
1722
+ const body = this.getDidcommMessageBodyFromResponse(result);
1723
+ const entry = (body as any)?.data?.[0];
1724
+ return entry && typeof entry === 'object' ? (entry as Record<string, unknown>) : undefined;
1725
+ }
1726
+
1727
+ /**
1728
+ * Extract `org.schema.Offer.identifier` from a submit/poll result.
1729
+ *
1730
+ * This helper normalizes canonical and legacy claim locations.
1731
+ */
1732
+ public getOfferIdFromResponse(result: SubmitAndPollResult | PollResult | unknown): string | undefined {
1733
+ const entry = this.getFirstDidcommDataEntryFromResponse(result);
1734
+ const offerId = String(
1735
+ (entry as any)?.meta?.claims?.[ClaimsOfferSchemaorg.identifier]
1736
+ || (entry as any)?.resource?.meta?.claims?.[ClaimsOfferSchemaorg.identifier]
1737
+ || '',
1738
+ ).trim();
1739
+ return offerId || undefined;
1740
+ }
1741
+
1742
+ /**
1743
+ * Extract a UI-ready Offer preview from activation/registration responses.
1744
+ */
1745
+ public getOfferPreviewFromResponse(result: SubmitAndPollResult | PollResult | unknown): OfferPreview {
1746
+ const entry = this.getFirstDidcommDataEntryFromResponse(result) as any;
1747
+ const claims = entry?.meta?.claims || entry?.resource?.meta?.claims || {};
1748
+ const seatsRaw = claims[ClaimsOfferSchemaorg.eligibleQuantityValue];
1749
+ const seats =
1750
+ typeof seatsRaw === 'number'
1751
+ ? seatsRaw
1752
+ : (typeof seatsRaw === 'string' && seatsRaw.trim() ? Number(seatsRaw) : undefined);
1753
+ return {
1754
+ offerId: this.getOfferIdFromResponse(result),
1755
+ amount: claims[ClaimsOfferSchemaorg.price],
1756
+ currency: claims[ClaimsOfferSchemaorg.priceCurrency],
1757
+ seats: Number.isFinite(seats as number) ? seats : undefined,
1758
+ planName: claims[ClaimsOfferSchemaorg.itemOfferedName],
1759
+ sku: claims[ClaimsOfferSchemaorg.itemOfferedSku],
1760
+ paymentMethod: claims[ClaimsOfferSchemaorg.acceptedPaymentMethod],
1761
+ checkoutUrl: claims[ClaimsOfferSchemaorg.checkoutPageURLTemplate],
1762
+ };
1763
+ }
1764
+
1765
+ /**
1766
+ * Alias of `getOfferPreviewFromResponse` with business naming.
1767
+ */
1768
+ public getOfferInfoFromResponse(result: SubmitAndPollResult | PollResult | unknown): OfferInfo {
1769
+ return this.getOfferPreviewFromResponse(result);
1770
+ }
1771
+
1772
+ /**
1773
+ * Extract activation code from response payload or claims.
1774
+ * Supports common response shapes used in onboarding and license issuance flows.
1775
+ */
1776
+ public getActivationCodeFromResponse(result: SubmitAndPollResult | PollResult | unknown): string | undefined {
1777
+ const root = (result as any)?.poll?.body || (result as any)?.body || {};
1778
+ const byBody =
1779
+ String(root?.activationCode || root?.body?.activationCode || '').trim();
1780
+ if (byBody) return byBody;
1781
+
1782
+ const entry = this.getFirstDidcommDataEntryFromResponse(result) as any;
1783
+ const claims = entry?.meta?.claims || entry?.resource?.meta?.claims || {};
1784
+ const byClaims = String(
1785
+ claims['org.schema.IndividualProduct.serialNumber']
1786
+ || claims['org.schema.Offer.serialNumber']
1787
+ || claims['activationCode']
1788
+ || '',
1789
+ ).trim();
1790
+ return byClaims || undefined;
1791
+ }
1792
+
1793
+ /**
1794
+ * Throws when first DIDComm entry contains a business-level error status.
1795
+ */
1796
+ public assertFirstDidcommEntrySuccess(
1797
+ result: SubmitAndPollResult | PollResult | unknown,
1798
+ contextLabel: string,
1799
+ ): void {
1800
+ const entry = this.getFirstDidcommDataEntryFromResponse(result) as any;
1801
+ const responseStatusRaw = entry?.response?.status;
1802
+ const responseStatus = Number(responseStatusRaw);
1803
+ if (!Number.isFinite(responseStatus) || responseStatus < 400) return;
1804
+
1805
+ const diagnostics =
1806
+ String(
1807
+ entry?.response?.outcome?.issue?.[0]?.diagnostics
1808
+ || entry?.response?.outcome?.issue?.[0]?.details?.text
1809
+ || '',
1810
+ ).trim();
1811
+ throw new Error(
1812
+ `${contextLabel} failed (business status=${responseStatus})${diagnostics ? `: ${diagnostics}` : ''}`,
1813
+ );
1814
+ }
1815
+
1325
1816
  /**
1326
1817
  * Activate employee/member device by activation code exchange + DCR registration.
1327
1818
  *
@@ -1329,7 +1820,7 @@ export class DataspaceNodeClient {
1329
1820
  * Step 2. Register device keys through Device/_dcr authorized by that initial token.
1330
1821
  */
1331
1822
  public async activateEmployeeDeviceWithActivationCode(
1332
- ctx: RouteContext,
1823
+ ctx: RouteContext | undefined,
1333
1824
  input: EmployeeDeviceActivationInput,
1334
1825
  ): Promise<EmployeeDeviceActivationResult> {
1335
1826
  const exchangePayload = {
@@ -1385,17 +1876,43 @@ export class DataspaceNodeClient {
1385
1876
  };
1386
1877
  }
1387
1878
 
1879
+ /**
1880
+ * Friendly wrapper for employee/member device activation.
1881
+ * Uses one object, seconds-based polling, and constructor ctx fallback.
1882
+ */
1883
+ public async activateEmployeeDeviceWithActivationCodeSimple(
1884
+ input: EmployeeDeviceActivationSimpleInput,
1885
+ ): Promise<EmployeeDeviceActivationResult> {
1886
+ const routeCtx = this.requireRouteContext(
1887
+ input.tenantId && input.jurisdiction && input.sector
1888
+ ? { tenantId: input.tenantId, jurisdiction: input.jurisdiction, sector: input.sector }
1889
+ : undefined,
1890
+ );
1891
+ const pollOptions = this.resolveSimplePollOptions(input.timeoutSeconds, input.intervalSeconds);
1892
+
1893
+ return this.activateEmployeeDeviceWithActivationCode(
1894
+ routeCtx,
1895
+ {
1896
+ activationCode: input.activationCode,
1897
+ idToken: input.idToken,
1898
+ dcrPayload: input.dcrPayload,
1899
+ pollOptions,
1900
+ },
1901
+ );
1902
+ }
1903
+
1388
1904
  /**
1389
1905
  * UC 5.3 wrapper: create organization employee in entity Employee batch route.
1390
1906
  */
1391
1907
  public async createOrganizationEmployee(
1392
- ctx: RouteContext,
1908
+ ctx: RouteContext | undefined,
1393
1909
  input: OrganizationEmployeeCreationInput,
1394
1910
  options?: PollOptions,
1395
1911
  ): Promise<SubmitAndPollResult> {
1912
+ const routeCtx = this.requireRouteContext(ctx);
1396
1913
  const payload = createDidcommPlainMessage({
1397
- iss: ctx.tenantId,
1398
- aud: ctx.tenantId,
1914
+ iss: routeCtx.tenantId,
1915
+ aud: routeCtx.tenantId,
1399
1916
  thid: `employee-${randomUUID()}`,
1400
1917
  body: {
1401
1918
  data: [
@@ -1409,8 +1926,8 @@ export class DataspaceNodeClient {
1409
1926
  });
1410
1927
 
1411
1928
  return this.submitAndPoll(
1412
- this.employeeBatchPath(ctx),
1413
- this.employeePollPath(ctx),
1929
+ this.employeeBatchPath(routeCtx),
1930
+ this.employeePollPath(routeCtx),
1414
1931
  payload,
1415
1932
  options,
1416
1933
  );
@@ -1420,7 +1937,7 @@ export class DataspaceNodeClient {
1420
1937
  * UC 5.1 wrapper: bootstrap subject organization context via registration + optional order confirmation.
1421
1938
  */
1422
1939
  public async bootstrapSubjectOrganizationIndex(
1423
- ctx: RouteContext,
1940
+ ctx: RouteContext | undefined,
1424
1941
  input: SubjectOrganizationBootstrapInput,
1425
1942
  ): Promise<SubjectOrganizationBootstrapResult> {
1426
1943
  const registrationPayload = {
@@ -1454,24 +1971,151 @@ export class DataspaceNodeClient {
1454
1971
  return { registration, confirmation };
1455
1972
  }
1456
1973
 
1974
+ /**
1975
+ * Friendly wrapper (recommended step 1): register individual organization and return Offer.
1976
+ */
1977
+ public async startIndividualOrganizationSimple(
1978
+ input: IndividualOrganizationBootstrapSimpleInput,
1979
+ ): Promise<IndividualOrganizationStartSimpleResult> {
1980
+ const routeCtx = this.requireRouteContext(
1981
+ input.tenantId && input.jurisdiction && input.sector
1982
+ ? { tenantId: input.tenantId, jurisdiction: input.jurisdiction, sector: input.sector }
1983
+ : undefined,
1984
+ );
1985
+ const alternateName = String(input.alternateName || '').trim();
1986
+ if (!alternateName) {
1987
+ throw new Error('bootstrapIndividualOrganizationSimple requires alternateName.');
1988
+ }
1989
+ const controllerEmail = String(input.controllerEmail || '').trim();
1990
+ const controllerTelephone = String(input.controllerTelephone || '').trim();
1991
+ if (!controllerEmail && !controllerTelephone) {
1992
+ throw new Error('bootstrapIndividualOrganizationSimple requires controllerEmail or controllerTelephone.');
1993
+ }
1994
+ const controllerRole = String(input.controllerRole || 'org.hl7.v3.RoleCode|RESPRSN').trim();
1995
+
1996
+ const claims: Record<string, unknown> = {
1997
+ '@context': 'org.schema',
1998
+ 'org.schema.Organization.alternateName': alternateName,
1999
+ 'org.schema.Service.category': routeCtx.sector,
2000
+ [ClaimsPersonSchemaorg.hasOccupation]: controllerRole,
2001
+ ...(controllerEmail ? { [ClaimsPersonSchemaorg.email]: controllerEmail } : {}),
2002
+ ...(controllerTelephone ? { [ClaimsPersonSchemaorg.telephone]: controllerTelephone } : {}),
2003
+ ...(input.additionalClaims || {}),
2004
+ };
2005
+ const pollOptions = this.resolveSimplePollOptions(input.timeoutSeconds, input.intervalSeconds);
2006
+ const registrationPayload = createDidcommPlainMessage({
2007
+ iss: routeCtx.tenantId,
2008
+ aud: routeCtx.tenantId,
2009
+ thid: `family-org-${randomUUID()}`,
2010
+ body: {
2011
+ data: [{
2012
+ type: 'SubjectOrg-registration-form-v1.0',
2013
+ meta: { claims },
2014
+ resource: { meta: { claims } },
2015
+ }],
2016
+ },
2017
+ });
2018
+
2019
+ const registration = await this.submitAndPoll(
2020
+ this.individualFamilyOrganizationBatchPath(routeCtx),
2021
+ this.individualFamilyOrganizationPollPath(routeCtx),
2022
+ registrationPayload,
2023
+ pollOptions,
2024
+ );
2025
+ this.assertFirstDidcommEntrySuccess(registration, 'startIndividualOrganizationSimple.registration');
2026
+
2027
+ const offerId = this.getOfferIdFromResponse(registration);
2028
+ if (!offerId) {
2029
+ throw new Error('startIndividualOrganizationSimple failed: missing offerId in registration response.');
2030
+ }
2031
+ return { registration, offerId, offerPreview: this.getOfferPreviewFromResponse(registration) };
2032
+ }
2033
+
2034
+ /**
2035
+ * Friendly wrapper (recommended step 2): confirm individual/family order from accepted offerId.
2036
+ */
2037
+ public async confirmIndividualOrganizationOrderSimple(
2038
+ input: IndividualOrganizationConfirmOrderSimpleInput,
2039
+ ): Promise<SubmitAndPollResult> {
2040
+ const routeCtx = this.requireRouteContext(
2041
+ input.tenantId && input.jurisdiction && input.sector
2042
+ ? { tenantId: input.tenantId, jurisdiction: input.jurisdiction, sector: input.sector }
2043
+ : undefined,
2044
+ );
2045
+ const offerId = String(input.offerId || '').trim();
2046
+ if (!offerId) {
2047
+ throw new Error('confirmIndividualOrganizationOrderSimple requires offerId.');
2048
+ }
2049
+ const pollOptions = this.resolveSimplePollOptions(input.timeoutSeconds, input.intervalSeconds);
2050
+ const orderClaims: Record<string, unknown> = {
2051
+ '@context': 'org.schema',
2052
+ 'Order.acceptedOffer.identifier': offerId,
2053
+ };
2054
+ const confirmationPayload = createDidcommPlainMessage({
2055
+ iss: routeCtx.tenantId,
2056
+ aud: routeCtx.tenantId,
2057
+ thid: `family-order-${randomUUID()}`,
2058
+ body: {
2059
+ data: [{
2060
+ type: 'Family-order-request-v1.0',
2061
+ meta: { claims: orderClaims },
2062
+ resource: { meta: { claims: orderClaims } },
2063
+ }],
2064
+ },
2065
+ });
2066
+
2067
+ const confirmation = await this.submitAndPoll(
2068
+ this.individualFamilyOrderBatchPath(routeCtx),
2069
+ this.individualFamilyOrderPollPath(routeCtx),
2070
+ confirmationPayload,
2071
+ pollOptions,
2072
+ );
2073
+ this.assertFirstDidcommEntrySuccess(confirmation, 'confirmIndividualOrganizationOrderSimple');
2074
+ return confirmation;
2075
+ }
2076
+
2077
+ /**
2078
+ * Friendly wrapper (provisional): register + auto-confirm individual order.
2079
+ * Prefer `startIndividualOrganizationSimple` + `confirmIndividualOrganizationOrderSimple`.
2080
+ */
2081
+ public async bootstrapIndividualOrganizationSimple(
2082
+ input: IndividualOrganizationBootstrapSimpleInput,
2083
+ ): Promise<IndividualOrganizationBootstrapSimpleResult> {
2084
+ const started = await this.startIndividualOrganizationSimple(input);
2085
+ const confirmation = await this.confirmIndividualOrganizationOrderSimple({
2086
+ tenantId: input.tenantId,
2087
+ jurisdiction: input.jurisdiction,
2088
+ sector: input.sector,
2089
+ offerId: started.offerId,
2090
+ timeoutSeconds: input.timeoutSeconds,
2091
+ intervalSeconds: input.intervalSeconds,
2092
+ });
2093
+ return {
2094
+ registration: started.registration,
2095
+ offerId: started.offerId,
2096
+ confirmation,
2097
+ };
2098
+ }
2099
+
1457
2100
  /**
1458
2101
  * UC 5.5 wrapper: import IPS/FHIR composition and update subject index context.
1459
2102
  */
1460
2103
  public async importIpsOrFhirAndUpdateIndex(
1461
- ctx: RouteContext,
2104
+ ctx: RouteContext | undefined,
1462
2105
  input: IpsOrFhirImportInput,
1463
2106
  ): Promise<SubmitAndPollResult> {
2107
+ const routeCtx = this.requireRouteContext(ctx);
1464
2108
  const payload = {
1465
2109
  thid: input.compositionPayload.thid || `composition-${randomUUID()}`,
1466
2110
  ...input.compositionPayload,
1467
2111
  };
1468
2112
 
1469
2113
  const submitPath = (input.format || 'r4') === 'api'
1470
- ? this.individualCompositionR4BatchPath(ctx).replace('/org.hl7.fhir.r4/', '/org.hl7.fhir.api/')
1471
- : this.individualCompositionR4BatchPath(ctx);
2114
+ ? this.individualCompositionR4BatchPath(routeCtx).replace('/org.hl7.fhir.r4/', '/org.hl7.fhir.api/')
2115
+ : this.individualCompositionR4BatchPath(routeCtx);
1472
2116
  const pollPath = (input.format || 'r4') === 'api'
1473
- ? this.individualCompositionR4PollPath(ctx).replace('/org.hl7.fhir.r4/', '/org.hl7.fhir.api/')
1474
- : this.individualCompositionR4PollPath(ctx);
2117
+ ? this.individualCompositionR4PollPath(routeCtx).replace('/org.hl7.fhir.r4/', '/org.hl7.fhir.api/')
2118
+ : this.individualCompositionR4PollPath(routeCtx);
1475
2119
 
1476
2120
  return this.submitAndPoll(submitPath, pollPath, payload, input.pollOptions);
1477
2121
  }
@@ -1481,9 +2125,10 @@ export class DataspaceNodeClient {
1481
2125
  * Builds canonical Consent claims and submits/polls the Consent batch.
1482
2126
  */
1483
2127
  public async grantProfessionalAccessSimple(
1484
- ctx: RouteContext,
2128
+ ctx: RouteContext | undefined,
1485
2129
  input: GrantProfessionalAccessSimpleInput,
1486
2130
  ): Promise<GrantProfessionalAccessSimpleResult> {
2131
+ const routeCtx = this.requireRouteContext(ctx);
1487
2132
  const built = buildConsentClaimsSimpleWithCid(
1488
2133
  {
1489
2134
  subjectDid: input.subjectDid,
@@ -1518,8 +2163,8 @@ export class DataspaceNodeClient {
1518
2163
  },
1519
2164
  };
1520
2165
  const consent = await this.submitAndPoll(
1521
- this.individualConsentR4BatchPath(ctx),
1522
- this.individualConsentR4PollPath(ctx),
2166
+ this.individualConsentR4BatchPath(routeCtx),
2167
+ this.individualConsentR4PollPath(routeCtx),
1523
2168
  consentPayload,
1524
2169
  input.pollOptions,
1525
2170
  );
@@ -1538,20 +2183,21 @@ export class DataspaceNodeClient {
1538
2183
  * UC 5.7 wrapper: generate digital twin composition from subject data.
1539
2184
  */
1540
2185
  public async generateDigitalTwinFromSubjectData(
1541
- ctx: RouteContext,
2186
+ ctx: RouteContext | undefined,
1542
2187
  input: DigitalTwinGenerationInput,
1543
2188
  ): Promise<SubmitAndPollResult> {
2189
+ const routeCtx = this.requireRouteContext(ctx);
1544
2190
  const payload = {
1545
2191
  thid: input.compositionPayload.thid || `digital-twin-${randomUUID()}`,
1546
2192
  ...input.compositionPayload,
1547
2193
  };
1548
2194
 
1549
2195
  const submitPath = (input.format || 'r4') === 'api'
1550
- ? this.digitalTwinCompositionApiBatchPath(ctx)
1551
- : this.digitalTwinCompositionR4BatchPath(ctx);
2196
+ ? this.digitalTwinCompositionApiBatchPath(routeCtx)
2197
+ : this.digitalTwinCompositionR4BatchPath(routeCtx);
1552
2198
  const pollPath = (input.format || 'r4') === 'api'
1553
- ? this.digitalTwinCompositionApiPollPath(ctx)
1554
- : this.digitalTwinCompositionR4PollPath(ctx);
2199
+ ? this.digitalTwinCompositionApiPollPath(routeCtx)
2200
+ : this.digitalTwinCompositionR4PollPath(routeCtx);
1555
2201
 
1556
2202
  return this.submitAndPoll(submitPath, pollPath, payload, input.pollOptions);
1557
2203
  }
@@ -1587,7 +2233,8 @@ export class DataspaceNodeClient {
1587
2233
  throw new Error(`Polling timeout after ${attempts} attempts (${timeoutMs}ms).`);
1588
2234
  }
1589
2235
 
1590
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
2236
+ const waitMs = options?.intervalMs ?? result.retryAfterMs ?? intervalMs;
2237
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
1591
2238
  }
1592
2239
  }
1593
2240
 
@@ -1617,10 +2264,15 @@ export class DataspaceNodeClient {
1617
2264
 
1618
2265
  private async parseResponseBody(response: Response): Promise<unknown> {
1619
2266
  const contentType = response.headers.get('content-type') || '';
2267
+ const raw = await response.text();
2268
+ if (!raw) return {};
1620
2269
  if (contentType.includes('application/json') || contentType.includes('application/didcomm-plaintext+json')) {
1621
- return response.json().catch(() => ({}));
2270
+ try {
2271
+ return JSON.parse(raw);
2272
+ } catch {
2273
+ return {};
2274
+ }
1622
2275
  }
1623
- const text = await response.text();
1624
- return text;
2276
+ return raw;
1625
2277
  }
1626
2278
  }