gdc-common-utils-ts 2.3.13 → 2.3.14

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
@@ -44,6 +44,13 @@ It is intentionally not a full backend orchestration layer.
44
44
 
45
45
  - FHIR SearchParameter names must use canonical FHIR naming (lowercase, with `-` when defined by FHIR).
46
46
  - Never use invented camelCase parameter names for FHIR claims/search keys (example: `Communication.part-of` is valid, `Communication.partOf` is not).
47
+ - FHIR claims use exactly `<ResourceType>.<concrete-parameter>` and never add
48
+ pseudo-path segments. For example, the section/topic claim is
49
+ `Communication.topic`; the native projection may contain
50
+ `Communication.topic.coding`, but `Communication.topic.coding` is not a
51
+ claims key.
52
+ - Schema.org claims are a different vocabulary and preserve the canonical
53
+ Schema.org camelCase property name.
47
54
  - Only define custom names when no canonical FHIR SearchParameter exists.
48
55
  - `resource.meta.claims` is the canonical project-specific claims container and must be preserved across conversions/transports.
49
56
  - `resource.meta.claims` is not part of base FHIR; it is a claims-first extension carried by FHIR-like resources in GDC contracts.
@@ -52,6 +59,12 @@ It is intentionally not a full backend orchestration layer.
52
59
  boundary before indexed storage. Existing `resource.meta.claims` take
53
60
  precedence so SDK-authored semantics survive transport conversion.
54
61
 
62
+ For a `Communication` whose attached Bundle is `batch` or `collection`, the
63
+ application section code belongs in `Communication.topic`. A LOINC section code
64
+ is serialized as native FHIR `topic.coding`; it is not inferred from
65
+ `payload.contentCodeableConcept`. A document Bundle instead remains organized
66
+ by its attached `Composition.section` graph.
67
+
55
68
  ## Identity Continuity
56
69
 
57
70
  For ICA-backed organization activation, the representative/controller proof is
@@ -134,6 +147,7 @@ read first:
134
147
  - [`docs/101-CLINICAL-IPS.md`](docs/101-CLINICAL-IPS.md)
135
148
  - [`docs/REFERENCE-CLINICAL-IPS-API.md`](docs/REFERENCE-CLINICAL-IPS-API.md)
136
149
  - [`docs/101-CONSENT_PERMISSION_TEMPLATES.md`](docs/101-CONSENT_PERMISSION_TEMPLATES.md)
150
+ - [`docs/101-INDIVIDUAL-MEMBER-SMART.md`](docs/101-INDIVIDUAL-MEMBER-SMART.md)
137
151
 
138
152
  Shared batch model for high-frequency clinical measurements:
139
153
 
@@ -21,6 +21,7 @@ export declare const EXAMPLE_CONSENT_ACCESS_RULES: Readonly<{
21
21
  paramedicByJurisdiction: ConsentRule & Partial<Record<ClaimConsent.resourceType, string>>;
22
22
  directPhysicianDenyInsideAllowedOrganization: ConsentRule & Partial<Record<ClaimConsent.resourceType, string>>;
23
23
  relatedPersonByEmail: ConsentRule & Partial<Record<ClaimConsent.resourceType, string>>;
24
+ relatedPersonClinicalSections: ConsentRule & Partial<Record<ClaimConsent.resourceType, string>>;
24
25
  revokedPhysicianEmailConsent: ConsentRule & Partial<Record<ClaimConsent.resourceType, string>>;
25
26
  }>;
