dataspace-client-sdk-node 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +310 -0
- package/SDK_PARITY_MAP.md +120 -0
- package/TODO_PROMPT_NEXT_STEPS.md +185 -0
- package/artifacts/update-smart-wallet.js +1016 -0
- package/dist/builders.d.ts +12 -0
- package/dist/builders.js +17 -0
- package/dist/client.d.ts +333 -0
- package/dist/client.js +1229 -0
- package/dist/consent/pdfSignatureVerification.d.ts +18 -0
- package/dist/consent/pdfSignatureVerification.js +23 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +8 -0
- package/dist/sdk/dataspace-wallet-sdk-node/MultiWalletClient.d.ts +9 -0
- package/dist/sdk/dataspace-wallet-sdk-node/MultiWalletClient.js +21 -0
- package/dist/sdk/dataspace-wallet-sdk-node/WalletClient.d.ts +26 -0
- package/dist/sdk/dataspace-wallet-sdk-node/WalletClient.js +36 -0
- package/dist/sdk/dataspace-wallet-sdk-node/index.d.ts +6 -0
- package/dist/sdk/dataspace-wallet-sdk-node/index.js +6 -0
- package/dist/sdk/dataspace-wallet-sdk-node/provider.d.ts +24 -0
- package/dist/sdk/dataspace-wallet-sdk-node/provider.js +1 -0
- package/dist/sdk/dataspace-wallet-sdk-node/providers/memory-provider.d.ts +41 -0
- package/dist/sdk/dataspace-wallet-sdk-node/providers/memory-provider.js +216 -0
- package/dist/sdk/dataspace-wallet-sdk-node/providers/seed-provider.d.ts +22 -0
- package/dist/sdk/dataspace-wallet-sdk-node/providers/seed-provider.js +28 -0
- package/dist/sdk/dataspace-wallet-sdk-node/types.d.ts +51 -0
- package/dist/sdk/dataspace-wallet-sdk-node/types.js +1 -0
- package/dist/types.d.ts +445 -0
- package/dist/types.js +1 -0
- package/docs/API.md +745 -0
- package/docs/DATA_MODEL_ALIGNMENT.md +31 -0
- package/docs/DATA_PLANES_SCOPE_MATRIX.md +51 -0
- package/docs/DEVELOPER_USE_CASES.md +253 -0
- package/docs/E2E_BOOTSTRAP.md +54 -0
- package/docs/TODO_SMART_EHR_COMPAT.md +58 -0
- package/examples/backend-pkce-auth.mjs +119 -0
- package/examples/conversion-upload.mjs +52 -0
- package/examples/e2e-bootstrap-tenant.mjs +126 -0
- package/examples/e2e-individual-flow.mjs +43 -0
- package/examples/host-activate-and-employee.mjs +75 -0
- package/package.json +26 -0
- package/src/builders.ts +28 -0
- package/src/client.ts +1626 -0
- package/src/consent/pdfSignatureVerification.ts +41 -0
- package/src/index.ts +8 -0
- package/src/sdk/dataspace-wallet-sdk-node/MultiWalletClient.ts +25 -0
- package/src/sdk/dataspace-wallet-sdk-node/WalletClient.ts +63 -0
- package/src/sdk/dataspace-wallet-sdk-node/index.ts +6 -0
- package/src/sdk/dataspace-wallet-sdk-node/provider.ts +44 -0
- package/src/sdk/dataspace-wallet-sdk-node/providers/memory-provider.ts +310 -0
- package/src/sdk/dataspace-wallet-sdk-node/providers/seed-provider.ts +31 -0
- package/src/sdk/dataspace-wallet-sdk-node/types.ts +61 -0
- package/src/types.ts +497 -0
- package/tests/client.test.mjs +892 -0
- package/tests/uc5-org-onboarding.flow.test.mjs +145 -0
- package/tests/uc5-subject-data.flow.test.mjs +198 -0
- package/tsconfig.json +13 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,1626 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { createDidcommPlainMessage } from './builders.js';
|
|
3
|
+
import { buildConsentClaimsSimpleWithCid } from 'gdc-common-utils-ts/utils/consent';
|
|
4
|
+
import {
|
|
5
|
+
MedicationStatementClaimsFhirApi,
|
|
6
|
+
MedicationStatementClaimsFhirApiExtended,
|
|
7
|
+
} from 'gdc-common-utils-ts/models/interoperable-claims/medication-statement-claims';
|
|
8
|
+
import type {
|
|
9
|
+
AsyncPollRequest,
|
|
10
|
+
BackendPkceAuthOptions,
|
|
11
|
+
BackendPkceAuthResult,
|
|
12
|
+
BackendSmartAuthOptions,
|
|
13
|
+
BackendSmartAuthResult,
|
|
14
|
+
ClientOptions,
|
|
15
|
+
CreatePhoneReminderTasksInput,
|
|
16
|
+
GrantProfessionalAccessSimpleInput,
|
|
17
|
+
GrantProfessionalAccessSimpleResult,
|
|
18
|
+
DigitalTwinGenerationInput,
|
|
19
|
+
EmployeeDeviceActivationInput,
|
|
20
|
+
EmployeeDeviceActivationResult,
|
|
21
|
+
FamilyOrganizationSummary,
|
|
22
|
+
FamilyRegistrationStatus,
|
|
23
|
+
GatewayOrganizationActivationInput,
|
|
24
|
+
HostRouteContext,
|
|
25
|
+
IpsOrFhirImportInput,
|
|
26
|
+
MedicationOverlapCheckInput,
|
|
27
|
+
MedicationRegistrationInput,
|
|
28
|
+
OrganizationEmployeeCreationInput,
|
|
29
|
+
PollOptions,
|
|
30
|
+
PollResult,
|
|
31
|
+
RouteContext,
|
|
32
|
+
SmartTokenExchangeInput,
|
|
33
|
+
SmartTokenExchangeResult,
|
|
34
|
+
SubjectOrganizationBootstrapInput,
|
|
35
|
+
SubjectOrganizationBootstrapResult,
|
|
36
|
+
SubmitAndPollResult,
|
|
37
|
+
SubmitResponse,
|
|
38
|
+
V1Action,
|
|
39
|
+
V1Section,
|
|
40
|
+
} from './types.js';
|
|
41
|
+
import type { WalletProvider } from './sdk/dataspace-wallet-sdk-node/provider.js';
|
|
42
|
+
import type { PublicJwk, WalletContext } from './sdk/dataspace-wallet-sdk-node/types.js';
|
|
43
|
+
|
|
44
|
+
function trimTrailingSlash(value: string): string {
|
|
45
|
+
return value.replace(/\/+$/, '');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function encode(value: string): string {
|
|
49
|
+
return encodeURIComponent(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type CachedToken = {
|
|
53
|
+
accessToken: string;
|
|
54
|
+
tokenType: string;
|
|
55
|
+
scopes: string[];
|
|
56
|
+
expiresAt: number; // unix ms
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export class DataspaceNodeClient {
|
|
60
|
+
private readonly baseUrl: string;
|
|
61
|
+
private readonly bearerToken?: string;
|
|
62
|
+
private readonly defaultHeaders: Record<string, string>;
|
|
63
|
+
private readonly wallet?: WalletProvider;
|
|
64
|
+
private readonly _tokenCache = new Map<string, CachedToken>();
|
|
65
|
+
|
|
66
|
+
constructor(options: ClientOptions) {
|
|
67
|
+
this.baseUrl = trimTrailingSlash(options.baseUrl);
|
|
68
|
+
this.bearerToken = options.bearerToken;
|
|
69
|
+
this.defaultHeaders = options.defaultHeaders ?? {};
|
|
70
|
+
this.wallet = options.wallet;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public getWallet(): WalletProvider | undefined {
|
|
74
|
+
return this.wallet;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---- Path helpers -------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Generic GW v1 tenant route builder.
|
|
81
|
+
* Use this for any section/format/resourceType/action combination not covered
|
|
82
|
+
* by a dedicated convenience method.
|
|
83
|
+
*
|
|
84
|
+
* Pattern: `/{tenantId}/cds-{jurisdiction}/v1/{sector}/{section}/{format}/{resourceType}/{action}`
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* client.v1Path(ctx, 'individual', 'org.schema', 'Organization', '_batch')
|
|
88
|
+
* // → /acme/cds-ES/v1/health-care/individual/org.schema/Organization/_batch
|
|
89
|
+
*/
|
|
90
|
+
public v1Path(
|
|
91
|
+
ctx: RouteContext,
|
|
92
|
+
section: V1Section,
|
|
93
|
+
format: string,
|
|
94
|
+
resourceType: string,
|
|
95
|
+
action: V1Action,
|
|
96
|
+
): string {
|
|
97
|
+
return `/${encode(ctx.tenantId)}/cds-${encode(ctx.jurisdiction)}/v1/${encode(ctx.sector)}/${encode(section)}/${encode(format)}/${encode(resourceType)}/${encode(action)}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Generic tenant-scoped identity route builder.
|
|
102
|
+
* Pattern: `/{prefix}/cds-{jurisdiction}/v1/{sector}/{tenantId}/identity/auth/{action}`
|
|
103
|
+
*
|
|
104
|
+
* The `prefix` is service-specific: `host` for GW, `publisher` for DataConv, `ica` for ICA.
|
|
105
|
+
* Dedicated path methods in this SDK use `host` (GW convention).
|
|
106
|
+
*/
|
|
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)}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Generic host registry route builder (tenant-agnostic, `host/` prefix).
|
|
113
|
+
* Use for controller-level registry operations (Organization activate, Order, etc.).
|
|
114
|
+
*
|
|
115
|
+
* Pattern: `/host/cds-{jurisdiction}/v1/{sector}/registry/org.schema/{resourceType}/{action}`
|
|
116
|
+
*/
|
|
117
|
+
public hostRegistryPath(
|
|
118
|
+
ctx: HostRouteContext,
|
|
119
|
+
resourceType: string,
|
|
120
|
+
action: V1Action,
|
|
121
|
+
): string {
|
|
122
|
+
return `/host/cds-${encode(ctx.jurisdiction)}/v1/${encode(ctx.sector)}/registry/org.schema/${encode(resourceType)}/${encode(action)}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Submit path: host registry Organization batch (controller-level org registration). */
|
|
126
|
+
public hostRegistryOrganizationBatchPath(ctx: HostRouteContext): string {
|
|
127
|
+
return this.hostRegistryPath(ctx, 'Organization', '_batch');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Poll path: host registry Organization batch. Pair with `hostRegistryOrganizationBatchPath`. */
|
|
131
|
+
public hostRegistryOrganizationPollPath(ctx: HostRouteContext): string {
|
|
132
|
+
return this.hostRegistryPath(ctx, 'Organization', '_batch-response');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Submit path: activate a tenant Organization in the GW registry using a VC from ICA. */
|
|
136
|
+
public hostRegistryOrganizationActivatePath(ctx: HostRouteContext): string {
|
|
137
|
+
return this.hostRegistryPath(ctx, 'Organization', '_activate');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Poll path: `_activate` response. Pair with `hostRegistryOrganizationActivatePath`. */
|
|
141
|
+
public hostRegistryOrganizationActivatePollPath(ctx: HostRouteContext): string {
|
|
142
|
+
return this.hostRegistryPath(ctx, 'Organization', '_activate-response');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Submit path: host registry Order batch (controller-level order submission). */
|
|
146
|
+
public hostRegistryOrderBatchPath(ctx: HostRouteContext): string {
|
|
147
|
+
return this.hostRegistryPath(ctx, 'Order', '_batch');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Poll path: host registry Order batch. Pair with `hostRegistryOrderBatchPath`. */
|
|
151
|
+
public hostRegistryOrderPollPath(ctx: HostRouteContext): string {
|
|
152
|
+
return this.hostRegistryPath(ctx, 'Order', '_batch-response');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Submit path: individual/family Organization onboarding (`org.schema/Organization/_batch`).
|
|
157
|
+
* Use for `family-registration/_create-or-resume` DIDComm payloads.
|
|
158
|
+
*/
|
|
159
|
+
public individualFamilyOrganizationBatchPath(ctx: RouteContext): string {
|
|
160
|
+
return `/${encode(ctx.tenantId)}/cds-${encode(ctx.jurisdiction)}/v1/${encode(ctx.sector)}/individual/org.schema/Organization/_batch`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 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`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Submit path: individual/family Organization search (`org.schema/Organization/_search`). */
|
|
169
|
+
public individualFamilyOrganizationSearchPath(ctx: RouteContext): string {
|
|
170
|
+
return this.v1Path(ctx, 'individual', 'org.schema', 'Organization', '_search');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Poll path: individual/family Organization search. Pair with `individualFamilyOrganizationSearchPath`. */
|
|
174
|
+
public individualFamilyOrganizationSearchPollPath(ctx: RouteContext): string {
|
|
175
|
+
return this.v1Path(ctx, 'individual', 'org.schema', 'Organization', '_search-response');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** 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`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 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`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Submit path: individual RelatedPerson (FHIR R4 API, `org.hl7.fhir.api/RelatedPerson/_batch`). */
|
|
189
|
+
public individualRelatedPersonBatchPath(ctx: RouteContext): string {
|
|
190
|
+
return `/${encode(ctx.tenantId)}/cds-${encode(ctx.jurisdiction)}/v1/${encode(ctx.sector)}/individual/org.hl7.fhir.api/RelatedPerson/_batch`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Poll path: individual RelatedPerson. Pair with `individualRelatedPersonBatchPath`. */
|
|
194
|
+
public individualRelatedPersonPollPath(ctx: RouteContext): string {
|
|
195
|
+
return `/${encode(ctx.tenantId)}/cds-${encode(ctx.jurisdiction)}/v1/${encode(ctx.sector)}/individual/org.hl7.fhir.api/RelatedPerson/_batch-response`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Submit path: individual Observation (FHIR R4 API, `org.hl7.fhir.api/Observation/_batch`). */
|
|
199
|
+
public individualObservationBatchPath(ctx: RouteContext): string {
|
|
200
|
+
return `/${encode(ctx.tenantId)}/cds-${encode(ctx.jurisdiction)}/v1/${encode(ctx.sector)}/individual/org.hl7.fhir.api/Observation/_batch`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Poll path: individual Observation. Pair with `individualObservationBatchPath`. */
|
|
204
|
+
public individualObservationPollPath(ctx: RouteContext): string {
|
|
205
|
+
return `/${encode(ctx.tenantId)}/cds-${encode(ctx.jurisdiction)}/v1/${encode(ctx.sector)}/individual/org.hl7.fhir.api/Observation/_batch-response`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Submit path: individual Communication (FHIR R4, `org.hl7.fhir.r4/Communication/_batch`). */
|
|
209
|
+
public individualCommunicationBatchPath(ctx: RouteContext): string {
|
|
210
|
+
return `/${encode(ctx.tenantId)}/cds-${encode(ctx.jurisdiction)}/v1/${encode(ctx.sector)}/individual/org.hl7.fhir.r4/Communication/_batch`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Poll path: individual Communication. Pair with `individualCommunicationBatchPath`. */
|
|
214
|
+
public individualCommunicationPollPath(ctx: RouteContext): string {
|
|
215
|
+
return `/${encode(ctx.tenantId)}/cds-${encode(ctx.jurisdiction)}/v1/${encode(ctx.sector)}/individual/org.hl7.fhir.r4/Communication/_batch-response`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Submit path: individual Task (FHIR R4 API, `org.hl7.fhir.api/Task/_batch`). */
|
|
219
|
+
public individualTaskBatchPath(ctx: RouteContext): string {
|
|
220
|
+
return this.v1Path(ctx, 'individual', 'org.hl7.fhir.api', 'Task', '_batch');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Poll path: individual Task. Pair with `individualTaskBatchPath`. */
|
|
224
|
+
public individualTaskPollPath(ctx: RouteContext): string {
|
|
225
|
+
return this.v1Path(ctx, 'individual', 'org.hl7.fhir.api', 'Task', '_batch-response');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Submit path: entity Employee (`entity/org.schema/Employee/_batch`). */
|
|
229
|
+
public employeeBatchPath(ctx: RouteContext): string {
|
|
230
|
+
return this.v1Path(ctx, 'entity', 'org.schema', 'Employee', '_batch');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Poll path: entity Employee. Pair with `employeeBatchPath`. */
|
|
234
|
+
public employeePollPath(ctx: RouteContext): string {
|
|
235
|
+
return this.v1Path(ctx, 'entity', 'org.schema', 'Employee', '_batch-response');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Submit path: individual Person legacy format (`individual/org.schema/Person/_batch`). Use for older flows; prefer Organization for family onboarding. */
|
|
239
|
+
public individualLegacyPersonBatchPath(ctx: RouteContext): string {
|
|
240
|
+
return this.v1Path(ctx, 'individual', 'org.schema', 'Person', '_batch');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Submit path: individual Consent (FHIR R4, `org.hl7.fhir.r4/Consent/_batch`). */
|
|
244
|
+
public individualConsentR4BatchPath(ctx: RouteContext): string {
|
|
245
|
+
return this.v1Path(ctx, 'individual', 'org.hl7.fhir.r4', 'Consent', '_batch');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Poll path: individual Consent R4. Pair with `individualConsentR4BatchPath`. */
|
|
249
|
+
public individualConsentR4PollPath(ctx: RouteContext): string {
|
|
250
|
+
return this.v1Path(ctx, 'individual', 'org.hl7.fhir.r4', 'Consent', '_batch-response');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Submit path: individual Composition (FHIR R4, `org.hl7.fhir.r4/Composition/_batch`). */
|
|
254
|
+
public individualCompositionR4BatchPath(ctx: RouteContext): string {
|
|
255
|
+
return this.v1Path(ctx, 'individual', 'org.hl7.fhir.r4', 'Composition', '_batch');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Poll path: individual Composition R4. Pair with `individualCompositionR4BatchPath`. */
|
|
259
|
+
public individualCompositionR4PollPath(ctx: RouteContext): string {
|
|
260
|
+
return this.v1Path(ctx, 'individual', 'org.hl7.fhir.r4', 'Composition', '_batch-response');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Submit path: digital twin Composition (FHIR API format, `digitaltwin/org.hl7.fhir.api/Composition/_batch`). */
|
|
264
|
+
public digitalTwinCompositionApiBatchPath(ctx: RouteContext): string {
|
|
265
|
+
return this.v1Path(ctx, 'digitaltwin', 'org.hl7.fhir.api', 'Composition', '_batch');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Poll path: digital twin Composition API. Pair with `digitalTwinCompositionApiBatchPath`. */
|
|
269
|
+
public digitalTwinCompositionApiPollPath(ctx: RouteContext): string {
|
|
270
|
+
return this.v1Path(ctx, 'digitaltwin', 'org.hl7.fhir.api', 'Composition', '_batch-response');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Submit path: digital twin Composition (FHIR R4 format, `digitaltwin/org.hl7.fhir.r4/Composition/_batch`). */
|
|
274
|
+
public digitalTwinCompositionR4BatchPath(ctx: RouteContext): string {
|
|
275
|
+
return this.v1Path(ctx, 'digitaltwin', 'org.hl7.fhir.r4', 'Composition', '_batch');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Poll path: digital twin Composition R4. Pair with `digitalTwinCompositionR4BatchPath`. */
|
|
279
|
+
public digitalTwinCompositionR4PollPath(ctx: RouteContext): string {
|
|
280
|
+
return this.v1Path(ctx, 'digitaltwin', 'org.hl7.fhir.r4', 'Composition', '_batch-response');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Submit path: identity DCR step — binds API key to service public JWK.
|
|
285
|
+
* Used internally by `authenticateBackendPkceAndExchange` (step 1 of identity-exchange.v1).
|
|
286
|
+
*/
|
|
287
|
+
public identityDeviceDcrPath(ctx: RouteContext): string {
|
|
288
|
+
return this.tenantIdentityPath(ctx, 'host', '_dcr');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Poll path: identity DCR. Pair with `identityDeviceDcrPath`. */
|
|
292
|
+
public identityDeviceDcrPollPath(ctx: RouteContext): string {
|
|
293
|
+
return this.tenantIdentityPath(ctx, 'host', '_dcr-response');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Submit path: identity token exchange — id_token → SMART bearer.
|
|
298
|
+
* Used internally by `authenticateBackendPkceAndExchange` (step 4 of identity-exchange.v1).
|
|
299
|
+
*/
|
|
300
|
+
public identityTokenExchangePath(ctx: RouteContext): string {
|
|
301
|
+
return this.tenantIdentityPath(ctx, 'host', '_exchange');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Poll path: identity token exchange. Pair with `identityTokenExchangePath`. */
|
|
305
|
+
public identityTokenExchangePollPath(ctx: RouteContext): string {
|
|
306
|
+
return this.tenantIdentityPath(ctx, 'host', '_exchange-response');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Submit path: identity license issue (`identity/auth/_issue`). */
|
|
310
|
+
public identityLicenseIssuePath(ctx: RouteContext): string {
|
|
311
|
+
return this.tenantIdentityPath(ctx, 'host', '_issue');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Submit path: SMART token step — code + code_verifier → id_token.
|
|
316
|
+
* Used internally by `authenticateBackendPkceAndExchange` (step 3 of identity-exchange.v1).
|
|
317
|
+
*/
|
|
318
|
+
public identitySmartTokenPath(ctx: RouteContext): string {
|
|
319
|
+
return this.tenantIdentityPath(ctx, 'host', '_token');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Poll path: SMART token. Pair with `identitySmartTokenPath`. */
|
|
323
|
+
public identitySmartTokenPollPath(ctx: RouteContext): string {
|
|
324
|
+
return this.tenantIdentityPath(ctx, 'host', '_token-response');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Submit path: Firebase custom token exchange (end-user device flow, NOT B2B). */
|
|
328
|
+
public identityFirebaseCustomPath(ctx: RouteContext): string {
|
|
329
|
+
return this.tenantIdentityPath(ctx, 'host', '_custom');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Poll path: Firebase custom token. Pair with `identityFirebaseCustomPath`. */
|
|
333
|
+
public identityFirebaseCustomPollPath(ctx: RouteContext): string {
|
|
334
|
+
return this.tenantIdentityPath(ctx, 'host', '_custom-response');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Submit path: identity PKCE code step — sends S256 code_challenge.
|
|
339
|
+
* Used internally by `authenticateBackendPkceAndExchange` (step 2 of identity-exchange.v1).
|
|
340
|
+
*/
|
|
341
|
+
public identityCodePath(ctx: RouteContext): string {
|
|
342
|
+
return this.tenantIdentityPath(ctx, 'host', '_code');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Poll path: identity PKCE code. Pair with `identityCodePath`. */
|
|
346
|
+
public identityCodePollPath(ctx: RouteContext): string {
|
|
347
|
+
return this.tenantIdentityPath(ctx, 'host', '_code-response');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Submit path: UHC debug task call-start (`individual/{format}/Task/_call-start`). For telephony integration testing. */
|
|
351
|
+
public taskDebugCallStartPath(ctx: RouteContext, format = 'org.hl7.fhir.api'): string {
|
|
352
|
+
return this.v1Path(ctx, 'individual', format, 'Task', '_call-start');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Path: UHC debug task logs (`individual/{format}/Task/_logs`). Retrieve async task execution logs. */
|
|
356
|
+
public taskDebugLogsPath(ctx: RouteContext, format = 'org.hl7.fhir.api'): string {
|
|
357
|
+
return this.v1Path(ctx, 'individual', format, 'Task', '_logs');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Submit path: DataConversion file upload.
|
|
362
|
+
* Pattern: `/{tenantId}/cds-{jurisdiction}/v1/{sector}/conversion/{softwareId}/{sourceFormat}/_upload`
|
|
363
|
+
* Use with `uploadConversionFile` to send a file (e.g. XLSX) for async processing.
|
|
364
|
+
*/
|
|
365
|
+
public conversionUploadPath(ctx: RouteContext, softwareId: string, sourceFormat: string): string {
|
|
366
|
+
return `/${encode(ctx.tenantId)}/cds-${encode(ctx.jurisdiction)}/v1/${encode(ctx.sector)}/conversion/${encode(softwareId)}/${encode(sourceFormat)}/_upload`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Poll path: DataConversion upload. Pair with `conversionUploadPath`. */
|
|
370
|
+
public conversionUploadPollPath(ctx: RouteContext, softwareId: string, sourceFormat: string): string {
|
|
371
|
+
return `/${encode(ctx.tenantId)}/cds-${encode(ctx.jurisdiction)}/v1/${encode(ctx.sector)}/conversion/${encode(softwareId)}/${encode(sourceFormat)}/_upload-response`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ---- Backend PKCE auth (identity-exchange.v1) -------------------------
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Orchestrates the full identity-exchange.v1 backend auth flow:
|
|
378
|
+
* DCR binding → PKCE code → token → SMART bearer exchange.
|
|
379
|
+
*
|
|
380
|
+
* Equivalent to Python connector_sdk `authenticate_backend_pkce_and_exchange`.
|
|
381
|
+
* Results are cached in memory; re-runs automatically on expiry.
|
|
382
|
+
*/
|
|
383
|
+
public async authenticateBackendPkceAndExchange(
|
|
384
|
+
options: BackendPkceAuthOptions,
|
|
385
|
+
): Promise<BackendPkceAuthResult> {
|
|
386
|
+
const {
|
|
387
|
+
ctx,
|
|
388
|
+
apiKey,
|
|
389
|
+
scopes,
|
|
390
|
+
endpointId = `pkce:${apiKey.slice(0, 8)}`,
|
|
391
|
+
codeVerifier = randomUUID(),
|
|
392
|
+
pollOptions,
|
|
393
|
+
} = options;
|
|
394
|
+
|
|
395
|
+
const cached = this._tokenCache.get(endpointId);
|
|
396
|
+
if (cached && cached.expiresAt > Date.now() + 30_000) {
|
|
397
|
+
return { status: 'cached', endpointId, accessToken: cached.accessToken, tokenType: cached.tokenType, scopes: cached.scopes };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const controllerPublicJwk = await this.resolveControllerPublicJwk(options);
|
|
401
|
+
|
|
402
|
+
// Step 1: DCR – bind API key to service public key
|
|
403
|
+
const dcrPayload = this._buildAuthDIDCommRequest({
|
|
404
|
+
thid: `dcr-${randomUUID()}`,
|
|
405
|
+
clientId: apiKey,
|
|
406
|
+
body: {},
|
|
407
|
+
controllerPublicJwk,
|
|
408
|
+
});
|
|
409
|
+
await this.submitBatch(this.identityDeviceDcrPath(ctx), dcrPayload);
|
|
410
|
+
const dcrPoll = await this.pollUntilComplete(
|
|
411
|
+
this.identityDeviceDcrPollPath(ctx),
|
|
412
|
+
{ thid: String(dcrPayload['thid']) },
|
|
413
|
+
pollOptions,
|
|
414
|
+
);
|
|
415
|
+
if (dcrPoll.status !== 200) {
|
|
416
|
+
return { status: 'failed', step: '_dcr', endpointId, accessToken: '', tokenType: 'Bearer', scopes };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Step 2: Code – PKCE S256 challenge
|
|
420
|
+
const codeChallenge = this._pkceS256Challenge(codeVerifier);
|
|
421
|
+
const codePayload = this._buildAuthDIDCommRequest({
|
|
422
|
+
thid: `code-${randomUUID()}`,
|
|
423
|
+
clientId: apiKey,
|
|
424
|
+
body: {},
|
|
425
|
+
controllerPublicJwk,
|
|
426
|
+
extra: { code_challenge: codeChallenge, code_challenge_method: 'S256' },
|
|
427
|
+
});
|
|
428
|
+
await this.submitBatch(this.identityCodePath(ctx), codePayload);
|
|
429
|
+
const codePoll = await this.pollUntilComplete(
|
|
430
|
+
this.identityCodePollPath(ctx),
|
|
431
|
+
{ thid: String(codePayload['thid']) },
|
|
432
|
+
pollOptions,
|
|
433
|
+
);
|
|
434
|
+
const codeBody = (codePoll.body as Record<string, unknown>) ?? {};
|
|
435
|
+
const code = String(codeBody['code'] ?? '').trim();
|
|
436
|
+
if (codePoll.status !== 200 || !code) {
|
|
437
|
+
return { status: 'failed', step: '_code', endpointId, accessToken: '', tokenType: 'Bearer', scopes };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Step 3: Token – exchange code + verifier for id_token
|
|
441
|
+
const tokenPayload = this._buildAuthDIDCommRequest({
|
|
442
|
+
thid: `token-${randomUUID()}`,
|
|
443
|
+
clientId: apiKey,
|
|
444
|
+
body: {},
|
|
445
|
+
controllerPublicJwk,
|
|
446
|
+
extra: { code, code_verifier: codeVerifier },
|
|
447
|
+
});
|
|
448
|
+
await this.submitBatch(this.identitySmartTokenPath(ctx), tokenPayload);
|
|
449
|
+
const tokenPoll = await this.pollUntilComplete(
|
|
450
|
+
this.identitySmartTokenPollPath(ctx),
|
|
451
|
+
{ thid: String(tokenPayload['thid']) },
|
|
452
|
+
pollOptions,
|
|
453
|
+
);
|
|
454
|
+
const tokenBody = (tokenPoll.body as Record<string, unknown>) ?? {};
|
|
455
|
+
const idToken = String(tokenBody['id_token'] ?? '').trim();
|
|
456
|
+
if (tokenPoll.status !== 200 || !idToken) {
|
|
457
|
+
return { status: 'failed', step: '_token', endpointId, accessToken: '', tokenType: 'Bearer', scopes };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Step 4: Exchange – id_token → SMART bearer
|
|
461
|
+
const exchangeThid = `exchange-${randomUUID()}`;
|
|
462
|
+
const exchangePayload: Record<string, unknown> = {
|
|
463
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
|
|
464
|
+
subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
|
|
465
|
+
subject_token: idToken,
|
|
466
|
+
scope: scopes.join(' '),
|
|
467
|
+
api_key: apiKey,
|
|
468
|
+
organization: ctx.tenantId,
|
|
469
|
+
thid: exchangeThid,
|
|
470
|
+
};
|
|
471
|
+
await this.submitBatch(this.identityTokenExchangePath(ctx), exchangePayload);
|
|
472
|
+
const exchangePoll = await this.pollUntilComplete(
|
|
473
|
+
this.identityTokenExchangePollPath(ctx),
|
|
474
|
+
{ thid: exchangeThid },
|
|
475
|
+
pollOptions,
|
|
476
|
+
);
|
|
477
|
+
const exchangeBody = (exchangePoll.body as Record<string, unknown>) ?? {};
|
|
478
|
+
const accessToken = String(exchangeBody['access_token'] ?? '').trim();
|
|
479
|
+
if (exchangePoll.status !== 200 || !accessToken) {
|
|
480
|
+
return { status: 'failed', step: '_exchange', endpointId, accessToken: '', tokenType: 'Bearer', scopes };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const tokenType = String(exchangeBody['token_type'] ?? 'Bearer');
|
|
484
|
+
const grantedScope = String(exchangeBody['scope'] ?? '').trim();
|
|
485
|
+
const grantedScopes = grantedScope ? grantedScope.split(' ').filter(Boolean) : scopes;
|
|
486
|
+
const expiresIn = Number(exchangeBody['expires_in'] ?? 0);
|
|
487
|
+
|
|
488
|
+
this._tokenCache.set(endpointId, {
|
|
489
|
+
accessToken,
|
|
490
|
+
tokenType,
|
|
491
|
+
scopes: grantedScopes,
|
|
492
|
+
expiresAt: Date.now() + expiresIn * 1000,
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
return { status: 'fetched', endpointId, accessToken, tokenType, scopes: grantedScopes };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Returns the cached SMART bearer for the given endpointId if still valid (>30s remaining).
|
|
500
|
+
* Returns `undefined` if not cached or expired.
|
|
501
|
+
*/
|
|
502
|
+
public getCachedBearerToken(endpointId: string): string | undefined {
|
|
503
|
+
const cached = this._tokenCache.get(endpointId);
|
|
504
|
+
if (cached && cached.expiresAt > Date.now() + 30_000) {
|
|
505
|
+
return cached.accessToken;
|
|
506
|
+
}
|
|
507
|
+
return undefined;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* smart-backend.v1: obtain an OAuth2 backend token using client_credentials + private_key_jwt.
|
|
512
|
+
*/
|
|
513
|
+
public async authenticateBackendSmartStandard(
|
|
514
|
+
options: BackendSmartAuthOptions,
|
|
515
|
+
): Promise<BackendSmartAuthResult> {
|
|
516
|
+
const {
|
|
517
|
+
clientId,
|
|
518
|
+
scopes,
|
|
519
|
+
endpointId = `smart-backend:${clientId}`,
|
|
520
|
+
tokenUrl,
|
|
521
|
+
tokenPath = '/token',
|
|
522
|
+
audience,
|
|
523
|
+
assertionTtlSeconds = 300,
|
|
524
|
+
additionalTokenFields,
|
|
525
|
+
} = options;
|
|
526
|
+
|
|
527
|
+
const cached = this._tokenCache.get(endpointId);
|
|
528
|
+
if (cached && cached.expiresAt > Date.now() + 30_000) {
|
|
529
|
+
return {
|
|
530
|
+
status: 'cached',
|
|
531
|
+
profile: 'smart-backend.v1',
|
|
532
|
+
endpointId,
|
|
533
|
+
accessToken: cached.accessToken,
|
|
534
|
+
tokenType: cached.tokenType,
|
|
535
|
+
scopes: cached.scopes,
|
|
536
|
+
expiresAt: new Date(cached.expiresAt).toISOString(),
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const resolvedTokenUrl = this.resolveStandardTokenUrl(tokenUrl, tokenPath);
|
|
541
|
+
const publicJwk = await this.resolveSmartAuthPublicJwk(options);
|
|
542
|
+
const clientAssertion = await this.signSmartBackendClientAssertion({
|
|
543
|
+
clientId,
|
|
544
|
+
audience: audience ?? resolvedTokenUrl,
|
|
545
|
+
publicJwk,
|
|
546
|
+
ttlSeconds: assertionTtlSeconds,
|
|
547
|
+
walletContext: options.walletContext,
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
const tokenRequest: Record<string, string> = {
|
|
551
|
+
grant_type: 'client_credentials',
|
|
552
|
+
client_id: clientId,
|
|
553
|
+
scope: scopes.join(' '),
|
|
554
|
+
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
|
555
|
+
client_assertion: clientAssertion,
|
|
556
|
+
...(additionalTokenFields ?? {}),
|
|
557
|
+
};
|
|
558
|
+
const response = await this.postJson(tokenUrl ?? tokenPath, tokenRequest);
|
|
559
|
+
const body = (response.body as Record<string, unknown>) ?? {};
|
|
560
|
+
const accessToken = String(body.access_token ?? '').trim();
|
|
561
|
+
|
|
562
|
+
if (response.status >= 400 || !accessToken) {
|
|
563
|
+
return {
|
|
564
|
+
status: 'failed',
|
|
565
|
+
profile: 'smart-backend.v1',
|
|
566
|
+
endpointId,
|
|
567
|
+
statusCode: response.status,
|
|
568
|
+
response,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const tokenType = String(body.token_type ?? 'Bearer');
|
|
573
|
+
const grantedScope = String(body.scope ?? '').trim();
|
|
574
|
+
const grantedScopes = grantedScope ? grantedScope.split(' ').filter(Boolean) : scopes;
|
|
575
|
+
const expiresIn = Number(body.expires_in ?? 0);
|
|
576
|
+
const expiresAt = Date.now() + expiresIn * 1000;
|
|
577
|
+
|
|
578
|
+
this._tokenCache.set(endpointId, {
|
|
579
|
+
accessToken,
|
|
580
|
+
tokenType,
|
|
581
|
+
scopes: grantedScopes,
|
|
582
|
+
expiresAt,
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
return {
|
|
586
|
+
status: 'fetched',
|
|
587
|
+
profile: 'smart-backend.v1',
|
|
588
|
+
endpointId,
|
|
589
|
+
statusCode: response.status,
|
|
590
|
+
accessToken,
|
|
591
|
+
tokenType,
|
|
592
|
+
scopes: grantedScopes,
|
|
593
|
+
expiresAt: new Date(expiresAt).toISOString(),
|
|
594
|
+
response,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Exchange token payload against gateway token endpoint and cache the result.
|
|
600
|
+
*/
|
|
601
|
+
public async requestSmartToken(input: SmartTokenExchangeInput): Promise<SmartTokenExchangeResult> {
|
|
602
|
+
const endpointId = String(input.endpointId || '').trim();
|
|
603
|
+
if (!endpointId) {
|
|
604
|
+
throw new Error('requestSmartToken requires endpointId.');
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const normalizedScopes = Array.from(new Set((input.scopes || []).filter(Boolean))).sort();
|
|
608
|
+
const cached = this._tokenCache.get(endpointId);
|
|
609
|
+
if (cached && cached.expiresAt > Date.now() + 30_000) {
|
|
610
|
+
return {
|
|
611
|
+
status: 'cached',
|
|
612
|
+
accessToken: cached.accessToken,
|
|
613
|
+
tokenType: cached.tokenType,
|
|
614
|
+
scopes: cached.scopes,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const response = await this.postJson(input.path || '/token', input.exchangePayload || {});
|
|
619
|
+
const body = (response.body as Record<string, unknown>) ?? {};
|
|
620
|
+
const accessToken = String(body.access_token ?? '').trim();
|
|
621
|
+
|
|
622
|
+
if (response.status >= 400 || !accessToken) {
|
|
623
|
+
return {
|
|
624
|
+
status: 'failed',
|
|
625
|
+
statusCode: response.status,
|
|
626
|
+
response,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const tokenType = String(body.token_type ?? 'Bearer');
|
|
631
|
+
const grantedScopes = Array.isArray(body.granted_scopes)
|
|
632
|
+
? (body.granted_scopes as string[])
|
|
633
|
+
: String(body.scope ?? '').trim().split(' ').filter(Boolean);
|
|
634
|
+
const resolvedScopes = grantedScopes.length ? grantedScopes : normalizedScopes;
|
|
635
|
+
const expiresIn = Number(body.expires_in ?? 0);
|
|
636
|
+
this._tokenCache.set(endpointId, {
|
|
637
|
+
accessToken,
|
|
638
|
+
tokenType,
|
|
639
|
+
scopes: resolvedScopes,
|
|
640
|
+
expiresAt: Date.now() + expiresIn * 1000,
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
return {
|
|
644
|
+
status: 'fetched',
|
|
645
|
+
accessToken,
|
|
646
|
+
tokenType,
|
|
647
|
+
scopes: resolvedScopes,
|
|
648
|
+
statusCode: response.status,
|
|
649
|
+
response,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// ---- Private auth helpers ----------------------------------------------
|
|
654
|
+
|
|
655
|
+
private _pkceS256Challenge(verifier: string): string {
|
|
656
|
+
return createHash('sha256').update(verifier).digest().toString('base64url');
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
private _buildAuthDIDCommRequest(params: {
|
|
660
|
+
thid: string;
|
|
661
|
+
clientId: string;
|
|
662
|
+
body: Record<string, unknown>;
|
|
663
|
+
controllerPublicJwk: PublicJwk | Record<string, unknown>;
|
|
664
|
+
extra?: Record<string, unknown>;
|
|
665
|
+
}): Record<string, unknown> {
|
|
666
|
+
const now = Math.floor(Date.now() / 1000);
|
|
667
|
+
return {
|
|
668
|
+
thid: params.thid,
|
|
669
|
+
type: 'application/bundle-api+json',
|
|
670
|
+
iat: now,
|
|
671
|
+
exp: now + 300,
|
|
672
|
+
client_id: params.clientId,
|
|
673
|
+
body: params.body,
|
|
674
|
+
meta: {
|
|
675
|
+
jws: {
|
|
676
|
+
protected: {
|
|
677
|
+
alg: 'ES384',
|
|
678
|
+
jwk: params.controllerPublicJwk,
|
|
679
|
+
},
|
|
680
|
+
},
|
|
681
|
+
},
|
|
682
|
+
...(params.extra ?? {}),
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
private async resolveControllerPublicJwk(
|
|
687
|
+
options: BackendPkceAuthOptions,
|
|
688
|
+
): Promise<PublicJwk | Record<string, unknown>> {
|
|
689
|
+
if (options.controllerPublicJwk) {
|
|
690
|
+
return options.controllerPublicJwk;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (!this.wallet) {
|
|
694
|
+
throw new Error('authenticateBackendPkceAndExchange requires controllerPublicJwk or a configured wallet provider.');
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const walletContext: WalletContext = options.walletContext ?? {
|
|
698
|
+
tenantId: options.ctx.tenantId,
|
|
699
|
+
jurisdiction: options.ctx.jurisdiction,
|
|
700
|
+
sector: options.ctx.sector,
|
|
701
|
+
};
|
|
702
|
+
const publicJwks = await this.wallet.getPublicJwks(walletContext);
|
|
703
|
+
const controllerPublicJwk = publicJwks.find((jwk) => jwk.use === 'sig' || jwk.alg === 'ES384') ?? publicJwks[0];
|
|
704
|
+
|
|
705
|
+
if (!controllerPublicJwk) {
|
|
706
|
+
throw new Error('Wallet provider returned no public JWKs for the requested context.');
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
return controllerPublicJwk;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
private resolveStandardTokenUrl(tokenUrl: string | undefined, tokenPath: string): string {
|
|
713
|
+
if (tokenUrl && tokenUrl.trim()) {
|
|
714
|
+
return tokenUrl.trim();
|
|
715
|
+
}
|
|
716
|
+
return `${this.baseUrl}${tokenPath.startsWith('/') ? tokenPath : `/${tokenPath}`}`;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
private async resolveSmartAuthPublicJwk(
|
|
720
|
+
options: BackendSmartAuthOptions,
|
|
721
|
+
): Promise<PublicJwk | Record<string, unknown>> {
|
|
722
|
+
if (options.publicJwk) {
|
|
723
|
+
return options.publicJwk;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (!this.wallet) {
|
|
727
|
+
throw new Error('authenticateBackendSmartStandard requires publicJwk or a configured wallet provider.');
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const walletContext: WalletContext = options.walletContext ?? {
|
|
731
|
+
tenantId: options.clientId,
|
|
732
|
+
jurisdiction: 'global',
|
|
733
|
+
sector: 'backend',
|
|
734
|
+
};
|
|
735
|
+
const publicJwks = await this.wallet.getPublicJwks(walletContext);
|
|
736
|
+
const signingJwk = publicJwks.find((jwk) => jwk.use === 'sig' || jwk.alg === 'ES384') ?? publicJwks[0];
|
|
737
|
+
|
|
738
|
+
if (!signingJwk) {
|
|
739
|
+
throw new Error('Wallet provider returned no public JWKs for smart-backend.v1.');
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
return signingJwk;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
private async signSmartBackendClientAssertion(params: {
|
|
746
|
+
clientId: string;
|
|
747
|
+
audience: string;
|
|
748
|
+
publicJwk: PublicJwk | Record<string, unknown>;
|
|
749
|
+
ttlSeconds: number;
|
|
750
|
+
walletContext?: WalletContext;
|
|
751
|
+
}): Promise<string> {
|
|
752
|
+
if (!this.wallet) {
|
|
753
|
+
throw new Error('smart-backend.v1 signing requires a configured wallet provider.');
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const now = Math.floor(Date.now() / 1000);
|
|
757
|
+
const walletContext: WalletContext = params.walletContext ?? {
|
|
758
|
+
tenantId: params.clientId,
|
|
759
|
+
jurisdiction: 'global',
|
|
760
|
+
sector: 'backend',
|
|
761
|
+
};
|
|
762
|
+
const kid = String((params.publicJwk as { kid?: string }).kid ?? '').trim();
|
|
763
|
+
|
|
764
|
+
return this.wallet.signCompactJws(walletContext, {
|
|
765
|
+
header: {
|
|
766
|
+
typ: 'JWT',
|
|
767
|
+
alg: this.preferredJwtAlg(params.publicJwk),
|
|
768
|
+
...(kid ? { kid } : {}),
|
|
769
|
+
},
|
|
770
|
+
claims: {
|
|
771
|
+
iss: params.clientId,
|
|
772
|
+
sub: params.clientId,
|
|
773
|
+
aud: params.audience,
|
|
774
|
+
iat: now,
|
|
775
|
+
exp: now + Math.max(params.ttlSeconds, 1),
|
|
776
|
+
jti: `jwt-${randomUUID()}`,
|
|
777
|
+
},
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
private preferredJwtAlg(publicJwk: PublicJwk | Record<string, unknown>): string {
|
|
782
|
+
const jwk = publicJwk as Record<string, unknown>;
|
|
783
|
+
const alg = String(jwk.alg ?? '').trim();
|
|
784
|
+
if (alg) {
|
|
785
|
+
return alg;
|
|
786
|
+
}
|
|
787
|
+
const kty = String(jwk.kty ?? '').toUpperCase();
|
|
788
|
+
const crv = String(jwk.crv ?? '').toUpperCase();
|
|
789
|
+
if (kty === 'EC' && crv === 'P-256') {
|
|
790
|
+
return 'ES256';
|
|
791
|
+
}
|
|
792
|
+
if (kty === 'EC' && crv === 'P-384') {
|
|
793
|
+
return 'ES384';
|
|
794
|
+
}
|
|
795
|
+
if (kty === 'RSA') {
|
|
796
|
+
return 'RS384';
|
|
797
|
+
}
|
|
798
|
+
return 'ES384';
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// ---- Generic batch API --------------------------------------------------
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* POST a DIDComm plaintext payload to a batch submit path.
|
|
805
|
+
* Use this for all `_batch` routes (family registration, observations, tasks, etc.).
|
|
806
|
+
* Content-Type: `application/didcomm-plaintext+json`.
|
|
807
|
+
*
|
|
808
|
+
* Returns immediately with the HTTP response — pair with `pollUntilComplete` or use `submitAndPoll`.
|
|
809
|
+
*/
|
|
810
|
+
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
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Sign and encrypt a DIDComm payload (nested JWS-in-JWE) and POST to the given path.
|
|
823
|
+
* Content-Type: `application/didcomm-encrypted+json`.
|
|
824
|
+
*
|
|
825
|
+
* Flow: `payload JSON → ES384 compact JWS → RSA-OAEP-256/A256GCM compact JWE → POST`
|
|
826
|
+
*
|
|
827
|
+
* Requires a wallet provider and the recipient's RSA encryption JWK
|
|
828
|
+
* (e.g. from GW `.well-known/jwks.json` where `use === 'enc'`).
|
|
829
|
+
*/
|
|
830
|
+
public async submitBatchEncrypted(
|
|
831
|
+
path: string,
|
|
832
|
+
payload: { thid?: string } & Record<string, unknown>,
|
|
833
|
+
recipientEncryptionJwk: PublicJwk,
|
|
834
|
+
walletContext: WalletContext,
|
|
835
|
+
): Promise<SubmitResponse> {
|
|
836
|
+
if (!this.wallet) {
|
|
837
|
+
throw new Error('submitBatchEncrypted requires a configured wallet provider.');
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const publicJwks = await this.wallet.getPublicJwks(walletContext);
|
|
841
|
+
const signingJwk = publicJwks.find((jwk) => jwk.use === 'sig' || jwk.alg === 'ES384') ?? publicJwks[0];
|
|
842
|
+
|
|
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
|
+
const url = /^https?:\/\//.test(path)
|
|
862
|
+
? path
|
|
863
|
+
: `${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
|
+
};
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* POST a plain JSON payload.
|
|
883
|
+
* Use for non-DIDComm routes (e.g. token exchange body, API key management).
|
|
884
|
+
* Content-Type: `application/json`.
|
|
885
|
+
*/
|
|
886
|
+
public async postJson(path: string, payload: unknown): Promise<SubmitResponse> {
|
|
887
|
+
const response = await this.doPost(path, payload, 'application/json');
|
|
888
|
+
const body = await this.parseResponseBody(response);
|
|
889
|
+
return {
|
|
890
|
+
status: response.status,
|
|
891
|
+
location: response.headers.get('location') ?? undefined,
|
|
892
|
+
body,
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* POST a multipart/form-data payload.
|
|
898
|
+
* Use for file upload endpoints. Prefer `uploadConversionFile` for DataConversion uploads.
|
|
899
|
+
*/
|
|
900
|
+
public async postFormData(path: string, formData: FormData): Promise<SubmitResponse> {
|
|
901
|
+
const url = `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
|
|
902
|
+
const headers: Record<string, string> = {
|
|
903
|
+
...this.defaultHeaders,
|
|
904
|
+
Accept: 'application/json, application/didcomm-plaintext+json, application/x-www-form-urlencoded, */*',
|
|
905
|
+
};
|
|
906
|
+
|
|
907
|
+
if (this.bearerToken) {
|
|
908
|
+
headers.Authorization = `Bearer ${this.bearerToken}`;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const response = await fetch(url, {
|
|
912
|
+
method: 'POST',
|
|
913
|
+
headers,
|
|
914
|
+
body: formData,
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
const body = await this.parseResponseBody(response);
|
|
918
|
+
return {
|
|
919
|
+
status: response.status,
|
|
920
|
+
location: response.headers.get('location') ?? undefined,
|
|
921
|
+
body,
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* Upload a file to a DataConversion endpoint.
|
|
927
|
+
* Wraps `postFormData` with sensible defaults for file field naming and multipart encoding.
|
|
928
|
+
*
|
|
929
|
+
* @param params.path - Use `conversionUploadPath(ctx, softwareId, sourceFormat)`.
|
|
930
|
+
* @param params.fileName - File name including extension (e.g. `data.xlsx`).
|
|
931
|
+
* @param params.fileContent - File bytes (Blob, Buffer, Uint8Array, or ArrayBuffer).
|
|
932
|
+
* @param params.fileFieldName - Form field name for the file. Defaults to `'file'`.
|
|
933
|
+
* @param params.fields - Additional form string fields to include.
|
|
934
|
+
*/
|
|
935
|
+
public async uploadConversionFile(params: {
|
|
936
|
+
path: string;
|
|
937
|
+
fileName: string;
|
|
938
|
+
fileContent: Blob | Buffer | Uint8Array | ArrayBuffer;
|
|
939
|
+
fileFieldName?: string;
|
|
940
|
+
fields?: Record<string, string>;
|
|
941
|
+
}): Promise<SubmitResponse> {
|
|
942
|
+
const form = new FormData();
|
|
943
|
+
const fileFieldName = params.fileFieldName ?? 'file';
|
|
944
|
+
const content =
|
|
945
|
+
params.fileContent instanceof Blob
|
|
946
|
+
? params.fileContent
|
|
947
|
+
: new Blob([params.fileContent as BlobPart]);
|
|
948
|
+
form.append(fileFieldName, content, params.fileName);
|
|
949
|
+
|
|
950
|
+
for (const [key, value] of Object.entries(params.fields ?? {})) {
|
|
951
|
+
form.append(key, value);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
return this.postFormData(params.path, form);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* Single poll attempt against a `_batch-response` or `_*-response` path.
|
|
959
|
+
* Returns HTTP 202 while the job is still processing, 200 (or other) when done.
|
|
960
|
+
* Prefer `pollUntilComplete` for automatic retry loops.
|
|
961
|
+
*/
|
|
962
|
+
public async pollBatchResponse(path: string, request: AsyncPollRequest): Promise<{ status: number; body: unknown }> {
|
|
963
|
+
const response = await this.doPost(path, request, 'application/json');
|
|
964
|
+
return {
|
|
965
|
+
status: response.status,
|
|
966
|
+
body: await this.parseResponseBody(response),
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Submit a DIDComm batch payload and poll until the async job completes.
|
|
972
|
+
* Convenience wrapper around `submitBatch` + `pollUntilComplete`.
|
|
973
|
+
*
|
|
974
|
+
* Requires `payload.thid` to be set (used as the poll correlation key).
|
|
975
|
+
* Use `createDidcommPlainMessage` from `builders.ts` to build the payload with a `thid`.
|
|
976
|
+
*
|
|
977
|
+
* @example
|
|
978
|
+
* const result = await client.submitAndPoll(
|
|
979
|
+
* client.individualFamilyOrganizationBatchPath(ctx),
|
|
980
|
+
* client.individualFamilyOrganizationPollPath(ctx),
|
|
981
|
+
* payload,
|
|
982
|
+
* { timeoutMs: 30_000, intervalMs: 2_000 },
|
|
983
|
+
* );
|
|
984
|
+
* // result.poll.status === 200 on success
|
|
985
|
+
*/
|
|
986
|
+
public async submitAndPoll(
|
|
987
|
+
submitPath: string,
|
|
988
|
+
pollPath: string,
|
|
989
|
+
payload: { thid?: string } & Record<string, unknown>,
|
|
990
|
+
options?: PollOptions,
|
|
991
|
+
): Promise<SubmitAndPollResult> {
|
|
992
|
+
const submit = await this.submitBatch(submitPath, payload);
|
|
993
|
+
|
|
994
|
+
const thid = String(payload.thid || '').trim();
|
|
995
|
+
if (!thid) {
|
|
996
|
+
throw new Error('submitAndPoll requires payload.thid.');
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const poll = await this.pollUntilComplete(pollPath, { thid }, options);
|
|
1000
|
+
return { submit, poll };
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Create scheduled phone reminder Task entries through canonical Task `_batch` routes.
|
|
1005
|
+
* This high-level helper accepts business parameters and internally builds flat
|
|
1006
|
+
* FHIR-style claims under `resource.meta.claims`.
|
|
1007
|
+
*
|
|
1008
|
+
* `description` is the Task title.
|
|
1009
|
+
* `reminderSummary` is the contextual summary of what the reminder refers to
|
|
1010
|
+
* (appointment, medication schedule, or another event), mapped to `based-on-display`.
|
|
1011
|
+
*/
|
|
1012
|
+
public async createPhoneReminderTasks(
|
|
1013
|
+
ctx: RouteContext,
|
|
1014
|
+
input: CreatePhoneReminderTasksInput,
|
|
1015
|
+
options?: PollOptions,
|
|
1016
|
+
): Promise<SubmitAndPollResult> {
|
|
1017
|
+
const windows = Array.isArray(input.windows) ? input.windows : [];
|
|
1018
|
+
if (!windows.length) {
|
|
1019
|
+
throw new Error('createPhoneReminderTasks requires at least one reminder window.');
|
|
1020
|
+
}
|
|
1021
|
+
if (!input.subjectRef || !input.ownerRef || !input.focusRef) {
|
|
1022
|
+
throw new Error('createPhoneReminderTasks requires subjectRef, ownerRef and focusRef.');
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const thid = `task-reminder-${randomUUID()}`;
|
|
1026
|
+
const maxAttempts = Number.isFinite(input.maxAttempts) ? Math.max(1, Math.floor(Number(input.maxAttempts))) : 3;
|
|
1027
|
+
const description = String(input.description || 'Reminder phone call').trim() || 'Reminder phone call';
|
|
1028
|
+
const reminderSummary = String(input.reminderSummary || input.appointmentSummary || '').trim();
|
|
1029
|
+
const dataType = String(input.dataType || 'Task').trim() || 'Task';
|
|
1030
|
+
|
|
1031
|
+
const data = windows.map((window) => {
|
|
1032
|
+
const offsetMinutes = Math.max(0, Math.floor(Number(window.offsetMinutes)));
|
|
1033
|
+
const remindAt = String(window.remindAt || '').trim();
|
|
1034
|
+
if (!remindAt) {
|
|
1035
|
+
throw new Error('createPhoneReminderTasks requires remindAt in every window.');
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
const taskIdSeed = [
|
|
1039
|
+
ctx.tenantId,
|
|
1040
|
+
ctx.jurisdiction,
|
|
1041
|
+
ctx.sector,
|
|
1042
|
+
input.subjectRef,
|
|
1043
|
+
input.ownerRef,
|
|
1044
|
+
input.focusRef,
|
|
1045
|
+
remindAt,
|
|
1046
|
+
String(offsetMinutes),
|
|
1047
|
+
].join('|');
|
|
1048
|
+
const taskId = `task-${createHash('sha256').update(taskIdSeed).digest('hex').slice(0, 24)}`;
|
|
1049
|
+
|
|
1050
|
+
const claims: Record<string, string> = {
|
|
1051
|
+
'@context': 'org.hl7.fhir.api',
|
|
1052
|
+
id: taskId,
|
|
1053
|
+
status: 'scheduled',
|
|
1054
|
+
subject: input.subjectRef,
|
|
1055
|
+
owner: input.ownerRef,
|
|
1056
|
+
focus: input.focusRef,
|
|
1057
|
+
'execution-period-start': remindAt,
|
|
1058
|
+
channel: 'phone',
|
|
1059
|
+
'trigger-type': 'phone-call',
|
|
1060
|
+
'timing-repeat-offset': String(offsetMinutes),
|
|
1061
|
+
'max-attempts': String(maxAttempts),
|
|
1062
|
+
};
|
|
1063
|
+
|
|
1064
|
+
if (input.locale) claims.language = String(input.locale);
|
|
1065
|
+
if (input.subjectDisplay) claims['subject-display'] = String(input.subjectDisplay);
|
|
1066
|
+
if (reminderSummary) claims['based-on-display'] = reminderSummary;
|
|
1067
|
+
if (input.notificationPhone) claims['subject-phone'] = String(input.notificationPhone);
|
|
1068
|
+
if (input.controllerPhone) claims['owner-phone'] = String(input.controllerPhone);
|
|
1069
|
+
if (input.callSid) claims['communication-request'] = String(input.callSid);
|
|
1070
|
+
|
|
1071
|
+
return {
|
|
1072
|
+
type: dataType,
|
|
1073
|
+
request: { method: 'POST' },
|
|
1074
|
+
resource: {
|
|
1075
|
+
resourceType: 'Task',
|
|
1076
|
+
id: taskId,
|
|
1077
|
+
description,
|
|
1078
|
+
meta: { claims },
|
|
1079
|
+
},
|
|
1080
|
+
};
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
const payload = createDidcommPlainMessage({
|
|
1084
|
+
iss: ctx.tenantId,
|
|
1085
|
+
aud: ctx.tenantId,
|
|
1086
|
+
thid,
|
|
1087
|
+
body: { data },
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
return this.submitAndPoll(
|
|
1091
|
+
this.individualTaskBatchPath(ctx),
|
|
1092
|
+
this.individualTaskPollPath(ctx),
|
|
1093
|
+
payload,
|
|
1094
|
+
options ?? { timeoutMs: 20_000, intervalMs: 1_000 },
|
|
1095
|
+
);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/** Endpoint path for medication overlap pre-check (planned GW contract). */
|
|
1099
|
+
public individualMedicationOverlapCheckPath(ctx: RouteContext): string {
|
|
1100
|
+
return this.v1Path(ctx, 'individual', 'org.hl7.fhir.api', 'MedicationStatement', '_overlap-check');
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* Pre-create overlap check for medication intake schedules.
|
|
1105
|
+
* TODO: Requires GW endpoint implementation (`MedicationStatement/_overlap-check`).
|
|
1106
|
+
*/
|
|
1107
|
+
public async checkMedicationScheduleOverlap(
|
|
1108
|
+
ctx: RouteContext,
|
|
1109
|
+
input: MedicationOverlapCheckInput,
|
|
1110
|
+
): Promise<SubmitResponse> {
|
|
1111
|
+
const payload = createDidcommPlainMessage({
|
|
1112
|
+
iss: ctx.tenantId,
|
|
1113
|
+
aud: ctx.tenantId,
|
|
1114
|
+
thid: `med-overlap-${randomUUID()}`,
|
|
1115
|
+
body: input as unknown as Record<string, unknown>,
|
|
1116
|
+
});
|
|
1117
|
+
return this.submitBatch(this.individualMedicationOverlapCheckPath(ctx), payload);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* High-level helper for medication reminder creation.
|
|
1122
|
+
* This creates one Task per explicit intake time and delegates reminder execution to GW daemon.
|
|
1123
|
+
* TODO: recurring interval expansion + overlap policy should be finalized in GW endpoint contract.
|
|
1124
|
+
*/
|
|
1125
|
+
public async createMedicationReminderTasks(
|
|
1126
|
+
ctx: RouteContext,
|
|
1127
|
+
input: MedicationRegistrationInput,
|
|
1128
|
+
options?: PollOptions,
|
|
1129
|
+
): Promise<SubmitAndPollResult> {
|
|
1130
|
+
const claims = (input.claims || {}) as Record<string, unknown>;
|
|
1131
|
+
const claimStart = String(
|
|
1132
|
+
claims[MedicationStatementClaimsFhirApiExtended.TimingBoundsPeriodStart] ||
|
|
1133
|
+
claims['MedicationStatement.timing-bounds-period-start'] ||
|
|
1134
|
+
claims[MedicationStatementClaimsFhirApi.Effective] ||
|
|
1135
|
+
claims['MedicationStatement.effective'] ||
|
|
1136
|
+
claims['DosageDetails.start'] ||
|
|
1137
|
+
claims['MedicationDetails.start'] ||
|
|
1138
|
+
'',
|
|
1139
|
+
).trim();
|
|
1140
|
+
const claimTimeOfDay =
|
|
1141
|
+
claims[MedicationStatementClaimsFhirApiExtended.TimingTimeOfDay] ??
|
|
1142
|
+
claims['MedicationStatement.timing-timeofday'] ??
|
|
1143
|
+
claims['Timing.repeat.timeOfDay'] ??
|
|
1144
|
+
claims['Timing.repeat.time-of-day'];
|
|
1145
|
+
const claimTimes = Array.isArray(claimTimeOfDay)
|
|
1146
|
+
? claimTimeOfDay
|
|
1147
|
+
: typeof claimTimeOfDay === 'string'
|
|
1148
|
+
? claimTimeOfDay.split(',').map((v) => v.trim()).filter(Boolean)
|
|
1149
|
+
: [];
|
|
1150
|
+
|
|
1151
|
+
const times = (Array.isArray(input.intakeTimes) ? input.intakeTimes : [])
|
|
1152
|
+
.concat(claimTimes.map((hhmm) => ({ hhmm: String(hhmm) })));
|
|
1153
|
+
if (!times.length) {
|
|
1154
|
+
throw new Error('createMedicationReminderTasks requires at least one intake time.');
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
const startDate = String(input.startDate || claimStart || '').trim();
|
|
1158
|
+
if (!startDate) {
|
|
1159
|
+
throw new Error('createMedicationReminderTasks requires startDate.');
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const windows = times.map((t) => {
|
|
1163
|
+
const hhmm = String(t.hhmm || '').trim();
|
|
1164
|
+
const remindAt = `${startDate}T${hhmm}:00.000Z`;
|
|
1165
|
+
return { offsetMinutes: 0, remindAt };
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
const medicationDescription = String(
|
|
1169
|
+
input.medicationDescription ||
|
|
1170
|
+
claims[MedicationStatementClaimsFhirApi.Medication] ||
|
|
1171
|
+
claims['MedicationStatement.medication'] ||
|
|
1172
|
+
claims['MedicationStatement.medication-display'] ||
|
|
1173
|
+
'Medication',
|
|
1174
|
+
).trim();
|
|
1175
|
+
const doseValue = String(
|
|
1176
|
+
input.doseValue ||
|
|
1177
|
+
claims[MedicationStatementClaimsFhirApiExtended.DoseQuantityValue] ||
|
|
1178
|
+
claims['MedicationStatement.dose-quantity-value'] ||
|
|
1179
|
+
claims['Dosage.quantity-value'] ||
|
|
1180
|
+
'',
|
|
1181
|
+
).trim();
|
|
1182
|
+
const doseUnitOrFormCode = String(
|
|
1183
|
+
input.doseUnitOrFormCode ||
|
|
1184
|
+
claims[MedicationStatementClaimsFhirApiExtended.DoseQuantityUnit] ||
|
|
1185
|
+
claims['MedicationStatement.dose-quantity-unit'] ||
|
|
1186
|
+
claims[MedicationStatementClaimsFhirApiExtended.DoseType] ||
|
|
1187
|
+
claims['MedicationStatement.dose-type'] ||
|
|
1188
|
+
claims['Dosage.quantity-unit'] ||
|
|
1189
|
+
claims['Dosage.form'] ||
|
|
1190
|
+
'',
|
|
1191
|
+
).trim();
|
|
1192
|
+
|
|
1193
|
+
const summary = `${medicationDescription} ${doseValue}${doseUnitOrFormCode ? ` ${doseUnitOrFormCode}` : ''}`.trim();
|
|
1194
|
+
return this.createPhoneReminderTasks(
|
|
1195
|
+
ctx,
|
|
1196
|
+
{
|
|
1197
|
+
windows,
|
|
1198
|
+
locale: input.locale,
|
|
1199
|
+
subjectRef: input.subjectRef,
|
|
1200
|
+
ownerRef: input.ownerRef,
|
|
1201
|
+
focusRef: `MedicationStatement/${createHash('sha256').update(summary + startDate).digest('hex').slice(0, 24)}`,
|
|
1202
|
+
subjectDisplay: medicationDescription,
|
|
1203
|
+
reminderSummary: summary,
|
|
1204
|
+
notificationPhone: input.notificationPhone,
|
|
1205
|
+
controllerPhone: input.controllerPhone,
|
|
1206
|
+
description: 'Medication reminder',
|
|
1207
|
+
maxAttempts: input.maxAttempts,
|
|
1208
|
+
},
|
|
1209
|
+
options,
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* Search for an existing family Organization registration by phone + usualname.
|
|
1215
|
+
* Submits to `individual/org.schema/Organization/_search`, polls for the result, and
|
|
1216
|
+
* parses the bundle entry into a `FamilyOrganizationSummary`.
|
|
1217
|
+
*
|
|
1218
|
+
* Returns `null` when no matching registration exists.
|
|
1219
|
+
*/
|
|
1220
|
+
public async searchFamilyOrganization(
|
|
1221
|
+
ctx: RouteContext,
|
|
1222
|
+
filters: { controllerPhone: string; usualname: string; birthDate?: string },
|
|
1223
|
+
options?: PollOptions,
|
|
1224
|
+
): Promise<FamilyOrganizationSummary | null> {
|
|
1225
|
+
const thid = `search-${randomUUID()}`;
|
|
1226
|
+
const claims: Record<string, unknown> = {
|
|
1227
|
+
'org.schema.Organization.owner.telephone': filters.controllerPhone,
|
|
1228
|
+
'org.schema.Organization.alternateName': filters.usualname,
|
|
1229
|
+
'org.schema.Service.category': ctx.sector,
|
|
1230
|
+
};
|
|
1231
|
+
if (filters.birthDate) {
|
|
1232
|
+
claims['org.schema.Organization.foundingDate'] = filters.birthDate;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
const payload = {
|
|
1236
|
+
jti: randomUUID(),
|
|
1237
|
+
thid,
|
|
1238
|
+
iss: ctx.tenantId,
|
|
1239
|
+
aud: ctx.tenantId,
|
|
1240
|
+
type: 'application/api+json',
|
|
1241
|
+
body: {
|
|
1242
|
+
data: [{
|
|
1243
|
+
type: 'Family-search-v1.0',
|
|
1244
|
+
meta: { claims }, // legacy compatibility
|
|
1245
|
+
resource: { meta: { claims } },
|
|
1246
|
+
}],
|
|
1247
|
+
},
|
|
1248
|
+
};
|
|
1249
|
+
|
|
1250
|
+
const result = await this.submitAndPoll(
|
|
1251
|
+
this.individualFamilyOrganizationSearchPath(ctx),
|
|
1252
|
+
this.individualFamilyOrganizationSearchPollPath(ctx),
|
|
1253
|
+
payload,
|
|
1254
|
+
options ?? { timeoutMs: 20_000, intervalMs: 1_000 },
|
|
1255
|
+
);
|
|
1256
|
+
|
|
1257
|
+
if (result.poll.status !== 200) return null;
|
|
1258
|
+
const entry = (result.poll.body as any)?.body?.data?.[0];
|
|
1259
|
+
if (!entry) return null;
|
|
1260
|
+
|
|
1261
|
+
const status = entry.meta?.claims?.['org.schema.FamilyRegistration.status'] as FamilyRegistrationStatus | undefined;
|
|
1262
|
+
if (!status || status === 'not_found') return null;
|
|
1263
|
+
|
|
1264
|
+
const subjectInfo: any = {
|
|
1265
|
+
identifierType: entry.meta?.claims?.['org.schema.Organization.identifier.additionalType'] as string | undefined,
|
|
1266
|
+
identifierValue: entry.meta?.claims?.['org.schema.Organization.identifier.value'] as string | undefined,
|
|
1267
|
+
nickname: entry.meta?.claims?.['org.schema.Organization.alternateName'] as string | undefined,
|
|
1268
|
+
birthDate: entry.meta?.claims?.['org.schema.Organization.foundingDate'] as string | undefined,
|
|
1269
|
+
telephone: entry.meta?.claims?.['org.schema.Organization.owner.telephone'] as string | undefined,
|
|
1270
|
+
};
|
|
1271
|
+
|
|
1272
|
+
return {
|
|
1273
|
+
status,
|
|
1274
|
+
offerId: entry.meta?.claims?.['org.schema.Offer.identifier'] as string | undefined,
|
|
1275
|
+
organizationId: entry.resource?.id as string | undefined,
|
|
1276
|
+
subjectInfo,
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* Activate tenant organization in GW from ICA-derived proof.
|
|
1282
|
+
*/
|
|
1283
|
+
public async activateOrganizationInGatewayFromIcaProof(
|
|
1284
|
+
ctx: HostRouteContext,
|
|
1285
|
+
input: GatewayOrganizationActivationInput,
|
|
1286
|
+
options?: PollOptions,
|
|
1287
|
+
): Promise<SubmitAndPollResult> {
|
|
1288
|
+
if (!input?.vpToken) {
|
|
1289
|
+
throw new Error('activateOrganizationInGatewayFromIcaProof requires vpToken.');
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
const claims: Record<string, unknown> = {
|
|
1293
|
+
'@context': 'org.schema',
|
|
1294
|
+
vp_token: input.vpToken,
|
|
1295
|
+
...(input.additionalClaims || {}),
|
|
1296
|
+
};
|
|
1297
|
+
if (input.organizationVc) claims['org.schema.OrganizationCredential.jwt'] = input.organizationVc;
|
|
1298
|
+
if (input.legalRepresentativeVc) {
|
|
1299
|
+
claims['org.schema.LegalRepresentativeCredential.jwt'] = input.legalRepresentativeVc;
|
|
1300
|
+
}
|
|
1301
|
+
if (input.regulatoryEvidence) claims['org.schema.Organization.regulatoryEvidence'] = input.regulatoryEvidence;
|
|
1302
|
+
|
|
1303
|
+
const payload = createDidcommPlainMessage({
|
|
1304
|
+
iss: 'did:web:controller.example.com',
|
|
1305
|
+
aud: 'did:web:host.example.com',
|
|
1306
|
+
body: {
|
|
1307
|
+
data: [
|
|
1308
|
+
{
|
|
1309
|
+
type: 'Organization-activation-request-v1.0',
|
|
1310
|
+
meta: { claims }, // legacy compatibility
|
|
1311
|
+
resource: { meta: { claims } },
|
|
1312
|
+
},
|
|
1313
|
+
],
|
|
1314
|
+
},
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
return this.submitAndPoll(
|
|
1318
|
+
this.hostRegistryOrganizationActivatePath(ctx),
|
|
1319
|
+
this.hostRegistryOrganizationActivatePollPath(ctx),
|
|
1320
|
+
payload,
|
|
1321
|
+
options,
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* Activate employee/member device by activation code exchange + DCR registration.
|
|
1327
|
+
*
|
|
1328
|
+
* Step 1. Exchange activation code using user id_token to obtain an initial access token.
|
|
1329
|
+
* Step 2. Register device keys through Device/_dcr authorized by that initial token.
|
|
1330
|
+
*/
|
|
1331
|
+
public async activateEmployeeDeviceWithActivationCode(
|
|
1332
|
+
ctx: RouteContext,
|
|
1333
|
+
input: EmployeeDeviceActivationInput,
|
|
1334
|
+
): Promise<EmployeeDeviceActivationResult> {
|
|
1335
|
+
const exchangePayload = {
|
|
1336
|
+
thid: `exchange-${randomUUID()}`,
|
|
1337
|
+
subject_token: input.activationCode,
|
|
1338
|
+
};
|
|
1339
|
+
|
|
1340
|
+
const exchangeClient = new DataspaceNodeClient({
|
|
1341
|
+
baseUrl: this.baseUrl,
|
|
1342
|
+
bearerToken: input.idToken,
|
|
1343
|
+
defaultHeaders: this.defaultHeaders,
|
|
1344
|
+
wallet: this.wallet,
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
const exchange = await exchangeClient.submitAndPoll(
|
|
1348
|
+
this.identityTokenExchangePath(ctx),
|
|
1349
|
+
this.identityTokenExchangePollPath(ctx),
|
|
1350
|
+
exchangePayload,
|
|
1351
|
+
input.pollOptions,
|
|
1352
|
+
);
|
|
1353
|
+
|
|
1354
|
+
const exchangeBody = (exchange.poll.body as any)?.body || (exchange.poll.body as any) || {};
|
|
1355
|
+
const initialAccessToken = String(
|
|
1356
|
+
exchangeBody.initial_access_token || exchangeBody.access_token || '',
|
|
1357
|
+
).trim();
|
|
1358
|
+
if (!initialAccessToken) {
|
|
1359
|
+
throw new Error('activateEmployeeDeviceWithActivationCode: missing initial_access_token in exchange response.');
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
const dcrPayload = {
|
|
1363
|
+
thid: `dcr-${randomUUID()}`,
|
|
1364
|
+
...input.dcrPayload,
|
|
1365
|
+
};
|
|
1366
|
+
|
|
1367
|
+
const dcrClient = new DataspaceNodeClient({
|
|
1368
|
+
baseUrl: this.baseUrl,
|
|
1369
|
+
bearerToken: initialAccessToken,
|
|
1370
|
+
defaultHeaders: this.defaultHeaders,
|
|
1371
|
+
wallet: this.wallet,
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
const dcr = await dcrClient.submitAndPoll(
|
|
1375
|
+
this.identityDeviceDcrPath(ctx),
|
|
1376
|
+
this.identityDeviceDcrPollPath(ctx),
|
|
1377
|
+
dcrPayload,
|
|
1378
|
+
input.pollOptions,
|
|
1379
|
+
);
|
|
1380
|
+
|
|
1381
|
+
return {
|
|
1382
|
+
initialAccessToken,
|
|
1383
|
+
exchange,
|
|
1384
|
+
dcr,
|
|
1385
|
+
};
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/**
|
|
1389
|
+
* UC 5.3 wrapper: create organization employee in entity Employee batch route.
|
|
1390
|
+
*/
|
|
1391
|
+
public async createOrganizationEmployee(
|
|
1392
|
+
ctx: RouteContext,
|
|
1393
|
+
input: OrganizationEmployeeCreationInput,
|
|
1394
|
+
options?: PollOptions,
|
|
1395
|
+
): Promise<SubmitAndPollResult> {
|
|
1396
|
+
const payload = createDidcommPlainMessage({
|
|
1397
|
+
iss: ctx.tenantId,
|
|
1398
|
+
aud: ctx.tenantId,
|
|
1399
|
+
thid: `employee-${randomUUID()}`,
|
|
1400
|
+
body: {
|
|
1401
|
+
data: [
|
|
1402
|
+
{
|
|
1403
|
+
type: input.dataType || 'Employee-create-request-v1.0',
|
|
1404
|
+
meta: { claims: input.employeeClaims || {} }, // legacy compatibility
|
|
1405
|
+
resource: { meta: { claims: input.employeeClaims || {} } },
|
|
1406
|
+
},
|
|
1407
|
+
],
|
|
1408
|
+
},
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
return this.submitAndPoll(
|
|
1412
|
+
this.employeeBatchPath(ctx),
|
|
1413
|
+
this.employeePollPath(ctx),
|
|
1414
|
+
payload,
|
|
1415
|
+
options,
|
|
1416
|
+
);
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/**
|
|
1420
|
+
* UC 5.1 wrapper: bootstrap subject organization context via registration + optional order confirmation.
|
|
1421
|
+
*/
|
|
1422
|
+
public async bootstrapSubjectOrganizationIndex(
|
|
1423
|
+
ctx: RouteContext,
|
|
1424
|
+
input: SubjectOrganizationBootstrapInput,
|
|
1425
|
+
): Promise<SubjectOrganizationBootstrapResult> {
|
|
1426
|
+
const registrationPayload = {
|
|
1427
|
+
thid: input.registrationPayload.thid || `family-org-${randomUUID()}`,
|
|
1428
|
+
...input.registrationPayload,
|
|
1429
|
+
};
|
|
1430
|
+
|
|
1431
|
+
const registration = await this.submitAndPoll(
|
|
1432
|
+
this.individualFamilyOrganizationBatchPath(ctx),
|
|
1433
|
+
this.individualFamilyOrganizationPollPath(ctx),
|
|
1434
|
+
registrationPayload,
|
|
1435
|
+
input.pollOptions,
|
|
1436
|
+
);
|
|
1437
|
+
|
|
1438
|
+
if (!input.confirmationPayload) {
|
|
1439
|
+
return { registration };
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
const confirmationPayload = {
|
|
1443
|
+
thid: input.confirmationPayload.thid || `family-order-${randomUUID()}`,
|
|
1444
|
+
...input.confirmationPayload,
|
|
1445
|
+
};
|
|
1446
|
+
|
|
1447
|
+
const confirmation = await this.submitAndPoll(
|
|
1448
|
+
this.individualFamilyOrderBatchPath(ctx),
|
|
1449
|
+
this.individualFamilyOrderPollPath(ctx),
|
|
1450
|
+
confirmationPayload,
|
|
1451
|
+
input.pollOptions,
|
|
1452
|
+
);
|
|
1453
|
+
|
|
1454
|
+
return { registration, confirmation };
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/**
|
|
1458
|
+
* UC 5.5 wrapper: import IPS/FHIR composition and update subject index context.
|
|
1459
|
+
*/
|
|
1460
|
+
public async importIpsOrFhirAndUpdateIndex(
|
|
1461
|
+
ctx: RouteContext,
|
|
1462
|
+
input: IpsOrFhirImportInput,
|
|
1463
|
+
): Promise<SubmitAndPollResult> {
|
|
1464
|
+
const payload = {
|
|
1465
|
+
thid: input.compositionPayload.thid || `composition-${randomUUID()}`,
|
|
1466
|
+
...input.compositionPayload,
|
|
1467
|
+
};
|
|
1468
|
+
|
|
1469
|
+
const submitPath = (input.format || 'r4') === 'api'
|
|
1470
|
+
? this.individualCompositionR4BatchPath(ctx).replace('/org.hl7.fhir.r4/', '/org.hl7.fhir.api/')
|
|
1471
|
+
: this.individualCompositionR4BatchPath(ctx);
|
|
1472
|
+
const pollPath = (input.format || 'r4') === 'api'
|
|
1473
|
+
? this.individualCompositionR4PollPath(ctx).replace('/org.hl7.fhir.r4/', '/org.hl7.fhir.api/')
|
|
1474
|
+
: this.individualCompositionR4PollPath(ctx);
|
|
1475
|
+
|
|
1476
|
+
return this.submitAndPoll(submitPath, pollPath, payload, input.pollOptions);
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
/**
|
|
1480
|
+
* UC 5.6 consent helper from minimal frontend fields.
|
|
1481
|
+
* Builds canonical Consent claims and submits/polls the Consent batch.
|
|
1482
|
+
*/
|
|
1483
|
+
public async grantProfessionalAccessSimple(
|
|
1484
|
+
ctx: RouteContext,
|
|
1485
|
+
input: GrantProfessionalAccessSimpleInput,
|
|
1486
|
+
): Promise<GrantProfessionalAccessSimpleResult> {
|
|
1487
|
+
const built = buildConsentClaimsSimpleWithCid(
|
|
1488
|
+
{
|
|
1489
|
+
subjectDid: input.subjectDid,
|
|
1490
|
+
subjectPhone: input.subjectPhone,
|
|
1491
|
+
subjectGivenName: input.subjectGivenName,
|
|
1492
|
+
actor: input.actor || {},
|
|
1493
|
+
actorRole: input.actorRole,
|
|
1494
|
+
purpose: input.purpose,
|
|
1495
|
+
actions: input.actions,
|
|
1496
|
+
consentIdentifier: input.consentIdentifier,
|
|
1497
|
+
consentDate: input.consentDate,
|
|
1498
|
+
decision: input.decision,
|
|
1499
|
+
attachmentContentType: input.attachmentContentType,
|
|
1500
|
+
attachmentBase64: input.attachmentBase64,
|
|
1501
|
+
},
|
|
1502
|
+
{
|
|
1503
|
+
consentIdentifierFactory: () => `urn:uuid:${randomUUID()}`,
|
|
1504
|
+
},
|
|
1505
|
+
);
|
|
1506
|
+
|
|
1507
|
+
const thid = `consent-${randomUUID()}`;
|
|
1508
|
+
const consentPayload = {
|
|
1509
|
+
thid,
|
|
1510
|
+
body: {
|
|
1511
|
+
data: [
|
|
1512
|
+
{
|
|
1513
|
+
type: input.dataType || 'Consent-grant-request-v1.0',
|
|
1514
|
+
meta: { claims: built.consentClaims }, // legacy compatibility
|
|
1515
|
+
resource: { meta: { claims: built.consentClaims } },
|
|
1516
|
+
},
|
|
1517
|
+
],
|
|
1518
|
+
},
|
|
1519
|
+
};
|
|
1520
|
+
const consent = await this.submitAndPoll(
|
|
1521
|
+
this.individualConsentR4BatchPath(ctx),
|
|
1522
|
+
this.individualConsentR4PollPath(ctx),
|
|
1523
|
+
consentPayload,
|
|
1524
|
+
input.pollOptions,
|
|
1525
|
+
);
|
|
1526
|
+
|
|
1527
|
+
return {
|
|
1528
|
+
thid,
|
|
1529
|
+
consent,
|
|
1530
|
+
actorIdentifier: built.actorIdentifier,
|
|
1531
|
+
subjectIdentifier: built.subjectIdentifier,
|
|
1532
|
+
consentClaims: built.consentClaims,
|
|
1533
|
+
claimsCid: built.claimsCid,
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
/**
|
|
1538
|
+
* UC 5.7 wrapper: generate digital twin composition from subject data.
|
|
1539
|
+
*/
|
|
1540
|
+
public async generateDigitalTwinFromSubjectData(
|
|
1541
|
+
ctx: RouteContext,
|
|
1542
|
+
input: DigitalTwinGenerationInput,
|
|
1543
|
+
): Promise<SubmitAndPollResult> {
|
|
1544
|
+
const payload = {
|
|
1545
|
+
thid: input.compositionPayload.thid || `digital-twin-${randomUUID()}`,
|
|
1546
|
+
...input.compositionPayload,
|
|
1547
|
+
};
|
|
1548
|
+
|
|
1549
|
+
const submitPath = (input.format || 'r4') === 'api'
|
|
1550
|
+
? this.digitalTwinCompositionApiBatchPath(ctx)
|
|
1551
|
+
: this.digitalTwinCompositionR4BatchPath(ctx);
|
|
1552
|
+
const pollPath = (input.format || 'r4') === 'api'
|
|
1553
|
+
? this.digitalTwinCompositionApiPollPath(ctx)
|
|
1554
|
+
: this.digitalTwinCompositionR4PollPath(ctx);
|
|
1555
|
+
|
|
1556
|
+
return this.submitAndPoll(submitPath, pollPath, payload, input.pollOptions);
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
/**
|
|
1560
|
+
* Poll a `_*-response` path repeatedly until the status is no longer 202.
|
|
1561
|
+
* Default: 60s timeout, 2s interval.
|
|
1562
|
+
* Throws if timeout is exceeded.
|
|
1563
|
+
*
|
|
1564
|
+
* @param path - Poll path (e.g. `individualFamilyOrganizationPollPath(ctx)`).
|
|
1565
|
+
* @param request - Must include `thid` matching the original submit payload.
|
|
1566
|
+
* @param options - `timeoutMs` (default 60000) and `intervalMs` (default 2000).
|
|
1567
|
+
*/
|
|
1568
|
+
public async pollUntilComplete(path: string, request: AsyncPollRequest, options?: PollOptions): Promise<PollResult> {
|
|
1569
|
+
const timeoutMs = options?.timeoutMs ?? 60_000;
|
|
1570
|
+
const intervalMs = options?.intervalMs ?? 2_000;
|
|
1571
|
+
const startedAt = Date.now();
|
|
1572
|
+
let attempts = 0;
|
|
1573
|
+
|
|
1574
|
+
while (true) {
|
|
1575
|
+
attempts += 1;
|
|
1576
|
+
const result = await this.pollBatchResponse(path, request);
|
|
1577
|
+
|
|
1578
|
+
if (result.status !== 202) {
|
|
1579
|
+
return {
|
|
1580
|
+
status: result.status,
|
|
1581
|
+
body: result.body,
|
|
1582
|
+
attempts,
|
|
1583
|
+
};
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
if (Date.now() - startedAt > timeoutMs) {
|
|
1587
|
+
throw new Error(`Polling timeout after ${attempts} attempts (${timeoutMs}ms).`);
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
// ---- Internal HTTP helpers ---------------------------------------------
|
|
1595
|
+
|
|
1596
|
+
private async doPost(path: string, payload: unknown, contentType: string): Promise<Response> {
|
|
1597
|
+
const url = /^https?:\/\//.test(path)
|
|
1598
|
+
? path
|
|
1599
|
+
: `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
|
|
1600
|
+
|
|
1601
|
+
const headers: Record<string, string> = {
|
|
1602
|
+
...this.defaultHeaders,
|
|
1603
|
+
'Content-Type': contentType,
|
|
1604
|
+
Accept: 'application/json, application/didcomm-plaintext+json, application/x-www-form-urlencoded, */*',
|
|
1605
|
+
};
|
|
1606
|
+
|
|
1607
|
+
if (this.bearerToken) {
|
|
1608
|
+
headers.Authorization = `Bearer ${this.bearerToken}`;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
return fetch(url, {
|
|
1612
|
+
method: 'POST',
|
|
1613
|
+
headers,
|
|
1614
|
+
body: JSON.stringify(payload),
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
private async parseResponseBody(response: Response): Promise<unknown> {
|
|
1619
|
+
const contentType = response.headers.get('content-type') || '';
|
|
1620
|
+
if (contentType.includes('application/json') || contentType.includes('application/didcomm-plaintext+json')) {
|
|
1621
|
+
return response.json().catch(() => ({}));
|
|
1622
|
+
}
|
|
1623
|
+
const text = await response.text();
|
|
1624
|
+
return text;
|
|
1625
|
+
}
|
|
1626
|
+
}
|