sos-sdk-node-ts 0.1.18 → 0.1.19

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/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.19 - 2026-11-04
6
+
7
+ - Discover existing professional employee DID documents from an exact
8
+ configured provider using country, optional regional jurisdiction,
9
+ organization legal identifier, sector and verified email. Return only roles
10
+ whose authoritative DID document binds the expected employee authorization.
11
+
5
12
  ## 0.1.18 - 2026-11-03
6
13
 
7
14
  - Add a trusted Node client for subject-card provider advertisements and
package/README.md CHANGED
@@ -78,6 +78,14 @@ BFF. It matches exact aliases inside `Organization.sameAs` CSV values and
78
78
  rejects removed animal-card DID layouts before UI mapping. See
79
79
  [the authorized subject directory contract](docs/AUTHORIZED_SUBJECT_DIRECTORY.md).
80
80
 
81
+ `sos-sdk-node-ts/portable-professional-evidence` lets a trusted BFF discover
82
+ existing professional roles from an exact configured provider using country,
83
+ optional ISO 3166-2 region, organization legal identifier, sector and verified
84
+ email. It derives candidate employee DIDs only from the governed role catalog
85
+ and returns a role only when the authoritative employee DID document binds the
86
+ expected authorization URN. These routing inputs and a later browser role
87
+ choice grant no authority by themselves.
88
+
81
89
  `sos-sdk-node-ts/host-organization-directory-client` is the separate trusted-
82
90
  BFF client for a host operator's organization directory. It sends the
83
91
  canonical `org.schema/Organization/_search` request, polls the matching
@@ -14,6 +14,21 @@ export type PortableProfessionalEnrollment = Readonly<{
14
14
  jurisdiction: string;
15
15
  role: string;
16
16
  }>;