26
27
  export declare const EXAMPLE_CONSENT_PHONE_EXTENSION_PENDING: Readonly<{
@@ -106,6 +106,21 @@ export const EXAMPLE_CONSENT_ACCESS_RULES = Object.freeze({
106
106
  actions: [HealthcareBasicSections.PatientSummaryDocument.claim],
107
107
  resourceTypes: [ResourceTypesFhirR4.Composition, ResourceTypesFhirR4.DocumentReference],
108
108
  }),
109
+ relatedPersonClinicalSections: buildRule({
110
+ identifier: 'urn:uuid:consent-related-person-clinical-sections',
111
+ actorIdentifier: EXAMPLE_EMAIL_RELATED_PERSON,
112
+ actorRole: EXAMPLE_RELATED_PERSON_ROLE,
113
+ purpose: HealthcareConsentPurposes.Treatment,
114
+ actions: [
115
+ HealthcareBasicSections.AllergiesAndIntolerances.attributeValue,
116
+ HealthcareBasicSections.HistoryOfMedicationUse.attributeValue,
117
+ ],
118
+ resourceTypes: [
119
+ ResourceTypesFhirR4.Composition,
120
+ ResourceTypesFhirR4.AllergyIntolerance,
121
+ ResourceTypesFhirR4.MedicationStatement,
122
+ ],
123
+ }),
109
124
  revokedPhysicianEmailConsent: buildRule({
110
125
  identifier: 'urn:uuid:consent-physician-email-revoked',
111
126
  actorIdentifier: EXAMPLE_EMAIL_PROFESSIONAL,
@@ -15,6 +15,8 @@ export type ConsentActorDescriptor = Readonly<{
15
15
  actorKind?: ConsentActorKind;
16
16
  email?: string;
17
17
  did?: string;
18
+ /** Additional verified identifiers bound to the same actor. */
19
+ aliases?: readonly string[];
18
20
  phone?: string;
19
21
  organizationDid?: string;
20
22
  organizationUrl?: string;
@@ -1,5 +1,6 @@
1
1
  // Always create JSDoc, do not use strings inline in keys nor values, use types instead, and reuse the data test examples.
2
2
  import { CommunicationClaim } from '../models/interoperable-claims/communication-claims.js';
3
+ import { LOINC_SYSTEM_URL } from '../models/clinical-sections.js';
3
4
  import { validateFhirResource } from './fhir-validator.js';
4
5
  /**
5
6
  * Validates a FHIR R4 `Communication` resource through the configured validator pipeline.
@@ -85,6 +86,9 @@ export function transformCommunicationClaimsToResourceFhirR4(communicationClaims
85
86
  const category = toStringOrUndefined(claims[CommunicationClaim.Category]);
86
87
  if (category)
87
88
  resource['category'] = [{ coding: [parseSystemCode(category)] }];
89
+ const topic = toStringOrUndefined(claims[CommunicationClaim.Topic]);
90
+ if (topic)
91
+ resource['topic'] = { coding: [parseTopicCoding(topic)] };
88
92
  const subject = toStringOrUndefined(claims[CommunicationClaim.Subject]);
89
93
  if (subject)
90
94
  resource['subject'] = { reference: subject };
@@ -127,6 +131,7 @@ export function extractCommunicationClaimsFromResourceFhirR4(resource, options =
127
131
  const partOfRef = resource?.partOf?.[0]?.reference;
128
132
  const noteText = resource?.note?.[0]?.text;
129
133
  const categoryCoding = resource?.category?.[0]?.coding?.[0];
134
+ const topicCoding = resource?.topic?.coding?.[0];
130
135
  const payloads = resource?.payload || [];
131
136
  const referencePayload = payloads.find((payload) => payload.contentReference !== undefined);
132
137
  const attachmentPayload = payloads.find((payload) => payload.contentAttachment !== undefined);
@@ -151,6 +156,14 @@ export function extractCommunicationClaimsFromResourceFhirR4(resource, options =
151
156
  else if (code)
152
157
  claims[CommunicationClaim.Category] = code;
153
158
  }
159
+ if (topicCoding) {
160
+ const system = toStringOrUndefined(topicCoding.system);
161
+ const code = toStringOrUndefined(topicCoding.code);
162
+ if (system && code)
163
+ claims[CommunicationClaim.Topic] = formatTopicClaim(system, code);
164
+ else if (code)
165
+ claims[CommunicationClaim.Topic] = code;
166
+ }
154
167
  setIf(claims, CommunicationClaim.ContentReference, contentReference);
155
168
  if (contentAttachment) {
156
169
  setIf(claims, CommunicationClaim.ContentAttachmentData, contentAttachment.data);
@@ -214,6 +227,22 @@ function parseSystemCode(value) {
214
227
  }
215
228
  return { code: trimmed };
216
229
  }
230
+ function parseTopicCoding(value) {
231
+ const coding = parseSystemCode(value);
232
+ if (coding.system?.toUpperCase() === 'LOINC') {
233
+ return { ...coding, system: LOINC_SYSTEM_URL };
234
+ }
235
+ return coding;
236
+ }
237
+ function formatTopicClaim(system, code) {
238
+ const normalizedSystem = system.trim().toLowerCase();
239
+ if (normalizedSystem === LOINC_SYSTEM_URL
240
+ || normalizedSystem === 'https://loinc.org'
241
+ || normalizedSystem === 'urn:oid:2.16.840.1.113883.6.1') {
242
+ return `LOINC|${code.trim()}`;
243
+ }
244
+ return `${system.trim()}|${code.trim()}`;
245
+ }
217
246
  function toStringOrUndefined(value) {
218
247
  if (value === undefined || value === null)
219
248
  return undefined;
@@ -245,9 +245,30 @@ function splitCsv(value) {
245
245
  .map((item) => item.trim())
246
246
  .filter(Boolean);
247
247
  }
248
+ /**
249
+ * Reads one concrete Consent search-parameter claim from either the internal
250
+ * rule shape (`Consent.action`) or a contextualized FHIR claim
251
+ * (`org.hl7.fhir.api.Consent.action`). Persisted rule sets from both forms are
252
+ * therefore evaluated identically.
253
+ */
254
+ function readConsentRuleClaim(rule, claim) {
255
+ const source = rule;
256
+ if (source[claim] !== undefined)
257
+ return source[claim];
258
+ const context = String(source['@context'] || '').trim().replace(/\.$/, '');
259
+ if (context && source[`${context}.${claim}`] !== undefined) {
260
+ return source[`${context}.${claim}`];
261
+ }
262
+ return source[`org.hl7.fhir.api.${claim}`];
263
+ }
248
264
  function normalizeSectionToken(value) {
249
265
  const trimmed = String(value || '').trim();
250
- if (!trimmed || !trimmed.includes('|'))
266
+ if (!trimmed)
267
+ return trimmed;
268
+ const loincPrefixed = trimmed.match(/^loinc:([^|:\s]+)$/i);
269
+ if (loincPrefixed)
270
+ return `loinc|${loincPrefixed[1]}`;
271
+ if (!trimmed.includes('|'))
251
272
  return trimmed;
252
273
  const [system, code] = trimmed.split('|', 2);
253
274
  const normalizedSystem = system
@@ -405,6 +426,12 @@ export function resolveConsentActor(actor) {
405
426
  }));
406
427
  }
407
428
  }
429
+ for (const alias of actor.aliases || []) {
430
+ const normalizedAlias = String(alias || '').trim();
431
+ if (normalizedAlias) {
432
+ directTargets.push(normalizeConsentTarget(normalizedAlias, { actorKind }));
433
+ }
434
+ }
408
435
  const phone = normalizePhone(String(actor.phone || ''));
409
436
  if (phone) {
410
437
  const phoneTarget = normalizeConsentTarget(`tel:${phone}`, { actorKind });
@@ -453,7 +480,7 @@ export function resolveConsentActor(actor) {
453
480
  * @param options.now Optional evaluation timestamp.
454
481
  */
455
482
  export function isConsentRuleActive(rule, options = {}) {
456
- if (options.subject && String(rule[ClaimConsent.subject] || '').trim() !== String(options.subject || '').trim()) {
483
+ if (options.subject && String(readConsentRuleClaim(rule, ClaimConsent.subject) || '').trim() !== String(options.subject || '').trim()) {
457
484
  return false;
458
485
  }
459
486
  const now = options.now instanceof Date
@@ -461,8 +488,8 @@ export function isConsentRuleActive(rule, options = {}) {
461
488
  : options.now
462
489
  ? new Date(options.now).getTime()
463
490
  : Date.now();
464
- const periodStart = String(rule[ClaimConsent.periodStart] || '').trim();
465
- const periodEnd = String(rule[ClaimConsent.periodEnd] || '').trim();
491
+ const periodStart = String(readConsentRuleClaim(rule, ClaimConsent.periodStart) || '').trim();
492
+ const periodEnd = String(readConsentRuleClaim(rule, ClaimConsent.periodEnd) || '').trim();
466
493
  if (periodStart && !Number.isNaN(Date.parse(periodStart)) && Date.parse(periodStart) > now)
467
494
  return false;
468
495
  if (periodEnd && !Number.isNaN(Date.parse(periodEnd)) && Date.parse(periodEnd) < now)
@@ -472,7 +499,7 @@ export function isConsentRuleActive(rule, options = {}) {
472
499
  function groupRulesBy(rules, predicate) {
473
500
  const groups = {};
474
501
  for (const rule of rules) {
475
- for (const token of splitCsv(rule[ClaimConsent.actorIdentifier])) {
502
+ for (const token of splitCsv(readConsentRuleClaim(rule, ClaimConsent.actorIdentifier))) {
476
503
  const normalized = normalizeConsentTarget(token, { preferOrganizationDid: true });
477
504
  if (!predicate(normalized))
478
505
  continue;
@@ -505,7 +532,7 @@ function normalizeRequestedList(values, wildcard = '*') {
505
532
  }
506
533
  function extractRuleResourceTypes(rule) {
507
534
  const candidates = [
508
- rule[ClaimConsent.resourceType],
535
+ readConsentRuleClaim(rule, ClaimConsent.resourceType),
509
536
  rule['Consent.resource-type'],
510
537
  rule['Consent.resource'],
511
538
  rule['Consent.data-type'],
@@ -518,7 +545,7 @@ function extractRuleResourceTypes(rule) {
518
545
  return [];
519
546
  }
520
547
  function ruleMatchesRole(rule, actorRole) {
521
- const ruleRoles = splitCsv(rule[ClaimConsent.actorRole]).map(normalizeConsentRoleValue).filter(Boolean);
548
+ const ruleRoles = splitCsv(readConsentRuleClaim(rule, ClaimConsent.actorRole)).map(normalizeConsentRoleValue).filter(Boolean);
522
549
  if (ruleRoles.length === 0 || ruleRoles.includes('*'))
523
550
  return true;
524
551
  const requestedRole = normalizeConsentRoleValue(String(actorRole || ''));
@@ -542,7 +569,7 @@ function ruleMatchesRole(rule, actorRole) {
542
569
  });
543
570
  }
544
571
  function ruleMatchesPurpose(rule, purpose) {
545
- const rulePurpose = String(rule[ClaimConsent.purpose] || '').trim();
572
+ const rulePurpose = String(readConsentRuleClaim(rule, ClaimConsent.purpose) || '').trim();
546
573
  if (!purpose || !rulePurpose)
547
574
  return true;
548
575
  return rulePurpose === purpose;
@@ -551,11 +578,29 @@ function ruleMatchesSection(rule, section) {
551
578
  if (!section || section === '*')
552
579
  return true;
553
580
  const requestedSection = normalizeSectionToken(section);
554
- const actions = splitCsv(rule[ClaimConsent.action]).map(normalizeSectionToken);
581
+ const rawAction = String(readConsentRuleClaim(rule, ClaimConsent.action) || '').trim();
582
+ const actions = extractRuleSectionTokens(rawAction).map(normalizeSectionToken);
555
583
  if (actions.length === 0)
556
584
  return false;
557
585
  return actions.includes(requestedSection) || actions.includes('*');
558
586
  }
587
+ function extractRuleSectionTokens(rawAction) {
588
+ if (!rawAction)
589
+ return [];
590
+ const canonicalExpressions = rawAction
591
+ .split(/\s+/)
592
+ .map((value) => value.trim())
593
+ .filter((value) => /^(?:organization\/|patient\/)?Composition\.[A-Za-z]+(?:\?|$)/i.test(value));
594
+ if (canonicalExpressions.length === 0)
595
+ return splitCsv(rawAction);
596
+ return canonicalExpressions.flatMap((expression) => {
597
+ const [, queryString = ''] = expression.split('?', 2);
598
+ const section = new URLSearchParams(queryString).get('section');
599
+ return section
600
+ ? section.split(',').map((value) => value.trim()).filter(Boolean)
601
+ : ['*'];
602
+ });
603
+ }
559
604
  function ruleMatchesResourceType(rule, resourceType) {
560
605
  if (!resourceType || resourceType === '*')
561
606
  return true;
@@ -565,7 +610,7 @@ function ruleMatchesResourceType(rule, resourceType) {
565
610
  return resourceTypes.includes(resourceType) || resourceTypes.includes('*');
566
611
  }
567
612
  function resolveRuleMatch(rule, actor) {
568
- for (const token of splitCsv(rule[ClaimConsent.actorIdentifier])) {
613
+ for (const token of splitCsv(readConsentRuleClaim(rule, ClaimConsent.actorIdentifier))) {
569
614
  const normalized = normalizeConsentTarget(token, { preferOrganizationDid: true });
570
615
  if (actor.directTargets.some((target) => target.canonicalValue === normalized.canonicalValue)) {
571
616
  return { matchKind: 'direct', target: normalized, precedenceBase: 10 };
@@ -583,7 +628,7 @@ function toRuleMatch(rule, actor, section, resourceType) {
583
628
  const resolved = resolveRuleMatch(rule, actor);
584
629
  if (!resolved.target || resolved.matchKind === 'none' || resolved.precedenceBase === undefined)
585
630
  return undefined;
586
- const decision = rule[ClaimConsent.decision];
631
+ const decision = readConsentRuleClaim(rule, ClaimConsent.decision);
587
632
  const precedence = resolved.precedenceBase + (decision === 'deny' ? 0 : 1);
588
633
  return {
589
634
  rule,
@@ -38,6 +38,15 @@ export type IndividualControllerCredentialInput = IndividualActorIdentityCredent
38
38
  export type IndividualControllerVpPayloadInput = IndividualActorIdentityVpPayloadInput;
39
39
  export type IndividualMemberCredentialInput = IndividualActorIdentityCredentialInput;
40
40
  export type IndividualMemberVpPayloadInput = IndividualActorIdentityVpPayloadInput;
41
+ export type IndividualMemberCredentialSummary = Readonly<{
42
+ actorDid: string;
43
+ subjectDid: string;
44
+ relationship: string;
45
+ issuerDid?: string;
46
+ email?: string;
47
+ telephone?: string;
48
+ sameAs: string[];
49
+ }>;
41
50
  export declare function getIndividualControllerIdentitySameAs(input: Readonly<Pick<IndividualControllerCredentialInput, 'sameAs' | 'email'>>): string[];
42
51
  export declare function getIndividualControllerIdentityTelephone(input: Readonly<Pick<IndividualControllerCredentialInput, 'telephone'>>): string | undefined;
43
52
  export declare function getIndividualControllerIdentityVC(input: IndividualControllerCredentialInput): Record<string, unknown>;
@@ -51,6 +60,17 @@ export declare function getIndividualSubjectVC(input: IndividualSubjectCredentia
51
60
  export declare function getIndividualMemberIdentitySameAs(input: Readonly<Pick<IndividualMemberCredentialInput, 'sameAs' | 'email'>>): string[];
52
61
  export declare function getIndividualMemberIdentityTelephone(input: Readonly<Pick<IndividualMemberCredentialInput, 'telephone'>>): string | undefined;
53
62
  export declare function getIndividualMemberIdentityVC(input: IndividualMemberCredentialInput): Record<string, unknown>;
63
+ /**
64
+ * Reads the relationship identity asserted by one already-verified member VC.
65
+ * Signature/proof verification belongs to the enclosing VP verifier.
66
+ */
67
+ export declare function summarizeIndividualMemberIdentityCredential(credential: unknown): IndividualMemberCredentialSummary | undefined;
68
+ /** Finds an exact actor/subject member relationship in an already-verified VP. */
69
+ export declare function getMatchingIndividualMemberCredentialFromVpToken(vpToken: string, criteria: Readonly<{
70
+ actorDid: string;
71
+ subjectDid: string;
72
+ relationship?: string;
73
+ }>): IndividualMemberCredentialSummary | undefined;
54
74
  export declare function buildIndividualMemberIdentityVpPayload(input: IndividualMemberVpPayloadInput): Record<string, unknown>;
55
75
  export declare function buildUnsignedIndividualMemberIdentityVpJwt(input: IndividualMemberVpPayloadInput, options?: Readonly<{
56
76
  nowSeconds?: number;
@@ -2,6 +2,7 @@ import { ClaimsPersonSchemaorg } from '../constants/schemaorg.js';
2
2
  import { IndividualCredentialTypes, W3cCredentialTypes, } from '../constants/verifiable-credentials.js';
3
3
  import { normalizeSameAsHashCsv, normalizeSameAsHashList, normalizeTelephoneHash } from './same-as.js';
4
4
  import { buildUnsignedVpJwt } from './jwt.js';
5
+ import { getVpCredentials } from './vp-token.js';
5
6
  function getIndividualIdentitySameAs(input) {
6
7
  const sameAsCandidates = [];
7
8
  if (input.sameAs) {
@@ -127,6 +128,50 @@ export function getIndividualMemberIdentityTelephone(input) {
127
128
  export function getIndividualMemberIdentityVC(input) {
128
129
  return buildIndividualIdentityVC(input, IndividualCredentialTypes.IndividualMemberCredential);
129
130
  }
131
+ /**
132
+ * Reads the relationship identity asserted by one already-verified member VC.
133
+ * Signature/proof verification belongs to the enclosing VP verifier.
134
+ */
135
+ export function summarizeIndividualMemberIdentityCredential(credential) {
136
+ const source = credential;
137
+ const types = Array.isArray(source?.type) ? source.type.map(String) : [String(source?.type || '')];
138
+ if (!types.includes(IndividualCredentialTypes.IndividualMemberCredential))
139
+ return undefined;
140
+ const subject = source?.credentialSubject || {};
141
+ const actorDid = String(subject.id || '').trim();
142
+ const subjectDid = String(subject.subject || '').trim();
143
+ const relationship = String(subject.relationship || '').trim();
144
+ if (!actorDid || !subjectDid || !relationship)
145
+ return undefined;
146
+ return {
147
+ actorDid,
148
+ subjectDid,
149
+ relationship,
150
+ ...(String(source?.issuer?.id || source?.issuer || '').trim()
151
+ ? { issuerDid: String(source.issuer?.id || source.issuer).trim() }
152
+ : {}),
153
+ ...(String(subject[ClaimsPersonSchemaorg.email] || '').trim()
154
+ ? { email: String(subject[ClaimsPersonSchemaorg.email]).trim().toLowerCase() }
155
+ : {}),
156
+ ...(String(subject[ClaimsPersonSchemaorg.telephone] || '').trim()
157
+ ? { telephone: String(subject[ClaimsPersonSchemaorg.telephone]).trim() }
158
+ : {}),
159
+ sameAs: normalizeSameAsHashList(subject.sameAs),
160
+ };
161
+ }
162
+ /** Finds an exact actor/subject member relationship in an already-verified VP. */
163
+ export function getMatchingIndividualMemberCredentialFromVpToken(vpToken, criteria) {
164
+ return getVpCredentials(vpToken)
165
+ .map(summarizeIndividualMemberIdentityCredential)
166
+ .find((summary) => {
167
+ if (!summary)
168
+ return false;
169
+ return summary.actorDid === String(criteria.actorDid || '').trim()
170
+ && summary.subjectDid === String(criteria.subjectDid || '').trim()
171
+ && (!criteria.relationship
172
+ || summary.relationship.toLowerCase() === String(criteria.relationship).trim().toLowerCase());
173
+ });
174
+ }
130
175
  export function buildIndividualMemberIdentityVpPayload(input) {
131
176
  return buildIndividualIdentityVpPayload(input, IndividualCredentialTypes.IndividualMemberCredential);
132
177
  }
@@ -1,3 +1,5 @@
1
+ import type { ConsentActorDescriptor, EffectiveAccessEvaluation } from '../models/consent-access';
2
+ import type { ConsentRule } from '../models/consent-rule';
1
3
  export type SmartCompositionReadScopeOptions = {
2
4
  /**
3
5
  * Subject DID pinned by the current CORE GW root scope contract.
@@ -26,3 +28,59 @@ export type SmartCompositionReadScopeOptions = {
26
28
  * scope builder.
27
29
  */
28
30
  export declare function buildSmartCompositionReadScope(options: SmartCompositionReadScopeOptions): string;
31
+ export type DeriveGrantedSmartScopesInput = Readonly<{
32
+ /** Requested SMART root scopes. Every scope must pin the same subject. */
33
+ requestedScopes: string | readonly string[];
34
+ /** Authenticated actor descriptors resolved by a trusted runtime. */
35
+ actor: ConsentActorDescriptor;
36
+ /** Canonical relationship or professional role claim. */
37
+ actorRole?: string;
38
+ /** Consent purpose required for the access. */
39
+ purpose?: string;
40
+ /** Deterministic clock used by tests and policy evaluation. */
41
+ now?: string | Date;
42
+ /**
43
+ * Known sections used to expand `section=*` or an omitted section filter.
44
+ *
45
+ * Defaults to the shared IPS summary-section registry. Supplying an explicit
46
+ * list is useful for a sector-specific profile while keeping the evaluator
47
+ * product-neutral.
48
+ */
49
+ availableSections?: readonly string[];
50
+ }>;
51
+ export type GrantedSmartScopesDecision = 'granted' | 'partial' | 'denied';
52
+ export type GrantedSmartScopesResult = Readonly<{
53
+ /** One subject shared by every accepted request scope. */
54
+ subject: string;
55
+ /** Overall projection decision. Empty grants are always `denied`. */
56
+ decision: GrantedSmartScopesDecision;
57
+ /** Exact scopes that GW may place in the signed token. */
58
+ grantedScopes: string[];
59
+ /** Original scopes or scope fragments that received no grant. */
60
+ deniedScopes: string[];
61
+ /** Exact clinical sections retained in `grantedScopes`. */
62
+ grantedSections: string[];
63
+ /** Requested clinical sections omitted from `grantedScopes`. */
64
+ deniedSections: string[];
65
+ /** Per-scope shared consent evaluations retained for audit/tests. */
66
+ evaluations: EffectiveAccessEvaluation[];
67
+ }>;
68
+ /**
69
+ * Derives the exact read-only SMART scopes covered by active Consent rules.
70
+ *
71
+ * This pure helper deliberately does not create or sign a JWT. A trusted GW
72
+ * supplies authoritative rules and actor identity, calls this projection, and
73
+ * signs only `grantedScopes`. Browser applications may use the same function
74
+ * with fixtures to predict UI capabilities, but that prediction grants no
75
+ * authority.
76
+ *
77
+ * Security properties:
78
+ * - all root scopes must target one subject
79
+ * - `patient/*`, non-Composition and write-bearing scopes fail closed
80
+ * - wildcard/omitted section requests are expanded to explicit known sections
81
+ * - the returned scopes contain only `requested ∩ actively consented`
82
+ *
83
+ * @param rules Authoritative or test Consent rules.
84
+ * @param input Requested scopes plus trusted actor/purpose context.
85
+ */
86
+ export declare function deriveGrantedSmartScopes(rules: readonly ConsentRule[], input: DeriveGrantedSmartScopesInput): GrantedSmartScopesResult;
@@ -1,4 +1,5 @@
1
- import { HealthcareBasicSections } from '../constants/healthcare.js';
1
+ import { HealthcareBasicSections, HealthcareSummarySections, } from '../constants/healthcare.js';
2
+ import { evaluateConsentCoverage } from './consent.js';
2
3
  /**
3
4
  * Builds the gateway-pinned SMART root scope required by the current CORE GW
4
5
  * token contract:
@@ -27,3 +28,130 @@ export function buildSmartCompositionReadScope(options) {
27
28
  }
28
29
  return `organization/Composition.${options.accessVerb || 'rs'}?${query.toString()}`;
29
30
  }
31
+ /**
32
+ * Derives the exact read-only SMART scopes covered by active Consent rules.
33
+ *
34
+ * This pure helper deliberately does not create or sign a JWT. A trusted GW
35
+ * supplies authoritative rules and actor identity, calls this projection, and
36
+ * signs only `grantedScopes`. Browser applications may use the same function
37
+ * with fixtures to predict UI capabilities, but that prediction grants no
38
+ * authority.
39
+ *
40
+ * Security properties:
41
+ * - all root scopes must target one subject
42
+ * - `patient/*`, non-Composition and write-bearing scopes fail closed
43
+ * - wildcard/omitted section requests are expanded to explicit known sections
44
+ * - the returned scopes contain only `requested ∩ actively consented`
45
+ *
46
+ * @param rules Authoritative or test Consent rules.
47
+ * @param input Requested scopes plus trusted actor/purpose context.
48
+ */
49
+ export function deriveGrantedSmartScopes(rules, input) {
50
+ const rawScopes = (Array.isArray(input.requestedScopes)
51
+ ? input.requestedScopes
52
+ : [input.requestedScopes])
53
+ .map((scope) => String(scope || '').trim())
54
+ .filter(Boolean);
55
+ if (rawScopes.length === 0) {
56
+ throw new Error('deriveGrantedSmartScopes requires at least one requested scope.');
57
+ }
58
+ const availableSections = Array.from(new Set((input.availableSections?.length
59
+ ? input.availableSections
60
+ : Object.values(HealthcareSummarySections).map((section) => section.attributeValue))
61
+ .map((section) => String(section || '').trim())
62
+ .filter(Boolean)));
63
+ const parsedScopes = rawScopes.map((scope) => parseClinicalScope(scope, availableSections));
64
+ const subjects = Array.from(new Set(parsedScopes.map((scope) => scope.subject).filter(Boolean)));
65
+ if (subjects.length !== 1) {
66
+ throw new Error('deriveGrantedSmartScopes requires every requested scope to pin one single subject.');
67
+ }
68
+ const subject = subjects[0];
69
+ const grantedScopes = [];
70
+ const deniedScopes = [];
71
+ const grantedSections = [];
72
+ const deniedSections = [];
73
+ const evaluations = [];
74
+ for (const parsed of parsedScopes) {
75
+ if (!parsed.supported) {
76
+ deniedScopes.push(parsed.raw);
77
+ deniedSections.push(...parsed.requestedSections);
78
+ continue;
79
+ }
80
+ const scopeGrantedSections = [];
81
+ for (const section of parsed.requestedSections) {
82
+ const evaluation = evaluateConsentCoverage([...rules], {
83
+ subject,
84
+ actor: input.actor,
85
+ actorRole: input.actorRole,
86
+ purpose: input.purpose,
87
+ sections: [section],
88
+ resourceTypes: ['Composition'],
89
+ now: input.now,
90
+ });
91
+ evaluations.push(evaluation);
92
+ if (evaluation.allowed) {
93
+ scopeGrantedSections.push(section);
94
+ grantedSections.push(section);
95
+ }
96
+ else {
97
+ deniedSections.push(section);
98
+ }
99
+ }
100
+ if (scopeGrantedSections.length === 0) {
101
+ deniedScopes.push(parsed.raw);
102
+ continue;
103
+ }
104
+ grantedScopes.push(buildSmartCompositionReadScope({
105
+ subjectDid: subject,
106
+ sections: scopeGrantedSections,
107
+ accessVerb: parsed.accessVerb,
108
+ }));
109
+ if (scopeGrantedSections.length !== parsed.requestedSections.length) {
110
+ deniedScopes.push(parsed.raw);
111
+ }
112
+ }
113
+ const uniqueGrantedScopes = Array.from(new Set(grantedScopes));
114
+ const uniqueDeniedScopes = Array.from(new Set(deniedScopes));
115
+ const uniqueGrantedSections = Array.from(new Set(grantedSections));
116
+ const uniqueDeniedSections = Array.from(new Set(deniedSections))
117
+ .filter((section) => !uniqueGrantedSections.includes(section));
118
+ const decision = uniqueGrantedScopes.length === 0
119
+ ? 'denied'
120
+ : uniqueDeniedScopes.length > 0 || uniqueDeniedSections.length > 0
121
+ ? 'partial'
122
+ : 'granted';
123
+ return {
124
+ subject,
125
+ decision,
126
+ grantedScopes: uniqueGrantedScopes,
127
+ deniedScopes: uniqueDeniedScopes,
128
+ grantedSections: uniqueGrantedSections,
129
+ deniedSections: uniqueDeniedSections,
130
+ evaluations,
131
+ };
132
+ }
133
+ function parseClinicalScope(rawScope, availableSections) {
134
+ const [head, queryString = ''] = rawScope.split('?', 2);
135
+ const match = head.match(/^organization\/Composition\.(r|rs)$/);
136
+ const params = new URLSearchParams(queryString);
137
+ const subject = String(params.get('subject') || '').trim();
138
+ if (!subject) {
139
+ throw new Error(`SMART scope must pin a subject: ${rawScope}`);
140
+ }
141
+ const unsupportedFilter = Array.from(params.keys())
142
+ .some((key) => key !== 'subject' && key !== 'section');
143
+ const requested = String(params.get('section') || '').trim();
144
+ const requestedSections = !requested || requested.split(',').includes('*')
145
+ ? [...availableSections]
146
+ : requested
147
+ .split(',')
148
+ .map((section) => section.trim())
149
+ .filter(Boolean);
150
+ return {
151
+ raw: rawScope,
152
+ subject,
153
+ accessVerb: match?.[1],
154
+ requestedSections: Array.from(new Set(requestedSections)),
155
+ supported: Boolean(match) && !unsupportedFilter && requestedSections.length > 0,
156
+ };
157
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdc-common-utils-ts",
3
- "version": "2.3.13",
3
+ "version": "2.3.14",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },