gdc-common-utils-ts 2.3.5 → 2.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -59,6 +59,10 @@ boundaries used in `gdc-common-utils-ts`.
59
59
  - Only define custom names when no canonical FHIR SearchParameter exists.
60
60
  - `resource.meta.claims` is the canonical project-specific claims container and must be preserved across conversions/transports.
61
61
  - `resource.meta.claims` is not part of base FHIR; it is a claims-first extension carried by FHIR-like resources in GDC contracts.
62
+ - Native FHIR resources received from EHR systems do not need that extension:
63
+ normalize them with `normalizeClaimsFromFhirResource(...)` at the processing
64
+ boundary before indexed storage. Existing `resource.meta.claims` take
65
+ precedence so SDK-authored semantics survive transport conversion.
62
66
 
63
67
  ## Identity Continuity
64
68
 
@@ -17,6 +17,9 @@ export function allergyIntoleranceFlatToFhirR4(claims) {
17
17
  code: claims[AllergyIntoleranceClaim.Code] ? { coding: codingFromValue(claims[AllergyIntoleranceClaim.Code]) } : undefined,
18
18
  clinicalStatus: claims[AllergyIntoleranceClaim.ClinicalStatus] ? { coding: [{ code: claims[AllergyIntoleranceClaim.ClinicalStatus] }] } : undefined,
19
19
  verificationStatus: claims[AllergyIntoleranceClaim.VerificationStatus] ? { coding: [{ code: claims[AllergyIntoleranceClaim.VerificationStatus] }] } : undefined,
20
+ category: claims[AllergyIntoleranceClaim.Category] ? [claims[AllergyIntoleranceClaim.Category]] : undefined,
21
+ criticality: claims[AllergyIntoleranceClaim.Criticality],
22
+ onsetDateTime: claims[AllergyIntoleranceClaim.OnsetDateTime],
20
23
  recorder: recorder ? { reference: recorder } : undefined,
21
24
  };
22
25
  }
@@ -30,6 +33,9 @@ export function allergyIntoleranceFhirR4ToFlat(resource) {
30
33
  [AllergyIntoleranceClaim.Code]: codingToValue(code?.coding?.[0]) || code?.text,
31
34
  [AllergyIntoleranceClaim.ClinicalStatus]: resource.clinicalStatus?.coding?.[0]?.code,
32
35
  [AllergyIntoleranceClaim.VerificationStatus]: resource.verificationStatus?.coding?.[0]?.code,
36
+ [AllergyIntoleranceClaim.Category]: resource.category?.[0],
37
+ [AllergyIntoleranceClaim.Criticality]: resource.criticality,
38
+ [AllergyIntoleranceClaim.OnsetDateTime]: resource.onsetDateTime,
33
39
  [AllergyIntoleranceClaim.Recorder]: resource.recorder?.reference,
34
40
  };
35
41
  }
@@ -1,5 +1,5 @@
1
1
  import type { ClaimsRecord } from '../models/resource-document';
2
- import { type GaiaXCredentialDraft, type GaiaXLegalPersonCredentialSubject, type GaiaXServiceOfferingCredentialSubject, type GaiaXVcJwtAttachment, type IcaMemberDiscoveryData } from '../models/gaia-x';
2
+ import { type GaiaXCredentialAttachmentRoleValue, type GaiaXCredentialDraft, type GaiaXLegalPersonCredentialSubject, type GaiaXServiceOfferingCredentialSubject, type GaiaXVcJwtAttachment, type IcaMemberDiscoveryData } from '../models/gaia-x';
3
3
  import type { DidDocument } from '../models/did';