17
+ /**
18
+ * Finds the signed professional DID documents already published beneath one
19
+ * exact organization. Country, optional region, legal identifier and sector
20
+ * only reconstruct routing; the authoritative employee DID and authorization
21
+ * URN still determine every returned role.
22
+ */
23
+ export declare function resolvePortableProfessionalOrganizationEnrollments(input: Readonly<{
24
+ country: unknown;
25
+ region?: unknown;
26
+ organizationIdentifier: unknown;
27
+ sector: unknown;
28
+ verifiedEmail: unknown;
29
+ providers: readonly PortableProfessionalProvider[];
30
+ fetchImpl?: typeof fetch;
31
+ }>): Promise<readonly PortableProfessionalEnrollment[]>;
17
32
  /**
18
33
  * Derives routing candidates only from evidence shape plus a server-owned host
19
34
  * and sector allowlist. The returned candidate grants no authority: the signed
@@ -1,6 +1,7 @@
1
1
  import { createPublicKey, verify } from 'node:crypto';
2
2
  import { inflateRawSync } from 'node:zlib';
3
3
  import { buildProfessionalDidWeb } from 'gdc-common-utils-ts/utils/did';
4
+ import { getHealthcareProfessionalRolesBySector } from 'gdc-common-utils-ts/constants/healthcare';
4
5
  const EMPLOYEE_AUTHORIZATION_URN_PATTERN = /^urn:cds-([a-z]{2}(?:-[a-z0-9]{2,3})?):v[1-9][0-9]*:organization:([a-z][a-z0-9-]*):([^:]+):member:(z[1-9A-HJ-NP-Za-km-z]+):([0-9]+)$/i;
5
6
  function record(value) {
6
7
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
@@ -16,6 +17,102 @@ function identifiers(resource) {
16
17
  function providerIssuer(provider) {
17
18
  return `${new URL(provider.baseUrl).origin}/${encodeURIComponent(provider.tenantId)}/cds-${encodeURIComponent(provider.jurisdiction)}/v1/${encodeURIComponent(provider.sector)}`;
18
19
  }
20
+ async function resolveProviderDid(provider, fetchImpl) {
21
+ const response = await fetchImpl(`${providerIssuer(provider)}/.well-known/did.json`, {
22
+ headers: { accept: 'application/did+json, application/json' },
23
+ });
24
+ if (!response.ok)
25
+ throw new TypeError('professional_tenant_did_unavailable');
26
+ const document = record(await response.json());
27
+ const providerDid = text(document.id);
28
+ const expectedContext = `:${provider.tenantId}:cds-${provider.jurisdiction}:v1:${provider.sector}`;
29
+ const services = Array.isArray(document.service) ? document.service : [];
30
+ const hasJwks = services.some(service => text(record(service).type) === 'JsonWebKeyService2020'
31
+ && /^https:\/\//i.test(text(record(service).serviceEndpoint)));
32
+ if (!providerDid.startsWith('did:web:') || !providerDid.endsWith(expectedContext) || !hasJwks) {
33
+ throw new TypeError('professional_tenant_did_invalid');
34
+ }
35
+ return providerDid;
36
+ }
37
+ /**
38
+ * Finds the signed professional DID documents already published beneath one
39
+ * exact organization. Country, optional region, legal identifier and sector
40
+ * only reconstruct routing; the authoritative employee DID and authorization
41
+ * URN still determine every returned role.
42
+ */
43
+ export async function resolvePortableProfessionalOrganizationEnrollments(input) {
44
+ const country = text(input.country).toUpperCase();
45
+ const region = text(input.region).toUpperCase();
46
+ const jurisdiction = region || country;
47
+ const organizationIdentifier = text(input.organizationIdentifier);
48
+ const sector = text(input.sector).toLowerCase();
49
+ const verifiedEmail = text(input.verifiedEmail).toLowerCase();
50
+ if (!/^[A-Z]{2}$/.test(country))
51
+ throw new TypeError('professional_country_invalid');
52
+ if (region && (!new RegExp(`^${country}-[A-Z0-9]{1,3}$`).test(region))) {
53
+ throw new TypeError('professional_region_country_mismatch');
54
+ }
55
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{1,127}$/.test(organizationIdentifier)) {
56
+ throw new TypeError('professional_organization_identifier_invalid');
57
+ }
58
+ if (!verifiedEmail)
59
+ throw new TypeError('verified_email_required');
60
+ const roleCatalog = getHealthcareProfessionalRolesBySector(sector);
61
+ const roles = Object.values(roleCatalog).filter(role => role.claim.startsWith('ISCO-08|'));
62
+ if (!roles.length)
63
+ throw new TypeError('professional_sector_not_allowed');
64
+ const providers = input.providers.filter(provider => provider.tenantId === organizationIdentifier
65
+ && provider.jurisdiction.toUpperCase() === jurisdiction
66
+ && provider.sector.toLowerCase() === sector);
67
+ if (providers.length === 0)
68
+ throw new TypeError('professional_provider_not_found');
69
+ if (providers.length > 1)
70
+ throw new TypeError('multiple_professional_providers_found');
71
+ const provider = providers[0];
72
+ const fetchImpl = input.fetchImpl ?? fetch;
73
+ const providerDid = await resolveProviderDid(provider, fetchImpl);
74
+ const results = [];
75
+ for (const role of roles) {
76
+ const professionalDid = buildProfessionalDidWeb({
77
+ organizationDidWeb: providerDid,
78
+ email: verifiedEmail,
79
+ role: role.claim,
80
+ });
81
+ const memberPath = professionalDid.slice(`${providerDid}:employee:`.length);
82
+ const memberId = memberPath.slice(0, memberPath.indexOf(':'));
83
+ const didUrl = `${providerIssuer(provider)}/employee/${encodeURIComponent(memberId)}/${encodeURIComponent(role.claim)}/did.json`;
84
+ const response = await fetchImpl(didUrl, { headers: { accept: 'application/did+json, application/json' } });
85
+ if (response.status === 404)
86
+ continue;
87
+ if (!response.ok)
88
+ throw new TypeError('professional_directory_unavailable');
89
+ const document = record(await response.json());
90
+ if (text(document.id) !== professionalDid)
91
+ throw new TypeError('professional_did_invalid');
92
+ const employeeAuthorizationUrn = (Array.isArray(document.alsoKnownAs) ? document.alsoKnownAs : [])
93
+ .map(String).find(value => EMPLOYEE_AUTHORIZATION_URN_PATTERN.test(value)) || '';
94
+ const authorization = EMPLOYEE_AUTHORIZATION_URN_PATTERN.exec(employeeAuthorizationUrn);
95
+ if (!authorization
96
+ || authorization[1].toUpperCase() !== jurisdiction
97
+ || authorization[3] !== organizationIdentifier
98
+ || authorization[5] !== role.code) {
99
+ throw new TypeError('professional_credential_binding_invalid');
100
+ }
101
+ results.push(Object.freeze({
102
+ sector: provider.sector,
103
+ employeeAuthorizationUrn,
104
+ professionalDid,
105
+ providerDid,
106
+ gatewayBaseUrl: new URL(provider.baseUrl).origin,
107
+ tenantId: provider.tenantId,
108
+ jurisdiction,
109
+ role: role.claim,
110
+ }));
111
+ }
112
+ if (!results.length)
113
+ throw new TypeError('professional_profile_not_found');
114
+ return Object.freeze(results);
115
+ }
19
116
  function decodeShcQr(value) {
20
117
  const normalized = text(value);
21
118
  if (!/^shc:\/\d+$/.test(normalized))
@@ -231,20 +328,7 @@ export async function resolvePortableProfessionalEnrollment(input) {
231
328
  throw new TypeError('multiple_professional_providers_found');
232
329
  const provider = matches[0];
233
330
  const gatewayBaseUrl = new URL(provider.baseUrl).origin;
234
- const response = await fetchImpl(`${providerIssuer(provider)}/.well-known/did.json`, {
235
- headers: { accept: 'application/did+json, application/json' },
236
- });
237
- if (!response.ok)
238
- throw new TypeError('professional_tenant_did_unavailable');
239
- const document = record(await response.json());
240
- const providerDid = text(document.id);
241
- const expectedContext = `:${provider.tenantId}:cds-${provider.jurisdiction}:v1:${provider.sector}`;
242
- const services = Array.isArray(document.service) ? document.service : [];
243
- const hasJwks = services.some(service => text(record(service).type) === 'JsonWebKeyService2020'
244
- && /^https:\/\//i.test(text(record(service).serviceEndpoint)));
245
- if (!providerDid.startsWith('did:web:') || !providerDid.endsWith(expectedContext) || !hasJwks) {
246
- throw new TypeError('professional_tenant_did_invalid');
247
- }
331
+ const providerDid = await resolveProviderDid(provider, fetchImpl);
248
332
  const role = `ISCO-08|${authorizationRoleCode}`;
249
333
  const professionalDid = buildProfessionalDidWeb({ organizationDidWeb: providerDid, email: verifiedEmail, role });
250
334
  return Object.freeze({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sos-sdk-node-ts",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "description": "Product-neutral Node adapters shared by SOSChain, Vet and UHC runtimes",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Connecting Solution & Applications Ltd",