sos-sdk-node-ts 0.1.17 → 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,21 @@
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
+
12
+ ## 0.1.18 - 2026-11-03
13
+
14
+ - Add a trusted Node client for subject-card provider advertisements and
15
+ federated authorized-subject reads. Provider failures remain visible while
16
+ successful directories continue to return their cards.
17
+ - Consume `sos-data-utils-ts@0.6.21` and `sos-sdk-core-ts@0.5.4`, the exact
18
+ published provider-discovery contracts validated by this release.
19
+
5
20
  ## 0.1.17 - 2026-10-22
6
21
 
7
22
  - Consume the shared historical NCBI OBO taxonomy read compatibility used by
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
package/dist/index.d.ts CHANGED
@@ -11,3 +11,4 @@ export * from './server-passkey-profile.js';
11
11
  export * from './host-organization-directory-client.js';
12
12
  export * from './secure-organization-registration.js';
13
13
  export * from './portable-professional-evidence.js';
14
+ export * from './subject-card-provider-catalog-client.js';
package/dist/index.js CHANGED
@@ -11,3 +11,4 @@ export * from './server-passkey-profile.js';
11
11
  export * from './host-organization-directory-client.js';
12
12
  export * from './secure-organization-registration.js';
13
13
  export * from './portable-professional-evidence.js';
