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
|
|
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