4
4
  export interface SchemaOrgOrganizationRegistrationIdentifier {
5
5
  additionalType: string;
@@ -94,6 +94,27 @@ export declare function buildGaiaXVcJwtAttachment(input: Readonly<{
94
94
  jwt: string;
95
95
  role: GaiaXVcJwtAttachment['role'];
96
96
  }>): GaiaXVcJwtAttachment;
97
+ /**
98
+ * Enforces the semantic contract of a signed Gaia-X discovery VC-JWT.
99
+ *
100
+ * This assertion deliberately does not verify the cryptographic signature.
101
+ * Signature, issuer, status and trust-chain verification remain the verifier's
102
+ * responsibility. It prevents a validly shaped schema.org
103
+ * OrganizationCredential from being merely serialized as JWT and mislabeled
104
+ * as the distinct Gaia-X participant credential.
105
+ *
106
+ * Participant attachments require `gx:LegalPerson` with the ICAM 25.11 legal
107
+ * properties. Service-offering attachments require `gx:ServiceOffering`,
108
+ * `gx:providedBy` and `gx:serviceOfferingTermsAndConditions`.
109
+ *
110
+ * This validates member-level `data[].attachments[]`; it does not describe
111
+ * `_retrieve?format=vc+jwt` or credential-internal
112
+ * `credential.evidence[].attachments`.
113
+ *
114
+ * @see https://docs.gaia-x.eu/technical-committee/identity-credential-access-management/25.11/gaia-x_credentials/
115
+ * @see https://docs.gaia-x.eu/technical-committee/identity-credential-access-management/25.11/semantic_model/
116
+ */
117
+ export declare function assertGaiaXDiscoveryAttachmentSemantics(jwt: string, role: GaiaXCredentialAttachmentRoleValue): void;
97
118
  /**
98
119
  * Assembles one ICA member discovery entry and enforces the interoperable
99
120
  * ordering contract: schema.org OrganizationCredential first in `vc[]`, and
@@ -174,14 +174,90 @@ export function buildGaiaXParticipantAttachment(input) {
174
174
  }
175
175
  /** Wraps an exact signed Gaia-X VC-JWT without decoding or re-signing it. */
176
176
  export function buildGaiaXVcJwtAttachment(input) {
177
+ const jwt = requiredInput(input.jwt, 'VC-JWT');
178
+ assertGaiaXDiscoveryAttachmentSemantics(jwt, input.role);
177
179
  return {
178
180
  id: requiredInput(input.id, 'attachment id'),
179
181
  format: GaiaXCredentialAttachmentFormat,
180
182
  role: input.role,
181
183
  media_type: GaiaXCredentialMediaType.VcJwt,
182
- data: { json: { jwt: requiredInput(input.jwt, 'VC-JWT') } },
184
+ data: { json: { jwt } },
183
185
  };
184
186
  }
187
+ function decodeVcJwtCredential(jwt) {
188
+ const parts = jwt.split('.');
189
+ if (parts.length !== 3 || parts.some((part) => !part)) {
190
+ throw new Error('Gaia-X attachment must contain one compact three-part VC-JWT.');
191
+ }
192
+ let payload;
193
+ try {
194
+ payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
195
+ }
196
+ catch {
197
+ throw new Error('Gaia-X attachment VC-JWT payload is not valid base64url JSON.');
198
+ }
199
+ return asObject(payload.vc) || payload;
200
+ }
201
+ function gaiaXCredentialSubject(document) {
202
+ const raw = document.credentialSubject;
203
+ if (Array.isArray(raw)) {
204
+ return raw.map(asObject).find((value) => Boolean(value));
205
+ }
206
+ return asObject(raw);
207
+ }
208
+ function requireGaiaXProperties(subject, names, role) {
209
+ const missing = names.filter((name) => !(name in subject));
210
+ if (missing.length) {
211
+ throw new Error(`Gaia-X ${role} VC-JWT is missing required semantic properties: ${missing.join(', ')}.`);
212
+ }
213
+ }
214
+ /**
215
+ * Enforces the semantic contract of a signed Gaia-X discovery VC-JWT.
216
+ *
217
+ * This assertion deliberately does not verify the cryptographic signature.
218
+ * Signature, issuer, status and trust-chain verification remain the verifier's
219
+ * responsibility. It prevents a validly shaped schema.org
220
+ * OrganizationCredential from being merely serialized as JWT and mislabeled
221
+ * as the distinct Gaia-X participant credential.
222
+ *
223
+ * Participant attachments require `gx:LegalPerson` with the ICAM 25.11 legal
224
+ * properties. Service-offering attachments require `gx:ServiceOffering`,
225
+ * `gx:providedBy` and `gx:serviceOfferingTermsAndConditions`.
226
+ *
227
+ * This validates member-level `data[].attachments[]`; it does not describe
228
+ * `_retrieve?format=vc+jwt` or credential-internal
229
+ * `credential.evidence[].attachments`.
230
+ *
231
+ * @see https://docs.gaia-x.eu/technical-committee/identity-credential-access-management/25.11/gaia-x_credentials/
232
+ * @see https://docs.gaia-x.eu/technical-committee/identity-credential-access-management/25.11/semantic_model/
233
+ */
234
+ export function assertGaiaXDiscoveryAttachmentSemantics(jwt, role) {
235
+ const document = decodeVcJwtCredential(jwt);
236
+ const subject = gaiaXCredentialSubject(document);
237
+ if (!subject)
238
+ throw new Error(`Gaia-X ${role} VC-JWT requires one credentialSubject object.`);
239
+ const subjectType = asString(subject.type);
240
+ if (role === GaiaXCredentialAttachmentRole.Participant) {
241
+ if (subjectType !== 'gx:LegalPerson') {
242
+ throw new Error('Gaia-X participant VC-JWT credentialSubject.type must be gx:LegalPerson.');
243
+ }
244
+ requireGaiaXProperties(subject, [
245
+ 'gx:legalName',
246
+ 'gx:legalRegistrationNumber',
247
+ 'gx:headquarterAddress',
248
+ 'gx:legalAddress',
249
+ ], 'participant');
250
+ }
251
+ if (role === GaiaXCredentialAttachmentRole.ServiceOffering) {
252
+ if (subjectType !== 'gx:ServiceOffering') {
253
+ throw new Error('Gaia-X service-offering VC-JWT credentialSubject.type must be gx:ServiceOffering.');
254
+ }
255
+ requireGaiaXProperties(subject, [
256
+ 'gx:providedBy',
257
+ 'gx:serviceOfferingTermsAndConditions',
258
+ ], 'service-offering');
259
+ }
260
+ }
185
261
  /**
186
262
  * Assembles one ICA member discovery entry and enforces the interoperable
187
263
  * ordering contract: schema.org OrganizationCredential first in `vc[]`, and
@@ -88,9 +88,7 @@ export declare const EXAMPLE_CONSENT_GRANT_INPUT: {
88
88
  };
89
89
  export declare const EXAMPLE_LIVE_CONSENT_GRANT_INPUT: {
90
90
  readonly subjectDid: string;
91
- readonly actor: {
92
- readonly identifier: "did:web:api.acme.org";
93
- };
91
+ readonly actorId: string;
94
92
  readonly actorRole: "ISCO-08|2211";
95
93
  readonly purpose: "TREAT";
96
94
  readonly actions: readonly [string];
@@ -1,5 +1,5 @@
1
1
  // Copyright 2026 Antifraud Services Inc. under the Apache License, Version 2.0.
2
- import { EXAMPLE_CONTROLLER_DID, EXAMPLE_API_ORGANIZATION_DID, EXAMPLE_CLINICAL_CODE_PROBLEM, EXAMPLE_CLINICAL_DATE_RANGE, EXAMPLE_CLINICAL_SECTION_ALLERGIES, EXAMPLE_CLINICAL_SECTION_PATIENT_SUMMARY, EXAMPLE_CONSENT_PURPOSE_TREATMENT, EXAMPLE_EMAIL_CONTROLLER_INDIVIDUAL, EXAMPLE_GENERIC_SUBJECT_DID, EXAMPLE_HEALTHCARE_ACTOR_ROLE_PHYSICIAN, EXAMPLE_HEALTHCARE_JURISDICTION, EXAMPLE_HEALTHCARE_ROLE_PHYSICIAN_TEXT, EXAMPLE_PRACTITIONER_DID, EXAMPLE_PROFESSIONAL_DID, EXAMPLE_SUBJECT_DID, EXAMPLE_FORM_CONTROLLER_PHONE, } from './shared.js';
2
+ import { EXAMPLE_CONTROLLER_DID, EXAMPLE_CLINICAL_CODE_PROBLEM, EXAMPLE_CLINICAL_DATE_RANGE, EXAMPLE_CLINICAL_SECTION_ALLERGIES, EXAMPLE_CLINICAL_SECTION_PATIENT_SUMMARY, EXAMPLE_CONSENT_PURPOSE_TREATMENT, EXAMPLE_EMAIL_CONTROLLER_INDIVIDUAL, EXAMPLE_GENERIC_SUBJECT_DID, EXAMPLE_HEALTHCARE_ACTOR_ROLE_GENERALIST_MEDICAL_PRACTITIONER, EXAMPLE_HEALTHCARE_JURISDICTION, EXAMPLE_HEALTHCARE_ROLE_PHYSICIAN_TEXT, EXAMPLE_PRACTITIONER_DID, EXAMPLE_PROFESSIONAL_DID, EXAMPLE_SUBJECT_DID, EXAMPLE_FORM_CONTROLLER_PHONE, } from './shared.js';
3
3
  export const EXAMPLE_INDIVIDUAL_ORGANIZATION_START_INPUT = {
4
4
  alternateName: 'ana',
5
5
  controllerEmail: EXAMPLE_EMAIL_CONTROLLER_INDIVIDUAL,
@@ -57,8 +57,8 @@ export const EXAMPLE_CONSENT_GRANT_INPUT = {
57
57
  };
58
58
  export const EXAMPLE_LIVE_CONSENT_GRANT_INPUT = {
59
59
  subjectDid: EXAMPLE_SUBJECT_DID,
60
- actor: { identifier: EXAMPLE_API_ORGANIZATION_DID },
61
- actorRole: EXAMPLE_HEALTHCARE_ACTOR_ROLE_PHYSICIAN,
60
+ actorId: EXAMPLE_PROFESSIONAL_DID,
61
+ actorRole: EXAMPLE_HEALTHCARE_ACTOR_ROLE_GENERALIST_MEDICAL_PRACTITIONER,
62
62
  purpose: EXAMPLE_CONSENT_PURPOSE_TREATMENT,
63
63
  actions: [EXAMPLE_CLINICAL_SECTION_ALLERGIES],
64
64
  };
@@ -75,36 +75,36 @@ export declare const EXAMPLE_SEARCH_CLINICAL_BUNDLE_INPUT: {
75
75
  *
76
76
  * - use `EXAMPLE_TOKEN_EXCHANGE_SMART_INPUT` or `EXAMPLE_OPENID_SMART_TOKEN_INPUT`
77
77
  * for the first read-only examples
78
- * - use the scenarios below when you explicitly want the composition read scope
79
- * plus `organization/Consent.cruds`
78
+ * - do not append `organization/Consent.cruds` to a clinical read unless a
79
+ * separate rule explicitly grants that resource capability
80
80
  */
81
81
  export declare const EXAMPLE_PROFESSIONAL_ACCESS_SCENARIOS: Readonly<{
82
82
  readonly physicianAllergiesRead: {
83
83
  readonly actorRole: "ISCO-08|2211";
84
84
  readonly purpose: "TREAT";
85
85
  readonly consentActions: readonly [string];
86
- readonly smartScopes: readonly [string, "organization/Consent.cruds"];
86
+ readonly smartScopes: readonly [string];
87
87
  readonly includedTypes: readonly ["Composition", "AllergyIntolerance", "DocumentReference"];
88
88
  };
89
89
  readonly nursingMedicationRead: {
90
90
  readonly actorRole: "ISCO-08|2221";
91
91
  readonly purpose: "TREAT";
92
92
  readonly consentActions: readonly [string];
93
- readonly smartScopes: readonly [string, "organization/Consent.cruds"];
93
+ readonly smartScopes: readonly [string];
94
94
  readonly includedTypes: readonly ["Composition", "MedicationStatement", "DocumentReference"];
95
95
  };
96
96
  readonly paramedicEmergencySummaryRead: {
97
97
  readonly actorRole: "ISCO-08|2240";
98
98
  readonly purpose: "ETREAT";
99
99
  readonly consentActions: readonly [string];
100
- readonly smartScopes: readonly [string, "organization/Consent.cruds"];
100
+ readonly smartScopes: readonly [string];
101
101
  readonly includedTypes: readonly ["Composition", "DocumentReference", "Observation"];
102
102
  };
103
103
  readonly physicianResultsAndProblemsRead: {
104
104
  readonly actorRole: "ISCO-08|2211";
105
105
  readonly purpose: "TREAT";
106
106
  readonly consentActions: readonly [string, string];
107
- readonly smartScopes: readonly [string, "organization/Consent.cruds"];
107
+ readonly smartScopes: readonly [string];
108
108
  readonly includedTypes: readonly ["Composition", "Condition", "DiagnosticReport", "DocumentReference"];
109
109
  };
110
110
  }>;
@@ -1,7 +1,6 @@
1
1
  // Copyright 2026 Antifraud Services Inc. under the Apache License, Version 2.0.
2
- import { HealthcareActorRoles, HealthcareBasicSections, HealthcareConsentPurposes, } from '../constants/healthcare.js';
2
+ import { HealthcareActorRoles, HealthcareBasicSections, HealthcareConsentActions, HealthcareConsentPurposes, } from '../constants/healthcare.js';
3
3
  import { ResourceTypesFhirR4 } from '../constants/fhir-resource-types.js';
4
- import { SmartGatewayScopesFhirR4 } from '../constants/smart.js';
5
4
  import { buildSmartCompositionReadScope } from '../utils/smart-scope.js';
6
5
  import { EXAMPLE_CLINICAL_SECTION_ALLERGIES, EXAMPLE_EMAIL_PROFESSIONAL, EXAMPLE_HEALTHCARE_JURISDICTION, EXAMPLE_PATIENT_DID, EXAMPLE_PROVIDER_ORGANIZATION_URL, EXAMPLE_SUBJECT_DID, } from './shared.js';
7
6
  /**
@@ -12,7 +11,7 @@ import { EXAMPLE_CLINICAL_SECTION_ALLERGIES, EXAMPLE_EMAIL_PROFESSIONAL, EXAMPLE
12
11
  * - the actor role of the professional (`Physician`, `NursingProfessional`, ...)
13
12
  * - the consented action/section over an individual subject
14
13
  * - the SMART scope ultimately requested against GW CORE
15
- * - the difference between a minimal read scope and an elevated consent-management scope
14
+ * - a read scope containing only capabilities covered by the consent
16
15
  *
17
16
  * This differs from organization-controller and individual-owner bootstrap
18
17
  * examples, where the main concern is identity/bootstrap rather than
@@ -28,10 +27,6 @@ const EXAMPLE_CANONICAL_SMART_READ_SCOPES = [
28
27
  sections: EXAMPLE_CLINICAL_SECTION_ALLERGIES,
29
28
  }),
30
29
  ];
31
- const EXAMPLE_CANONICAL_SMART_SCOPES = [
32
- ...EXAMPLE_CANONICAL_SMART_READ_SCOPES,
33
- SmartGatewayScopesFhirR4.ConsentCruds,
34
- ];
35
30
  export const EXAMPLE_TOKEN_EXCHANGE_SMART_INPUT = {
36
31
  idToken: 'employee-id-token-001',
37
32
  scopes: [...EXAMPLE_CANONICAL_SMART_READ_SCOPES],
@@ -99,20 +94,19 @@ export const EXAMPLE_SEARCH_CLINICAL_BUNDLE_INPUT = {
99
94
  *
100
95
  * - use `EXAMPLE_TOKEN_EXCHANGE_SMART_INPUT` or `EXAMPLE_OPENID_SMART_TOKEN_INPUT`
101
96
  * for the first read-only examples
102
- * - use the scenarios below when you explicitly want the composition read scope
103
- * plus `organization/Consent.cruds`
97
+ * - do not append `organization/Consent.cruds` to a clinical read unless a
98
+ * separate rule explicitly grants that resource capability
104
99
  */
105
100
  export const EXAMPLE_PROFESSIONAL_ACCESS_SCENARIOS = Object.freeze({
106
101
  physicianAllergiesRead: {
107
- actorRole: HealthcareActorRoles.Physician,
102
+ actorRole: HealthcareActorRoles.GeneralistMedicalPractitioner,
108
103
  purpose: HealthcareConsentPurposes.Treatment,
109
- consentActions: [HealthcareBasicSections.AllergiesAndIntolerances.claim],
104
+ consentActions: [HealthcareConsentActions.AllergiesAndIntolerances],
110
105
  smartScopes: [
111
106
  buildSmartCompositionReadScope({
112
107
  subjectDid: EXAMPLE_SMART_SUBJECT_DID,
113
- sections: HealthcareBasicSections.AllergiesAndIntolerances.claim,
108
+ sections: HealthcareConsentActions.AllergiesAndIntolerances,
114
109
  }),
115
- SmartGatewayScopesFhirR4.ConsentCruds,
116
110
  ],
117
111
  includedTypes: [
118
112
  ResourceTypesFhirR4.Composition,
@@ -129,7 +123,6 @@ export const EXAMPLE_PROFESSIONAL_ACCESS_SCENARIOS = Object.freeze({
129
123
  subjectDid: EXAMPLE_SMART_SUBJECT_DID,
130
124
  sections: HealthcareBasicSections.HistoryOfMedicationUse.claim,
131
125
  }),
132
- SmartGatewayScopesFhirR4.ConsentCruds,
133
126
  ],
134
127
  includedTypes: [
135
128
  ResourceTypesFhirR4.Composition,
@@ -146,7 +139,6 @@ export const EXAMPLE_PROFESSIONAL_ACCESS_SCENARIOS = Object.freeze({
146
139
  subjectDid: EXAMPLE_SMART_SUBJECT_DID,
147
140
  sections: HealthcareBasicSections.PatientSummaryDocument.claim,
148
141
  }),
149
- SmartGatewayScopesFhirR4.ConsentCruds,
150
142
  ],
151
143
  includedTypes: [
152
144
  ResourceTypesFhirR4.Composition,
@@ -155,7 +147,7 @@ export const EXAMPLE_PROFESSIONAL_ACCESS_SCENARIOS = Object.freeze({
155
147
  ],
156
148
  },
157
149
  physicianResultsAndProblemsRead: {
158
- actorRole: HealthcareActorRoles.Physician,
150
+ actorRole: HealthcareActorRoles.GeneralistMedicalPractitioner,
159
151
  purpose: HealthcareConsentPurposes.Treatment,
160
152
  consentActions: [
161
153
  HealthcareBasicSections.Results.claim,
@@ -169,7 +161,6 @@ export const EXAMPLE_PROFESSIONAL_ACCESS_SCENARIOS = Object.freeze({
169
161
  HealthcareBasicSections.ProblemList.claim,
170
162
  ],
171
163
  }),
172
- SmartGatewayScopesFhirR4.ConsentCruds,
173
164
  ],
174
165
  includedTypes: [
175
166
  ResourceTypesFhirR4.Composition,
@@ -190,7 +181,6 @@ function buildConsentDecisionScenario(params) {
190
181
  subjectDid: EXAMPLE_SMART_SUBJECT_DID,
191
182
  sections: params.requestedSections,
192
183
  }),
193
- SmartGatewayScopesFhirR4.ConsentCruds,
194
184
  ],
195
185
  includedTypes: [...params.includedTypes],
196
186
  expectedSmartTokenDecision: params.expectedSmartTokenDecision,
@@ -210,4 +200,115 @@ function buildConsentDecisionScenario(params) {
210
200
  * consent state + actor target + role + purpose + requested scope.
211
201
  */
212
202
  export const EXAMPLE_PROFESSIONAL_CONSENT_SCENARIOS = Object.freeze({
213
- physicianB
203
+ physicianByEmailContinuousCareAllergiesAllowed: buildConsentDecisionScenario({
204
+ actorId: EXAMPLE_PHYSICIAN_EMAIL,
205
+ actorRole: HealthcareActorRoles.GeneralistMedicalPractitioner,
206
+ purpose: HealthcareConsentPurposes.Treatment,
207
+ consentActions: [HealthcareBasicSections.AllergiesAndIntolerances.claim],
208
+ requestedSections: HealthcareBasicSections.AllergiesAndIntolerances.claim,
209
+ includedTypes: [
210
+ ResourceTypesFhirR4.Composition,
211
+ ResourceTypesFhirR4.AllergyIntolerance,
212
+ ResourceTypesFhirR4.DocumentReference,
213
+ ],
214
+ expectedSmartTokenDecision: 'allowed',
215
+ reason: 'physician is targeted directly by email and role for continuous care over allergies section',
216
+ }),
217
+ physicianByEmailEmergencySummaryAllowed: buildConsentDecisionScenario({
218
+ actorId: EXAMPLE_PHYSICIAN_EMAIL,
219
+ actorRole: HealthcareActorRoles.GeneralistMedicalPractitioner,
220
+ purpose: HealthcareConsentPurposes.EmergencyTreatment,
221
+ consentActions: [HealthcareBasicSections.PatientSummaryDocument.claim],
222
+ requestedSections: HealthcareBasicSections.PatientSummaryDocument.claim,
223
+ includedTypes: [
224
+ ResourceTypesFhirR4.Composition,
225
+ ResourceTypesFhirR4.DocumentReference,
226
+ ResourceTypesFhirR4.Observation,
227
+ ],
228
+ expectedSmartTokenDecision: 'allowed',
229
+ reason: 'physician is targeted directly by email and role for emergency summary access',
230
+ }),
231
+ physicianByOrganizationResultsAllowed: buildConsentDecisionScenario({
232
+ actorId: { organizationUrl: EXAMPLE_PROVIDER_ORG_URL },
233
+ actorRole: HealthcareActorRoles.GeneralistMedicalPractitioner,
234
+ purpose: HealthcareConsentPurposes.Treatment,
235
+ consentActions: [HealthcareBasicSections.Results.claim],
236
+ requestedSections: HealthcareBasicSections.Results.claim,
237
+ includedTypes: [
238
+ ResourceTypesFhirR4.Composition,
239
+ ResourceTypesFhirR4.DiagnosticReport,
240
+ ResourceTypesFhirR4.DocumentReference,
241
+ ],
242
+ expectedSmartTokenDecision: 'allowed',
243
+ reason: 'consent is granted to a physician role within a given organization for continuous care results access',
244
+ }),
245
+ physicianByJurisdictionEmergencySummaryAllowed: buildConsentDecisionScenario({
246
+ actorId: EXAMPLE_JURISDICTION,
247
+ actorRole: HealthcareActorRoles.GeneralistMedicalPractitioner,
248
+ purpose: HealthcareConsentPurposes.EmergencyTreatment,
249
+ consentActions: [HealthcareBasicSections.PatientSummaryDocument.claim],
250
+ requestedSections: HealthcareBasicSections.PatientSummaryDocument.claim,
251
+ includedTypes: [
252
+ ResourceTypesFhirR4.Composition,
253
+ ResourceTypesFhirR4.DocumentReference,
254
+ ResourceTypesFhirR4.Observation,
255
+ ],
256
+ expectedSmartTokenDecision: 'allowed',
257
+ reason: 'consent is granted at jurisdiction level for physician emergency access',
258
+ }),
259
+ nursingByOrganizationMedicationHistoryAllowed: buildConsentDecisionScenario({
260
+ actorId: { organizationUrl: EXAMPLE_PROVIDER_ORG_URL },
261
+ actorRole: HealthcareActorRoles.NursingProfessional,
262
+ purpose: HealthcareConsentPurposes.Treatment,
263
+ consentActions: [HealthcareBasicSections.HistoryOfMedicationUse.claim],
264
+ requestedSections: HealthcareBasicSections.HistoryOfMedicationUse.claim,
265
+ includedTypes: [
266
+ ResourceTypesFhirR4.Composition,
267
+ ResourceTypesFhirR4.MedicationStatement,
268
+ ResourceTypesFhirR4.DocumentReference,
269
+ ],
270
+ expectedSmartTokenDecision: 'allowed',
271
+ reason: 'nursing professional is allowed to read medication history for treatment through organization-scoped consent',
272
+ }),
273
+ paramedicByJurisdictionEmergencySummaryAllowed: buildConsentDecisionScenario({
274
+ actorId: EXAMPLE_JURISDICTION,
275
+ actorRole: HealthcareActorRoles.Paramedic,
276
+ purpose: HealthcareConsentPurposes.EmergencyTreatment,
277
+ consentActions: [HealthcareBasicSections.PatientSummaryDocument.claim],
278
+ requestedSections: HealthcareBasicSections.PatientSummaryDocument.claim,
279
+ includedTypes: [
280
+ ResourceTypesFhirR4.Composition,
281
+ ResourceTypesFhirR4.DocumentReference,
282
+ ResourceTypesFhirR4.Observation,
283
+ ],
284
+ expectedSmartTokenDecision: 'allowed',
285
+ reason: 'paramedic receives emergency-only jurisdiction-scoped access to patient summary',
286
+ }),
287
+ physicianObstetricianDeniedWhenOnlyAllergiesConsent: buildConsentDecisionScenario({
288
+ actorId: EXAMPLE_PHYSICIAN_EMAIL,
289
+ actorRole: `${HealthcareActorRoles.GeneralistMedicalPractitioner}:obstetrician`,
290
+ purpose: HealthcareConsentPurposes.Treatment,
291
+ consentActions: [HealthcareBasicSections.AllergiesAndIntolerances.claim],
292
+ requestedSections: HealthcareBasicSections.Results.claim,
293
+ includedTypes: [
294
+ ResourceTypesFhirR4.Composition,
295
+ ResourceTypesFhirR4.DiagnosticReport,
296
+ ResourceTypesFhirR4.DocumentReference,
297
+ ],
298
+ expectedSmartTokenDecision: 'denied',
299
+ reason: 'requested SMART scope targets results but active consent only covers allergies section',
300
+ }),
301
+ physicianByEmailDeniedWhenConsentRevokedAndNoOrgNorJurisdictionConsentIsActive: buildConsentDecisionScenario({
302
+ actorId: EXAMPLE_PHYSICIAN_EMAIL,
303
+ actorRole: HealthcareActorRoles.GeneralistMedicalPractitioner,
304
+ purpose: HealthcareConsentPurposes.EmergencyTreatment,
305
+ consentActions: [HealthcareBasicSections.PatientSummaryDocument.claim],
306
+ requestedSections: HealthcareBasicSections.PatientSummaryDocument.claim,
307
+ includedTypes: [
308
+ ResourceTypesFhirR4.Composition,
309
+ ResourceTypesFhirR4.DocumentReference,
310
+ ],
311
+ expectedSmartTokenDecision: 'denied',
312
+ reason: 'matching consent rule existed before but is no longer active after controller deactivation/revocation',
313
+ }),
314
+ });
@@ -128,7 +128,7 @@ export const EXAMPLE_SERVICE_PUBLIC_DID = 'did:web:public.acme.org';
128
128
  export const EXAMPLE_PROFESSIONAL_DID = buildProfessionalDidWeb({
129
129
  organizationDidWeb: 'did:web:api.acme.org',
130
130
  email: 'doctor.oncall@example.org',
131
- role: HealthcareActorRoles.Physician,
131
+ role: HealthcareActorRoles.GeneralistMedicalPractitioner,
132
132
  });
133
133
  export const EXAMPLE_PROVIDER_ORGANIZATION_DID = 'did:web:hospital.acme.org';
134
134
  export const EXAMPLE_PROVIDER_ORGANIZATION_URL = 'https://hospital.acme.org';
@@ -193,7 +193,7 @@ export function buildIndexParametersFromClaims(claims, allowedClaims) {
193
193
  const parameter = {
194
194
  name: claimKey,
195
195
  value: value,
196
- type: inferPa type: inferParameterTypeFromClaimKey(claimKey, rawValue),
196
+ type: inferParameterTypeFromClaimKey(claimKey, rawValue),
197
197
  ...(claimKey === ObservationClaim.ValueQuantityUnit && typeof rawValue === 'string'
198
198
  ? { unit: rawValue }
199
199
  : {}),
@@ -136,7 +136,13 @@ export class BundleReader {
136
136
  }
137
137
  const entries = this.getEntries();
138
138
  for (let index = 0; index < entries.length; index += 1) {
139
- if (this.resolveEntryIdentifier(entries[index]) === normalizedIdentifier) {
139
+ const resource = asRecord(entries[index].resource);
140
+ const resourceType = asNonEmptyString(resource.resourceType) || 'resource';
141
+ const candidates = new Set([
142
+ ...this.resolveEntryReferenceCandidates(entries[index]),
143
+ `${resourceType}#${index}`,
144
+ ]);
145
+ if (candidates.has(normalizedIdentifier)) {
140
146
  return index;
141
147
  }
142
148
  }
@@ -148,7 +148,13 @@ export declare function buildOrganizationDidWeb(input: {
148
148
  /**
149
149
  * Builds a professional/member DID under a hosted organization DID.
150
150
  *
151
- * The stable actor identifier is derived from the email using multibase(base58btc(multihash(sha384))).
151
+ * The stable actor path identifier is derived from the lower-cased email using
152
+ * multibase(base58btc(multihash(SHA3-256))). The raw email is never embedded
153
+ * in the DID.
154
+ *
155
+ * This is the same multibase payload used by the ICA-compatible credential
156
+ * `sameAs`; only the representation differs: the DID path uses `z...` and the
157
+ * credential alias uses `urn:multibase:z...`.
152
158
  *
153
159
  * @param input.organizationDidWeb Canonical hosted organization DID.
154
160
  * @param input.email Professional email used to derive a stable member identifier.
package/dist/utils/did.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // crypto-ts/utils/did.ts
2
2
  // Copyright 2025 Antifraud Services Inc. under the Apache License, Version 2.0.
3
- import { encodeMultibaseSha384 } from './multibasehash.js';
3
+ import { multibase58MultihashSha3_256 } from './same-as.js';
4
4
  /**
5
5
  * Canonical DID path markers for hosted/provider individual identities.
6
6
  *
@@ -240,7 +240,13 @@ export function buildOrganizationDidWeb(input) {
240
240
  /**
241
241
  * Builds a professional/member DID under a hosted organization DID.
242
242
  *
243
- * The stable actor identifier is derived from the email using multibase(base58btc(multihash(sha384))).
243
+ * The stable actor path identifier is derived from the lower-cased email using
244
+ * multibase(base58btc(multihash(SHA3-256))). The raw email is never embedded
245
+ * in the DID.
246
+ *
247
+ * This is the same multibase payload used by the ICA-compatible credential
248
+ * `sameAs`; only the representation differs: the DID path uses `z...` and the
249
+ * credential alias uses `urn:multibase:z...`.
244
250
  *
245
251
  * @param input.organizationDidWeb Canonical hosted organization DID.
246
252
  * @param input.email Professional email used to derive a stable member identifier.
@@ -254,7 +260,7 @@ export function buildProfessionalDidWeb(input) {
254
260
  throw new Error('buildProfessionalDidWeb requires email.');
255
261
  if (!role)
256
262
  throw new Error('buildProfessionalDidWeb requires role.');
257
- const memberId = encodeMultibaseSha384(normalizedEmail);
263
+ const memberId = multibase58MultihashSha3_256(normalizedEmail);
258
264
  return [
259
265
  String(input.organizationDidWeb).trim(),
260
266
  'employee',
@@ -29,6 +29,10 @@ export type ProfessionalSmartVpPayloadInput = Readonly<{
29
29
  * - otherwise the public employee email
30
30
  *
31
31
  * When both are present they are merged and deduplicated after normalization.
32
+ * Plain emails become ICA-compatible
33
+ * `urn:multibase:<base58btc(multihash(SHA3-256))>` values. The multibase
34
+ * payload is the same value used in the professional DID actor path; `sameAs`
35
+ * adds only the `urn:multibase:` prefix.
32
36
  *
33
37
  * @param input Professional identity source values.
34
38
  */
@@ -11,6 +11,10 @@ import { buildUnsignedVpJwt } from './jwt.js';
11
11
  * - otherwise the public employee email
12
12
  *
13
13
  * When both are present they are merged and deduplicated after normalization.
14
+ * Plain emails become ICA-compatible
15
+ * `urn:multibase:<base58btc(multihash(SHA3-256))>` values. The multibase
16
+ * payload is the same value used in the professional DID actor path; `sameAs`
17
+ * adds only the `urn:multibase:` prefix.
14
18
  *
15
19
  * @param input Professional identity source values.
16
20
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdc-common-utils-ts",
3
- "version": "2.3.5",
3
+ "version": "2.3.7",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -19,7 +19,10 @@
19
19
  "test": "jest",
20
20
  "test:coverage": "jest --coverage",
21
21
  "typecheck": "tsc -p tsconfig.json --noEmit",
22
- "build": "tsc -p tsconfig.build.json && node patch-esm-imports.mjs",
22
+ "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
23
+ "build": "npm run clean && tsc -p tsconfig.build.json && node patch-esm-imports.mjs",
24
+ "verify:dist": "node scripts/verify-dist-syntax.mjs",
25
+ "prepack": "npm run verify:dist",
23
26
  "prepublishOnly": "npm run typecheck && npm test -- --watchman=false && npm run build"
24
27
  },
25
28
  "main": "./dist/index.js",
@@ -1,39 +0,0 @@
1
- import type { BundleResourceDateFilter, BundleResourceIdFilters } from './bundle-query';
2
- /**
3
- * Canonical high-level filter accepted by both Bundle section readers and
4
- * `FhirDocumentFacade`.
5
- */
6
- export type ClinicalResourceFilter = Readonly<{
7
- sections?: string[];
8
- types?: string[];
9
- date?: BundleResourceDateFilter;
10
- }>;
11
- /**
12
- * Chainable editor for one clinical document resource filter.
13
- *
14
- * The built value can be passed unchanged to:
15
- *
16
- * - `BundleReader.getDocumentSectionResourceIds(...)`
17
- * - `BundleReader.getDocumentSectionResourceEntries(...)`
18
- * - `FhirDocumentFacade.getResourcesByFilter(...)`
19
- * - `FhirDocumentFacade.getResourceCount(...)`
20
- */
21
- export declare class ClinicalResourceFilterEditor {
22
- private sections?;
23
- private types?;
24
- private date?;
25
- /** Replaces the selected Composition sections. An empty list means all. */
26
- setSections(values: readonly string[]): this;
27
- /** Replaces the selected FHIR resource types. An empty list means all. */
28
- setTypes(values: readonly string[]): this;
29
- /** Sets the complete clinical date interval. Either boundary may be omitted. */
30
- setPeriod(start?: string, end?: string): this;
31
- /** Sets or clears only the start boundary of the clinical date interval. */
32
- setPeriodStart(start?: string): this;
33
- /** Sets or clears only the end boundary of the clinical date interval. */
34
- setPeriodEnd(end?: string): this;
35
- /** Removes both clinical date boundaries. */
36
- clearPeriod(): this;
37
- /** Returns a detached canonical filter safe for either public reader. */
38
- build(): ClinicalResourceFilter & BundleResourceIdFilters;
39
- }
@@ -1,84 +0,0 @@
1
- // Copyright 2026 Antifraud Services Inc. under the Apache License, Version 2.0.
2
- /**
3
- * Chainable editor for one clinical document resource filter.
4
- *
5
- * The built value can be passed unchanged to:
6
- *
7
- * - `BundleReader.getDocumentSectionResourceIds(...)`
8
- * - `BundleReader.getDocumentSectionResourceEntries(...)`
9
- * - `FhirDocumentFacade.getResourcesByFilter(...)`
10
- * - `FhirDocumentFacade.getResourceCount(...)`
11
- */
12
- export class ClinicalResourceFilterEditor {
13
- sections;
14
- types;
15
- date;
16
- /** Replaces the selected Composition sections. An empty list means all. */
17
- setSections(values) {
18
- this.sections = normalizeTokens(values);
19
- return this;
20
- }
21
- /** Replaces the selected FHIR resource types. An empty list means all. */
22
- setTypes(values) {
23
- this.types = normalizeTokens(values);
24
- return this;
25
- }
26
- /** Sets the complete clinical date interval. Either boundary may be omitted. */
27
- setPeriod(start, end) {
28
- this.date = buildDateFilter(start, end);
29
- return this;
30
- }
31
- /** Sets or clears only the start boundary of the clinical date interval. */
32
- setPeriodStart(start) {
33
- this.date = buildDateFilter(start, this.date?.end);
34
- return this;
35
- }
36
- /** Sets or clears only the end boundary of the clinical date interval. */
37
- setPeriodEnd(end) {
38
- this.date = buildDateFilter(this.date?.start, end);
39
- return this;
40
- }
41
- /** Removes both clinical date boundaries. */
42
- clearPeriod() {
43
- this.date = undefined;
44
- return this;
45
- }
46
- /** Returns a detached canonical filter safe for either public reader. */
47
- build() {
48
- return {
49
- ...(this.sections?.length ? { sections: [...this.sections] } : {}),
50
- ...(this.types?.length ? { types: [...this.types] } : {}),
51
- ...(this.date ? { date: { ...this.date } } : {}),
52
- };
53
- }
54
- }
55
- function normalizeTokens(values) {
56
- const normalized = [...new Set(values.map((value) => String(value || '').trim()).filter(Boolean))];
57
- return normalized.length ? normalized : undefined;
58
- }
59
- function buildDateFilter(start, end) {
60
- const normalizedStart = normalizeDate(start, 'start');
61
- const normalizedEnd = normalizeDate(end, 'end');
62
- if (normalizedStart && normalizedEnd) {
63
- if (Date.parse(normalizedStart) > Date.parse(normalizedEnd)) {
64
- throw new RangeError('Clinical resource filter period start must not be after end.');
65
- }
66
- }
67
- if (!normalizedStart && !normalizedEnd) {
68
- return undefined;
69
- }
70
- return {
71
- ...(normalizedStart ? { start: normalizedStart } : {}),
72
- ...(normalizedEnd ? { end: normalizedEnd } : {}),
73
- };
74
- }
75
- function normalizeDate(value, boundary) {
76
- const normalized = String(value || '').trim();
77
- if (!normalized) {
78
- return undefined;
79
- }
80
- if (!Number.isFinite(Date.parse(normalized))) {
81
- throw new TypeError(`Clinical resource filter period ${boundary} must be a valid date.`);
82
- }
83
- return normalized;
84
- }