14
+ export * from './subject-card-provider-catalog-client.js';
@@ -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({
@@ -0,0 +1,32 @@
1
+ import type { SubjectCardProviderDescriptor } from 'sos-data-utils-ts/subject-card-directory';
2
+ import { type SubjectCardProviderDiscoveryFilter } from 'sos-sdk-core-ts/subject-card-provider-discovery';
3
+ import { type AuthorizedIndividualSubject, type AuthorizedSubjectVerifiedContact } from './subject-card-directory-client.js';
4
+ export type SosSubjectCardProviderCatalogClientOptions = Readonly<{
5
+ catalogUrl: string;
6
+ bearerToken?: string;
7
+ fetchImpl?: typeof fetch;
8
+ }>;
9
+ /** Reads public provider advertisements from a data-space JSON:API catalog. */
10
+ export declare class SosSubjectCardProviderCatalogClient {
11
+ private readonly catalogUrl;
12
+ private readonly bearerToken?;
13
+ private readonly fetchImpl;
14
+ constructor(options: SosSubjectCardProviderCatalogClientOptions);
15
+ discover(filter: SubjectCardProviderDiscoveryFilter): Promise<readonly SubjectCardProviderDescriptor[]>;
16
+ }
17
+ export type FederatedProviderSubject = Readonly<{
18
+ providerId: string;
19
+ subject: AuthorizedIndividualSubject;
20
+ }>;
21
+ export type FederatedProviderSubjectResult = Readonly<{
22
+ subjects: readonly FederatedProviderSubject[];
23
+ unavailableProviderIds: readonly string[];
24
+ }>;
25
+ /** Queries every advertised directory independently and keeps partial success. */
26
+ export declare function readFederatedProviderSubjects(input: Readonly<{
27
+ providers: readonly SubjectCardProviderDescriptor[];
28
+ subjectKind?: 'human' | 'animal';
29
+ verifiedContact: AuthorizedSubjectVerifiedContact;
30
+ bearerToken?: string;
31
+ readProvider?: (provider: SubjectCardProviderDescriptor, verifiedContact: AuthorizedSubjectVerifiedContact) => Promise<readonly AuthorizedIndividualSubject[]>;
32
+ }>): Promise<FederatedProviderSubjectResult>;
@@ -0,0 +1,100 @@
1
+ import { discoverSubjectCardProviders, } from 'sos-sdk-core-ts/subject-card-provider-discovery';
2
+ import { SosGatewaySubjectDirectoryClient, } from './subject-card-directory-client.js';
3
+ /** Reads public provider advertisements from a data-space JSON:API catalog. */
4
+ export class SosSubjectCardProviderCatalogClient {
5
+ catalogUrl;
6
+ bearerToken;
7
+ fetchImpl;
8
+ constructor(options) {
9
+ this.catalogUrl = secureUrl(options.catalogUrl);
10
+ this.bearerToken = optional(options.bearerToken);
11
+ this.fetchImpl = options.fetchImpl ?? fetch;
12
+ }
13
+ async discover(filter) {
14
+ const url = new URL(this.catalogUrl);
15
+ url.searchParams.set('subjectKind', filter.subjectKind);
16
+ url.searchParams.set('operation', filter.operation);
17
+ const response = await this.fetchImpl(url, {
18
+ headers: {
19
+ Accept: 'application/vnd.api+json',
20
+ ...(this.bearerToken ? { Authorization: `Bearer ${this.bearerToken}` } : {}),
21
+ },
22
+ });
23
+ if (!response.ok)
24
+ throw new Error(`provider_catalog_failed:${response.status}`);
25
+ const body = await response.json();
26
+ if (!Array.isArray(body.data))
27
+ throw new Error('provider_catalog_response_invalid');
28
+ const catalog = body.data.map(entry => {
29
+ const row = record(entry);
30
+ if (row.type !== 'subject-card-provider')
31
+ throw new Error('provider_catalog_entry_invalid');
32
+ return record(row.attributes);
33
+ });
34
+ return discoverSubjectCardProviders(catalog, filter);
35
+ }
36
+ }
37
+ /** Queries every advertised directory independently and keeps partial success. */
38
+ export async function readFederatedProviderSubjects(input) {
39
+ const providers = discoverSubjectCardProviders(input.providers, {
40
+ subjectKind: input.subjectKind ?? commonSubjectKind(input.providers),
41
+ operation: 'directory',
42
+ });
43
+ const readProvider = input.readProvider ?? (async (provider) => {
44
+ const route = parseDirectoryRoute(provider.directoryRoute);
45
+ const client = new SosGatewaySubjectDirectoryClient({
46
+ baseUrl: route.baseUrl,
47
+ bearerToken: input.bearerToken,
48
+ });
49
+ return client.listAuthorizedIndividualSubjects(route.context, {
50
+ verifiedContact: input.verifiedContact,
51
+ });
52
+ });
53
+ const settled = await Promise.allSettled(providers.map(provider => readProvider(provider, input.verifiedContact)));
54
+ const subjects = [];
55
+ const unavailableProviderIds = [];
56
+ for (const [index, result] of settled.entries()) {
57
+ const provider = providers[index];
58
+ if (result.status === 'rejected') {
59
+ unavailableProviderIds.push(provider.providerId);
60
+ continue;
61
+ }
62
+ for (const subject of result.value)
63
+ subjects.push(Object.freeze({ providerId: provider.providerId, subject }));
64
+ }
65
+ return Object.freeze({
66
+ subjects: Object.freeze(subjects),
67
+ unavailableProviderIds: Object.freeze(unavailableProviderIds),
68
+ });
69
+ }
70
+ function commonSubjectKind(providers) {
71
+ const kinds = new Set(providers.flatMap(provider => provider.subjectKinds));
72
+ if (kinds.size !== 1)
73
+ throw new Error('provider_subject_kind_filter_required');
74
+ return [...kinds][0];
75
+ }
76
+ function parseDirectoryRoute(value) {
77
+ const url = secureUrl(value);
78
+ const segments = url.pathname.split('/').filter(Boolean);
79
+ if (segments.length !== 4 || !segments[1]?.startsWith('cds-') || segments[2] !== 'v1') {
80
+ throw new Error('provider_directory_route_invalid');
81
+ }
82
+ return Object.freeze({
83
+ baseUrl: url.origin,
84
+ context: Object.freeze({ tenantId: segments[0], jurisdiction: segments[1].slice(4), sector: segments[3] }),
85
+ });
86
+ }
87
+ function secureUrl(value) {
88
+ const url = new URL(value);
89
+ const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
90
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local))
91
+ throw new Error('provider_url_insecure');
92
+ return url;
93
+ }
94
+ function optional(value) {
95
+ const normalized = String(value ?? '').trim();
96
+ return normalized || undefined;
97
+ }
98
+ function record(value) {
99
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
100
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sos-sdk-node-ts",
3
- "version": "0.1.17",
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",
@@ -59,6 +59,10 @@
59
59
  "./portable-professional-evidence": {
60
60
  "types": "./dist/portable-professional-evidence.d.ts",
61
61
  "default": "./dist/portable-professional-evidence.js"
62
+ },
63
+ "./subject-card-provider-catalog-client": {
64
+ "types": "./dist/subject-card-provider-catalog-client.d.ts",
65
+ "default": "./dist/subject-card-provider-catalog-client.js"
62
66
  }
63
67
  },
64
68
  "files": [
@@ -80,8 +84,8 @@
80
84
  "@simplewebauthn/server": "13.3.2",
81
85
  "gdc-common-utils-ts": "2.9.30",
82
86
  "gdc-sdk-node-ts": "2.9.32",
83
- "sos-data-utils-ts": "0.6.17",
84
- "sos-sdk-core-ts": "0.5.2"
87
+ "sos-data-utils-ts": "0.6.21",
88
+ "sos-sdk-core-ts": "0.5.4"
85
89
  },
86
90
  "devDependencies": {
87
91
  "@types/node": "^24.10.0",