sos-sdk-node-ts 0.1.13 → 0.1.15
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 +25 -0
- package/README.md +9 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/portable-professional-evidence.d.ts +55 -0
- package/dist/portable-professional-evidence.js +260 -0
- package/dist/secure-organization-registration.d.ts +58 -0
- package/dist/secure-organization-registration.js +128 -0
- package/dist/server-passkey-profile.d.ts +8 -0
- package/dist/server-passkey-profile.js +24 -1
- package/docs/ORGANIZATION_REGISTRATION_SOURCE_EVALUATION.md +27 -0
- package/package.json +11 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.1.15 - 2026-10-22
|
|
6
|
+
|
|
7
|
+
- Add product-neutral verification and resolution for an existing professional
|
|
8
|
+
DID, employee-authorization URN or signed professional SHC. Invitation and
|
|
9
|
+
device registration remain separate host-managed operations.
|
|
10
|
+
|
|
11
|
+
## 0.1.14 - 2026-10-21
|
|
12
|
+
|
|
13
|
+
- Add a product-neutral server-profile owner assertion for personal passkey
|
|
14
|
+
registration. It binds the selected profile to the authenticated Firebase
|
|
15
|
+
principal without misinterpreting an individual-member actor DID as a
|
|
16
|
+
professional employee identifier; mandatory passkey enrollment after PIN
|
|
17
|
+
verification remains unchanged.
|
|
18
|
+
- Accept the browser WebAuthn secure-context exception for local loopback
|
|
19
|
+
origins while continuing to reject plain HTTP origins on remote hosts.
|
|
20
|
+
|
|
21
|
+
- Adopt the product-neutral secure organization registration runtime previously
|
|
22
|
+
specialized in the clinical Node SDK: reviewed controller bootstrap-wallet
|
|
23
|
+
reconstruction, encrypted verification/reissuance transport, Offer-to-Order
|
|
24
|
+
confirmation and terminal OperationOutcome rejection. Portal forms, sector
|
|
25
|
+
admission policy, profile persistence and deployment values remain outside
|
|
26
|
+
this package.
|
|
27
|
+
|
|
3
28
|
## 0.1.13 - 2026-10-21
|
|
4
29
|
|
|
5
30
|
- Add the trusted-BFF host organization directory client. It submits and polls
|
package/README.md
CHANGED
|
@@ -24,6 +24,15 @@ begins only after exact registry packages are installed and verified.
|
|
|
24
24
|
|
|
25
25
|
Product-neutral Node adapters for trusted SOSCHAIN runtimes.
|
|
26
26
|
|
|
27
|
+
`secure-organization-registration` owns the trusted Node transport used after
|
|
28
|
+
an application has been reviewed: deterministic reconstruction of the exact
|
|
29
|
+
controller bootstrap wallet, encrypted organization verification or credential
|
|
30
|
+
reissuance, and confirmation of the exact accepted Offer through Order. It
|
|
31
|
+
does not own portal forms, admission policy, user authentication, application
|
|
32
|
+
storage, payment policy or professional operations.
|
|
33
|
+
The capability-by-capability source decision is recorded in
|
|
34
|
+
[`docs/ORGANIZATION_REGISTRATION_SOURCE_EVALUATION.md`](docs/ORGANIZATION_REGISTRATION_SOURCE_EVALUATION.md).
|
|
35
|
+
|
|
27
36
|
```text
|
|
28
37
|
gdc-* frozen
|
|
29
38
|
↓
|
package/dist/index.d.ts
CHANGED
|
@@ -9,3 +9,5 @@ export * from './bff-email-assertion.js';
|
|
|
9
9
|
export * from './subject-card-directory-client.js';
|
|
10
10
|
export * from './server-passkey-profile.js';
|
|
11
11
|
export * from './host-organization-directory-client.js';
|
|
12
|
+
export * from './secure-organization-registration.js';
|
|
13
|
+
export * from './portable-professional-evidence.js';
|
package/dist/index.js
CHANGED
|
@@ -9,3 +9,5 @@ export * from './bff-email-assertion.js';
|
|
|
9
9
|
export * from './subject-card-directory-client.js';
|
|
10
10
|
export * from './server-passkey-profile.js';
|
|
11
11
|
export * from './host-organization-directory-client.js';
|
|
12
|
+
export * from './secure-organization-registration.js';
|
|
13
|
+
export * from './portable-professional-evidence.js';
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export type PortableProfessionalProvider = Readonly<{
|
|
2
|
+
baseUrl: string;
|
|
3
|
+
tenantId: string;
|
|
4
|
+
jurisdiction: string;
|
|
5
|
+
sector: string;
|
|
6
|
+
}>;
|
|
7
|
+
export type PortableProfessionalEnrollment = Readonly<{
|
|
8
|
+
sector: string;
|
|
9
|
+
employeeAuthorizationUrn: string;
|
|
10
|
+
professionalDid: string;
|
|
11
|
+
providerDid: string;
|
|
12
|
+
gatewayBaseUrl: string;
|
|
13
|
+
tenantId: string;
|
|
14
|
+
jurisdiction: string;
|
|
15
|
+
role: string;
|
|
16
|
+
}>;
|
|
17
|
+
/**
|
|
18
|
+
* Derives routing candidates only from evidence shape plus a server-owned host
|
|
19
|
+
* and sector allowlist. The returned candidate grants no authority: the signed
|
|
20
|
+
* credential or authoritative DID documents are still verified by the
|
|
21
|
+
* resolution functions below.
|
|
22
|
+
*/
|
|
23
|
+
export declare function derivePortableProfessionalProviderCandidates(input: Readonly<{
|
|
24
|
+
professionalCredential?: unknown;
|
|
25
|
+
professionalIdentifier?: unknown;
|
|
26
|
+
sectorHint?: unknown;
|
|
27
|
+
trustedGatewayBaseUrls: readonly string[];
|
|
28
|
+
allowedSectors: readonly string[];
|
|
29
|
+
}>): PortableProfessionalProvider[];
|
|
30
|
+
/**
|
|
31
|
+
* Verifies one signed professional SHC and returns only its existing
|
|
32
|
+
* organization-issued authorization reference. It creates neither an
|
|
33
|
+
* invitation nor a device registration.
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveProfessionalShcEnrollment(input: Readonly<{
|
|
36
|
+
credential: unknown;
|
|
37
|
+
providers: readonly PortableProfessionalProvider[];
|
|
38
|
+
fetchImpl?: typeof fetch;
|
|
39
|
+
nowSeconds?: number;
|
|
40
|
+
}>): Promise<Readonly<{
|
|
41
|
+
professionalIdentifier: string;
|
|
42
|
+
sectorHint: string;
|
|
43
|
+
}>>;
|
|
44
|
+
/**
|
|
45
|
+
* Resolves one organization-issued DID or authorization URN through
|
|
46
|
+
* server-owned provider configuration. The result still requires the host
|
|
47
|
+
* profile manager to perform licence, contact and device-registration checks.
|
|
48
|
+
*/
|
|
49
|
+
export declare function resolvePortableProfessionalEnrollment(input: Readonly<{
|
|
50
|
+
professionalIdentifier: unknown;
|
|
51
|
+
sectorHint: unknown;
|
|
52
|
+
verifiedEmail: unknown;
|
|
53
|
+
providers: readonly PortableProfessionalProvider[];
|
|
54
|
+
fetchImpl?: typeof fetch;
|
|
55
|
+
}>): Promise<PortableProfessionalEnrollment>;
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { createPublicKey, verify } from 'node:crypto';
|
|
2
|
+
import { inflateRawSync } from 'node:zlib';
|
|
3
|
+
import { buildProfessionalDidWeb } from 'gdc-common-utils-ts/utils/did';
|
|
4
|
+
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
|
+
function record(value) {
|
|
6
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
7
|
+
}
|
|
8
|
+
function text(value) {
|
|
9
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
10
|
+
}
|
|
11
|
+
function identifiers(resource) {
|
|
12
|
+
return (Array.isArray(resource.identifier) ? resource.identifier : [])
|
|
13
|
+
.map(item => text(record(item).value))
|
|
14
|
+
.filter(Boolean);
|
|
15
|
+
}
|
|
16
|
+
function providerIssuer(provider) {
|
|
17
|
+
return `${new URL(provider.baseUrl).origin}/${encodeURIComponent(provider.tenantId)}/cds-${encodeURIComponent(provider.jurisdiction)}/v1/${encodeURIComponent(provider.sector)}`;
|
|
18
|
+
}
|
|
19
|
+
function decodeShcQr(value) {
|
|
20
|
+
const normalized = text(value);
|
|
21
|
+
if (!/^shc:\/\d+$/.test(normalized))
|
|
22
|
+
throw new TypeError('professional_credential_shc_required');
|
|
23
|
+
const digits = normalized.slice('shc:/'.length);
|
|
24
|
+
if (!digits || digits.length % 2 !== 0)
|
|
25
|
+
throw new TypeError('professional_credential_shc_invalid');
|
|
26
|
+
let compact = '';
|
|
27
|
+
for (let index = 0; index < digits.length; index += 2) {
|
|
28
|
+
const pair = Number(digits.slice(index, index + 2));
|
|
29
|
+
if (pair > 77)
|
|
30
|
+
throw new TypeError('professional_credential_shc_invalid');
|
|
31
|
+
compact += String.fromCharCode(pair + 45);
|
|
32
|
+
}
|
|
33
|
+
return compact;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Derives routing candidates only from evidence shape plus a server-owned host
|
|
37
|
+
* and sector allowlist. The returned candidate grants no authority: the signed
|
|
38
|
+
* credential or authoritative DID documents are still verified by the
|
|
39
|
+
* resolution functions below.
|
|
40
|
+
*/
|
|
41
|
+
export function derivePortableProfessionalProviderCandidates(input) {
|
|
42
|
+
const hasCredential = Boolean(text(input.professionalCredential));
|
|
43
|
+
const professionalIdentifier = text(input.professionalIdentifier);
|
|
44
|
+
if (hasCredential === Boolean(professionalIdentifier))
|
|
45
|
+
throw new TypeError('professional_evidence_path_invalid');
|
|
46
|
+
let tenantId = '';
|
|
47
|
+
let jurisdiction = '';
|
|
48
|
+
let sector = text(input.sectorHint).toLowerCase();
|
|
49
|
+
let issuerOrigin = '';
|
|
50
|
+
if (hasCredential) {
|
|
51
|
+
const parts = decodeShcQr(input.professionalCredential).split('.');
|
|
52
|
+
if (parts.length !== 3)
|
|
53
|
+
throw new TypeError('professional_credential_shc_invalid');
|
|
54
|
+
let payload;
|
|
55
|
+
try {
|
|
56
|
+
payload = record(JSON.parse(inflateRawSync(Buffer.from(parts[1], 'base64url')).toString('utf8')));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw new TypeError('professional_credential_shc_invalid');
|
|
60
|
+
}
|
|
61
|
+
const issuer = new URL(text(payload.iss));
|
|
62
|
+
const match = issuer.pathname.match(/^\/([^/]+)\/cds-([^/]+)\/v[1-9][0-9]*\/([a-z][a-z0-9-]*)\/?$/i);
|
|
63
|
+
if (!match)
|
|
64
|
+
throw new TypeError('professional_credential_issuer_invalid');
|
|
65
|
+
tenantId = decodeURIComponent(match[1]);
|
|
66
|
+
jurisdiction = decodeURIComponent(match[2]);
|
|
67
|
+
sector = decodeURIComponent(match[3]).toLowerCase();
|
|
68
|
+
issuerOrigin = issuer.origin;
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
const authorization = EMPLOYEE_AUTHORIZATION_URN_PATTERN.exec(professionalIdentifier);
|
|
72
|
+
const did = professionalIdentifier.match(/:([^:]+):cds-([^:]+):v[1-9][0-9]*:([a-z][a-z0-9-]*):employee:/i);
|
|
73
|
+
if (authorization) {
|
|
74
|
+
jurisdiction = authorization[1];
|
|
75
|
+
tenantId = authorization[3];
|
|
76
|
+
}
|
|
77
|
+
else if (did) {
|
|
78
|
+
tenantId = did[1];
|
|
79
|
+
jurisdiction = did[2];
|
|
80
|
+
if (sector && sector !== did[3].toLowerCase())
|
|
81
|
+
throw new TypeError('professional_sector_mismatch');
|
|
82
|
+
sector = did[3].toLowerCase();
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
throw new TypeError('professional_credential_invalid');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const allowedSectors = new Set(input.allowedSectors.map(value => text(value).toLowerCase()).filter(Boolean));
|
|
89
|
+
if (!sector || !allowedSectors.has(sector))
|
|
90
|
+
throw new TypeError('professional_sector_not_allowed');
|
|
91
|
+
const origins = [...new Set(input.trustedGatewayBaseUrls.map(value => new URL(value).origin))]
|
|
92
|
+
.filter(origin => !issuerOrigin || origin === issuerOrigin);
|
|
93
|
+
if (origins.length === 0)
|
|
94
|
+
throw new TypeError('professional_provider_not_allowed');
|
|
95
|
+
return origins.map(baseUrl => Object.freeze({ baseUrl, tenantId, jurisdiction: jurisdiction.toUpperCase(), sector }));
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Verifies one signed professional SHC and returns only its existing
|
|
99
|
+
* organization-issued authorization reference. It creates neither an
|
|
100
|
+
* invitation nor a device registration.
|
|
101
|
+
*/
|
|
102
|
+
export async function resolveProfessionalShcEnrollment(input) {
|
|
103
|
+
const compact = decodeShcQr(input.credential);
|
|
104
|
+
const parts = compact.split('.');
|
|
105
|
+
if (parts.length !== 3)
|
|
106
|
+
throw new TypeError('professional_credential_shc_invalid');
|
|
107
|
+
let header;
|
|
108
|
+
let payload;
|
|
109
|
+
try {
|
|
110
|
+
header = record(JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf8')));
|
|
111
|
+
payload = record(JSON.parse(inflateRawSync(Buffer.from(parts[1], 'base64url')).toString('utf8')));
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
throw new TypeError('professional_credential_shc_invalid');
|
|
115
|
+
}
|
|
116
|
+
if (header.alg !== 'ES256' || header.zip !== 'DEF' || !text(header.kid)) {
|
|
117
|
+
throw new TypeError('professional_credential_header_invalid');
|
|
118
|
+
}
|
|
119
|
+
const issuer = text(payload.iss);
|
|
120
|
+
const notBefore = Number(payload.nbf);
|
|
121
|
+
if (!issuer.startsWith('https://') || !Number.isInteger(notBefore)
|
|
122
|
+
|| notBefore > (input.nowSeconds ?? Math.floor(Date.now() / 1000))) {
|
|
123
|
+
throw new TypeError('professional_credential_time_or_issuer_invalid');
|
|
124
|
+
}
|
|
125
|
+
const provider = input.providers.find(candidate => providerIssuer(candidate) === issuer);
|
|
126
|
+
if (!provider)
|
|
127
|
+
throw new TypeError('professional_credential_provider_not_allowed');
|
|
128
|
+
const jwksResponse = await (input.fetchImpl ?? fetch)(`${issuer}/.well-known/jwks.json`, {
|
|
129
|
+
headers: { accept: 'application/jwk-set+json, application/json' },
|
|
130
|
+
});
|
|
131
|
+
if (!jwksResponse.ok)
|
|
132
|
+
throw new TypeError('professional_credential_jwks_unavailable');
|
|
133
|
+
const jwks = record(await jwksResponse.json());
|
|
134
|
+
const key = (Array.isArray(jwks.keys) ? jwks.keys : []).map(record)
|
|
135
|
+
.find(candidate => candidate.kid === header.kid && candidate.kty === 'EC' && candidate.crv === 'P-256');
|
|
136
|
+
if (!key)
|
|
137
|
+
throw new TypeError('professional_credential_key_not_found');
|
|
138
|
+
let valid = false;
|
|
139
|
+
try {
|
|
140
|
+
valid = verify('sha256', Buffer.from(`${parts[0]}.${parts[1]}`), {
|
|
141
|
+
key: createPublicKey({ key: key, format: 'jwk' }),
|
|
142
|
+
dsaEncoding: 'ieee-p1363',
|
|
143
|
+
}, Buffer.from(parts[2], 'base64url'));
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
valid = false;
|
|
147
|
+
}
|
|
148
|
+
if (!valid)
|
|
149
|
+
throw new TypeError('professional_credential_signature_invalid');
|
|
150
|
+
const credentialSubject = record(record(payload.vc).credentialSubject);
|
|
151
|
+
const bundle = record(credentialSubject.fhirBundle);
|
|
152
|
+
if (bundle.resourceType !== 'Bundle')
|
|
153
|
+
throw new TypeError('professional_credential_bundle_invalid');
|
|
154
|
+
const resources = (Array.isArray(bundle.entry) ? bundle.entry : [])
|
|
155
|
+
.map(entry => record(record(entry).resource));
|
|
156
|
+
const organization = resources.find(resource => resource.resourceType === 'Organization');
|
|
157
|
+
const practitioner = resources.find(resource => resource.resourceType === 'Practitioner');
|
|
158
|
+
const practitionerRole = resources.find(resource => resource.resourceType === 'PractitionerRole');
|
|
159
|
+
if (!organization || !practitioner || !practitionerRole || practitionerRole.active === false) {
|
|
160
|
+
throw new TypeError('professional_credential_resources_required');
|
|
161
|
+
}
|
|
162
|
+
const authorizationUrn = identifiers(practitionerRole)
|
|
163
|
+
.find(value => EMPLOYEE_AUTHORIZATION_URN_PATTERN.test(value));
|
|
164
|
+
const authorization = authorizationUrn
|
|
165
|
+
? EMPLOYEE_AUTHORIZATION_URN_PATTERN.exec(authorizationUrn)
|
|
166
|
+
: undefined;
|
|
167
|
+
if (!authorization || !authorizationUrn)
|
|
168
|
+
throw new TypeError('professional_credential_authorization_missing');
|
|
169
|
+
const [, jurisdiction, , organizationOfficialId] = authorization;
|
|
170
|
+
if (jurisdiction.toLowerCase() !== provider.jurisdiction.toLowerCase()
|
|
171
|
+
|| organizationOfficialId !== provider.tenantId
|
|
172
|
+
|| !identifiers(organization).includes(provider.tenantId)
|
|
173
|
+
|| identifiers(practitioner).length === 0
|
|
174
|
+
|| text(record(practitionerRole.organization).reference) !== 'resource:0'
|
|
175
|
+
|| text(record(practitionerRole.practitioner).reference) !== 'resource:1') {
|
|
176
|
+
throw new TypeError('professional_credential_binding_invalid');
|
|
177
|
+
}
|
|
178
|
+
return Object.freeze({ professionalIdentifier: authorizationUrn, sectorHint: provider.sector });
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Resolves one organization-issued DID or authorization URN through
|
|
182
|
+
* server-owned provider configuration. The result still requires the host
|
|
183
|
+
* profile manager to perform licence, contact and device-registration checks.
|
|
184
|
+
*/
|
|
185
|
+
export async function resolvePortableProfessionalEnrollment(input) {
|
|
186
|
+
const professionalIdentifier = text(input.professionalIdentifier);
|
|
187
|
+
const sectorHint = text(input.sectorHint).toLowerCase();
|
|
188
|
+
const verifiedEmail = text(input.verifiedEmail).toLowerCase();
|
|
189
|
+
if (!/^[a-z][a-z0-9-]*$/.test(sectorHint))
|
|
190
|
+
throw new TypeError('professional_sector_hint_required');
|
|
191
|
+
if (!verifiedEmail)
|
|
192
|
+
throw new TypeError('verified_email_required');
|
|
193
|
+
const fetchImpl = input.fetchImpl ?? fetch;
|
|
194
|
+
let employeeAuthorizationUrn = professionalIdentifier;
|
|
195
|
+
let authorization = EMPLOYEE_AUTHORIZATION_URN_PATTERN.exec(employeeAuthorizationUrn);
|
|
196
|
+
if (!authorization && professionalIdentifier.startsWith('did:web:')) {
|
|
197
|
+
const didMatches = input.providers.flatMap(provider => {
|
|
198
|
+
const context = `:${provider.tenantId}:cds-${provider.jurisdiction}:v1:${provider.sector}:employee:`;
|
|
199
|
+
if (provider.sector.toLowerCase() !== sectorHint || !professionalIdentifier.includes(context))
|
|
200
|
+
return [];
|
|
201
|
+
const employeePath = professionalIdentifier.slice(professionalIdentifier.indexOf(context) + context.length);
|
|
202
|
+
const [memberId, ...roleParts] = employeePath.split(':');
|
|
203
|
+
const role = roleParts.join(':');
|
|
204
|
+
return memberId && role ? [{ provider, memberId, role }] : [];
|
|
205
|
+
});
|
|
206
|
+
if (didMatches.length === 0)
|
|
207
|
+
throw new TypeError('professional_provider_not_found');
|
|
208
|
+
if (didMatches.length > 1)
|
|
209
|
+
throw new TypeError('multiple_professional_providers_found');
|
|
210
|
+
const { provider, memberId, role } = didMatches[0];
|
|
211
|
+
const didUrl = `${providerIssuer(provider)}/employee/${encodeURIComponent(memberId)}/${encodeURIComponent(role)}/did.json`;
|
|
212
|
+
const didResponse = await fetchImpl(didUrl, { headers: { accept: 'application/did+json, application/json' } });
|
|
213
|
+
if (!didResponse.ok)
|
|
214
|
+
throw new TypeError('professional_did_unavailable');
|
|
215
|
+
const didDocument = record(await didResponse.json());
|
|
216
|
+
if (text(didDocument.id) !== professionalIdentifier)
|
|
217
|
+
throw new TypeError('professional_did_invalid');
|
|
218
|
+
employeeAuthorizationUrn = (Array.isArray(didDocument.alsoKnownAs) ? didDocument.alsoKnownAs : [])
|
|
219
|
+
.map(String).find(value => EMPLOYEE_AUTHORIZATION_URN_PATTERN.test(value)) || '';
|
|
220
|
+
authorization = EMPLOYEE_AUTHORIZATION_URN_PATTERN.exec(employeeAuthorizationUrn);
|
|
221
|
+
}
|
|
222
|
+
if (!authorization)
|
|
223
|
+
throw new TypeError('professional_credential_invalid');
|
|
224
|
+
const [, authorizationJurisdiction, , organizationOfficialId, , authorizationRoleCode] = authorization;
|
|
225
|
+
const matches = input.providers.filter(provider => provider.tenantId === organizationOfficialId
|
|
226
|
+
&& provider.jurisdiction.toLowerCase() === authorizationJurisdiction.toLowerCase()
|
|
227
|
+
&& provider.sector.toLowerCase() === sectorHint);
|
|
228
|
+
if (matches.length === 0)
|
|
229
|
+
throw new TypeError('professional_provider_not_found');
|
|
230
|
+
if (matches.length > 1)
|
|
231
|
+
throw new TypeError('multiple_professional_providers_found');
|
|
232
|
+
const provider = matches[0];
|
|
233
|
+
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
|
+
}
|
|
248
|
+
const role = `ISCO-08|${authorizationRoleCode}`;
|
|
249
|
+
const professionalDid = buildProfessionalDidWeb({ organizationDidWeb: providerDid, email: verifiedEmail, role });
|
|
250
|
+
return Object.freeze({
|
|
251
|
+
sector: provider.sector,
|
|
252
|
+
employeeAuthorizationUrn,
|
|
253
|
+
professionalDid,
|
|
254
|
+
providerDid,
|
|
255
|
+
gatewayBaseUrl,
|
|
256
|
+
tenantId: provider.tenantId,
|
|
257
|
+
jurisdiction: provider.jurisdiction.toUpperCase(),
|
|
258
|
+
role,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { resolveDidWebKeyAgreementJwk, type AppInfo, type HostRouteContext, type LegalOrganizationOrderInput, type NodeLegalOrganizationVerificationTransactionInput, type PollOptions, type RouteContext, type SubmitAndPollResult } from 'gdc-sdk-node-ts';
|
|
2
|
+
type PublicJwk = Readonly<Record<string, unknown>>;
|
|
3
|
+
export type HostScopedDidWebResolverOptions = Readonly<{
|
|
4
|
+
gatewayBaseUrl: string;
|
|
5
|
+
hostCoverageScope: string;
|
|
6
|
+
version: string;
|
|
7
|
+
hostNetwork: string;
|
|
8
|
+
fetchImpl?: typeof fetch;
|
|
9
|
+
}>;
|
|
10
|
+
export declare function buildHostScopedDidDocumentUrl(options: Omit<HostScopedDidWebResolverOptions, 'fetchImpl'>): string;
|
|
11
|
+
export declare function createHostScopedDidWebKeyAgreementResolver(options: HostScopedDidWebResolverOptions): (recipientDid: string) => ReturnType<typeof resolveDidWebKeyAgreementJwk>;
|
|
12
|
+
export type SecureOrganizationRegistrationRuntimeOptions = Readonly<{
|
|
13
|
+
gatewayBaseUrl: string;
|
|
14
|
+
appInfo?: AppInfo;
|
|
15
|
+
resolveRecipientJwk(recipientDid: string): Promise<PublicJwk>;
|
|
16
|
+
fetchImpl?: typeof fetch;
|
|
17
|
+
requestTimeoutMs?: number;
|
|
18
|
+
}>;
|
|
19
|
+
type SecureOrganizationBootstrapInput = Readonly<{
|
|
20
|
+
walletSeed: string;
|
|
21
|
+
walletKeyDerivationId: string;
|
|
22
|
+
bearerToken: string;
|
|
23
|
+
providerDid: string;
|
|
24
|
+
routeContext: RouteContext;
|
|
25
|
+
hostContext: HostRouteContext;
|
|
26
|
+
pollOptions?: PollOptions;
|
|
27
|
+
}>;
|
|
28
|
+
export type SecureOrganizationRegistrationInput = SecureOrganizationBootstrapInput & Readonly<{
|
|
29
|
+
verificationInput: NodeLegalOrganizationVerificationTransactionInput;
|
|
30
|
+
}>;
|
|
31
|
+
export type SecureOrganizationOrderInput = SecureOrganizationBootstrapInput & Readonly<{
|
|
32
|
+
orderInput: LegalOrganizationOrderInput;
|
|
33
|
+
}>;
|
|
34
|
+
export declare function buildOrganizationRegistrationWalletContext(walletKeyDerivationId: string): {
|
|
35
|
+
readonly profile: {
|
|
36
|
+
readonly profileId: string;
|
|
37
|
+
};
|
|
38
|
+
readonly runtime: {
|
|
39
|
+
readonly runtimeId: `${string}:server-runtime`;
|
|
40
|
+
readonly runtimeType: "backend-service";
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
/** Trusted-server facade for encrypted verification, reissuance and Offer-to-Order confirmation. */
|
|
44
|
+
export declare class SecureOrganizationRegistrationRuntime {
|
|
45
|
+
private readonly options;
|
|
46
|
+
constructor(options: SecureOrganizationRegistrationRuntimeOptions);
|
|
47
|
+
submitVerificationWithBootstrapWallet(input: SecureOrganizationRegistrationInput): Promise<SubmitAndPollResult>;
|
|
48
|
+
submitCredentialReissuanceWithBootstrapWallet(input: SecureOrganizationRegistrationInput): Promise<SubmitAndPollResult>;
|
|
49
|
+
confirmOrderWithBootstrapWallet(input: SecureOrganizationOrderInput): Promise<SubmitAndPollResult>;
|
|
50
|
+
private createEncryptedClient;
|
|
51
|
+
}
|
|
52
|
+
export declare function assertSuccessfulOrganizationRegistrationResult<T extends Readonly<{
|
|
53
|
+
poll: Readonly<{
|
|
54
|
+
status: number;
|
|
55
|
+
body: unknown;
|
|
56
|
+
}>;
|
|
57
|
+
}>>(result: T): T;
|
|
58
|
+
export {};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Copyright 2026 Connecting Solution & Applications Ltd under the Apache License, Version 2.0.
|
|
2
|
+
import { NodeHttpClient, NodeManagedWallet, TransportProfiles, resolveDidWebKeyAgreementJwk, } from 'gdc-sdk-node-ts';
|
|
3
|
+
import { buildAuthorityDidWeb } from 'gdc-common-utils-ts/utils/authority-resolution';
|
|
4
|
+
export function buildHostScopedDidDocumentUrl(options) {
|
|
5
|
+
const gatewayOrigin = secureOrigin(options.gatewayBaseUrl, 'host_did_gateway_url_required');
|
|
6
|
+
const coverage = routeSegment(options.hostCoverageScope, 'host_did_coverage_scope_required').toUpperCase();
|
|
7
|
+
const version = routeSegment(options.version, 'host_did_version_required');
|
|
8
|
+
const network = routeSegment(options.hostNetwork, 'host_did_network_required');
|
|
9
|
+
return `${gatewayOrigin}/host/cds-${coverage}/${version}/${network}/.well-known/did.json`;
|
|
10
|
+
}
|
|
11
|
+
export function createHostScopedDidWebKeyAgreementResolver(options) {
|
|
12
|
+
const gatewayOrigin = secureOrigin(options.gatewayBaseUrl, 'host_did_gateway_url_required');
|
|
13
|
+
const hostDid = buildAuthorityDidWeb({ authority: gatewayOrigin }).toLowerCase();
|
|
14
|
+
const didDocumentUrl = buildHostScopedDidDocumentUrl(options);
|
|
15
|
+
return recipientDid => resolveDidWebKeyAgreementJwk(recipientDid, {
|
|
16
|
+
fetchImpl: options.fetchImpl,
|
|
17
|
+
...(String(recipientDid || '').trim().toLowerCase() === hostDid ? { didDocumentUrl } : {}),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export function buildOrganizationRegistrationWalletContext(walletKeyDerivationId) {
|
|
21
|
+
const id = required(walletKeyDerivationId, 'organization_registration_wallet_context_required');
|
|
22
|
+
return { profile: { profileId: id }, runtime: { runtimeId: `${id}:server-runtime`, runtimeType: 'backend-service' } };
|
|
23
|
+
}
|
|
24
|
+
/** Trusted-server facade for encrypted verification, reissuance and Offer-to-Order confirmation. */
|
|
25
|
+
export class SecureOrganizationRegistrationRuntime {
|
|
26
|
+
options;
|
|
27
|
+
constructor(options) {
|
|
28
|
+
this.options = options;
|
|
29
|
+
secureOrigin(options.gatewayBaseUrl, 'organization_registration_gateway_url_required');
|
|
30
|
+
}
|
|
31
|
+
async submitVerificationWithBootstrapWallet(input) {
|
|
32
|
+
const client = await this.createEncryptedClient(input);
|
|
33
|
+
return assertSuccessfulOrganizationRegistrationResult(await client.submitLegalOrganizationVerificationTransaction(input.hostContext, input.verificationInput, input.pollOptions));
|
|
34
|
+
}
|
|
35
|
+
async submitCredentialReissuanceWithBootstrapWallet(input) {
|
|
36
|
+
const client = await this.createEncryptedClient(input);
|
|
37
|
+
return assertSuccessfulOrganizationRegistrationResult(await client.submitLegalOrganizationCredentialReissuance(input.hostContext, input.verificationInput, input.pollOptions));
|
|
38
|
+
}
|
|
39
|
+
async confirmOrderWithBootstrapWallet(input) {
|
|
40
|
+
const client = await this.createEncryptedClient(input);
|
|
41
|
+
return assertSuccessfulOrganizationRegistrationResult(await client.confirmLegalOrganizationOrder(input.hostContext, input.orderInput, input.pollOptions));
|
|
42
|
+
}
|
|
43
|
+
async createEncryptedClient(input) {
|
|
44
|
+
const walletKeyDerivationId = required(input.walletKeyDerivationId, 'organization_registration_wallet_context_required');
|
|
45
|
+
const walletSeed = required(input.walletSeed, 'organization_registration_wallet_seed_required');
|
|
46
|
+
const providerDid = required(input.providerDid, 'organization_registration_provider_did_required');
|
|
47
|
+
const bearerToken = required(input.bearerToken, 'organization_registration_bearer_required');
|
|
48
|
+
const wallet = new NodeManagedWallet({ resolveRecipientJwk: this.options.resolveRecipientJwk });
|
|
49
|
+
const context = buildOrganizationRegistrationWalletContext(walletKeyDerivationId);
|
|
50
|
+
await wallet.provisionManagedKeys(context, { ownerScope: 'profile', purposes: ['actor-signing', 'document-at-rest'], mode: 'deterministic', seedMaterial: walletSeed });
|
|
51
|
+
await wallet.provisionManagedKeys(context, { ownerScope: 'runtime', purposes: ['openid-id-token-signing', 'vp-token-signing', 'comm-signing', 'comm-encryption'], mode: 'deterministic', seedMaterial: walletSeed });
|
|
52
|
+
return new NodeHttpClient({
|
|
53
|
+
baseUrl: this.options.gatewayBaseUrl, bearerToken, ctx: input.routeContext,
|
|
54
|
+
appInfo: this.options.appInfo, fetchImpl: this.options.fetchImpl, requestTimeoutMs: this.options.requestTimeoutMs,
|
|
55
|
+
transportProfile: TransportProfiles.DidcommEncryptedForm,
|
|
56
|
+
secureTransportAdapter: {
|
|
57
|
+
pack: message => wallet.packForRecipientWithContext(message, providerDid, { context }),
|
|
58
|
+
unpack: async (jwe) => (await wallet.unpackWithContext(jwe, { context })).content,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function assertSuccessfulOrganizationRegistrationResult(result) {
|
|
64
|
+
const failure = findFailedOperationOutcome(result.poll.body);
|
|
65
|
+
if (result.poll.status >= 400 || failure) {
|
|
66
|
+
const code = String(failure?.issue.code || 'operation-outcome').trim() || 'operation-outcome';
|
|
67
|
+
const status = failure?.status || result.poll.status;
|
|
68
|
+
throw new Error(`organization_registration_gateway_failed:${status}:${code}`);
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
function findFailedOperationOutcome(value) {
|
|
73
|
+
const seen = new WeakSet();
|
|
74
|
+
const visit = (candidate, inheritedStatus) => {
|
|
75
|
+
if (!candidate || typeof candidate !== 'object' || seen.has(candidate))
|
|
76
|
+
return undefined;
|
|
77
|
+
seen.add(candidate);
|
|
78
|
+
if (Array.isArray(candidate)) {
|
|
79
|
+
for (const item of candidate) {
|
|
80
|
+
const failure = visit(item, inheritedStatus);
|
|
81
|
+
if (failure)
|
|
82
|
+
return failure;
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
const record = asRecord(candidate);
|
|
87
|
+
const responseStatus = parseHttpStatus(asRecord(record.response).status) || inheritedStatus;
|
|
88
|
+
if (record.resourceType === 'OperationOutcome') {
|
|
89
|
+
const issues = Array.isArray(record.issue) ? record.issue.map(asRecord) : [];
|
|
90
|
+
const issue = issues.find(item => ['error', 'fatal'].includes(String(item.severity || '').toLowerCase()));
|
|
91
|
+
if (issue)
|
|
92
|
+
return { issue, status: parseHttpStatus(issue.responseStatus) || responseStatus };
|
|
93
|
+
}
|
|
94
|
+
for (const nested of Object.values(record)) {
|
|
95
|
+
const failure = visit(nested, responseStatus);
|
|
96
|
+
if (failure)
|
|
97
|
+
return failure;
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
};
|
|
101
|
+
return visit(value);
|
|
102
|
+
}
|
|
103
|
+
function parseHttpStatus(value) {
|
|
104
|
+
const status = Number.parseInt(String(value ?? ''), 10);
|
|
105
|
+
return Number.isInteger(status) && status >= 100 && status <= 599 ? status : undefined;
|
|
106
|
+
}
|
|
107
|
+
function asRecord(value) {
|
|
108
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
109
|
+
}
|
|
110
|
+
function required(value, error) {
|
|
111
|
+
const normalized = String(value ?? '').trim();
|
|
112
|
+
if (!normalized)
|
|
113
|
+
throw new TypeError(error);
|
|
114
|
+
return normalized;
|
|
115
|
+
}
|
|
116
|
+
function routeSegment(value, error) {
|
|
117
|
+
const normalized = required(value, error);
|
|
118
|
+
if (!/^[A-Za-z0-9-]+$/u.test(normalized))
|
|
119
|
+
throw new TypeError(error);
|
|
120
|
+
return normalized;
|
|
121
|
+
}
|
|
122
|
+
function secureOrigin(value, error) {
|
|
123
|
+
const url = new URL(required(value, error));
|
|
124
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '::1'].includes(url.hostname))) {
|
|
125
|
+
throw new TypeError('organization_registration_gateway_https_required');
|
|
126
|
+
}
|
|
127
|
+
return url.origin;
|
|
128
|
+
}
|
|
@@ -102,6 +102,14 @@ export type ServerPasskeyProfileServiceOptions = Readonly<{
|
|
|
102
102
|
ceremonyTtlMs?: number;
|
|
103
103
|
accessTtlMs?: number;
|
|
104
104
|
}>;
|
|
105
|
+
/**
|
|
106
|
+
* Confirms that an authenticated Firebase principal owns the selected server
|
|
107
|
+
* profile. Personal actor DIDs describe the subject relationship and are not
|
|
108
|
+
* professional employee identifiers, so they must not be parsed as one.
|
|
109
|
+
*/
|
|
110
|
+
export declare function assertServerProfileOwner<T extends Readonly<{
|
|
111
|
+
ownerId: string;
|
|
112
|
+
}>>(profile: T | undefined, ownerId: string): asserts profile is T;
|
|
105
113
|
/** Product-neutral passkey state machine for SOS, health and animal portal BFFs. */
|
|
106
114
|
export declare class ServerPasskeyProfileService {
|
|
107
115
|
private readonly options;
|
|
@@ -2,6 +2,17 @@ import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
|
|
3
3
|
const CEREMONY_TTL_MS = 5 * 60 * 1000;
|
|
4
4
|
const ACCESS_TTL_MS = 15 * 60 * 1000;
|
|
5
|
+
/**
|
|
6
|
+
* Confirms that an authenticated Firebase principal owns the selected server
|
|
7
|
+
* profile. Personal actor DIDs describe the subject relationship and are not
|
|
8
|
+
* professional employee identifiers, so they must not be parsed as one.
|
|
9
|
+
*/
|
|
10
|
+
export function assertServerProfileOwner(profile, ownerId) {
|
|
11
|
+
const expectedOwnerId = ownerId.trim();
|
|
12
|
+
if (!profile || !expectedOwnerId || profile.ownerId !== expectedOwnerId) {
|
|
13
|
+
throw new Error('profile_not_found');
|
|
14
|
+
}
|
|
15
|
+
}
|
|
5
16
|
/** Product-neutral passkey state machine for SOS, health and animal portal BFFs. */
|
|
6
17
|
export class ServerPasskeyProfileService {
|
|
7
18
|
options;
|
|
@@ -140,7 +151,7 @@ export function createSimpleWebAuthnCeremonyAdapter(input) {
|
|
|
140
151
|
const rpName = String(input.rpName || '').trim();
|
|
141
152
|
const rpId = String(input.rpId || '').trim();
|
|
142
153
|
const origins = input.origins.map(value => String(value || '').trim()).filter(Boolean);
|
|
143
|
-
if (!rpName || !rpId || !origins.length || origins.some(origin => !origin
|
|
154
|
+
if (!rpName || !rpId || !origins.length || origins.some(origin => !isSecureWebAuthnOrigin(origin))) {
|
|
144
155
|
throw new Error('passkey_relying_party_not_configured');
|
|
145
156
|
}
|
|
146
157
|
return {
|
|
@@ -204,3 +215,15 @@ export function createSimpleWebAuthnCeremonyAdapter(input) {
|
|
|
204
215
|
},
|
|
205
216
|
};
|
|
206
217
|
}
|
|
218
|
+
function isSecureWebAuthnOrigin(origin) {
|
|
219
|
+
try {
|
|
220
|
+
const parsed = new URL(origin);
|
|
221
|
+
if (parsed.protocol === 'https:')
|
|
222
|
+
return true;
|
|
223
|
+
return parsed.protocol === 'http:'
|
|
224
|
+
&& (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1' || parsed.hostname === '[::1]');
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Organization registration source evaluation
|
|
2
|
+
|
|
3
|
+
The shared SOS Node SDK evaluated the existing clinical organization
|
|
4
|
+
registration runtime capability-by-capability. It did not copy a portal,
|
|
5
|
+
product policy or deployment configuration.
|
|
6
|
+
|
|
7
|
+
| Capability | Decision | Shared boundary |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| deterministic controller bootstrap-wallet reconstruction | adopted | trusted Node SDK |
|
|
10
|
+
| encrypted DIDComm form transport to SOS Gateway | adopted | trusted Node SDK |
|
|
11
|
+
| organization verification and credential reissuance submission | adopted | trusted Node SDK |
|
|
12
|
+
| exact accepted Offer continuation through Order | adopted | trusted Node SDK |
|
|
13
|
+
| terminal nested `OperationOutcome` rejection | adopted | trusted Node SDK |
|
|
14
|
+
| host DID document route and recipient key resolution | adopted | trusted Node SDK |
|
|
15
|
+
| host organization search and lifecycle | already equivalent | existing host-directory client |
|
|
16
|
+
| application form, localization and product branding | rejected | hosting/controller presentation |
|
|
17
|
+
| sector and jurisdiction admission policy | rejected | trusted portal BFF and ICA |
|
|
18
|
+
| application, PIN and controller-profile persistence | rejected | trusted portal BFF |
|
|
19
|
+
| user authentication and account recovery | rejected | portal identity boundary |
|
|
20
|
+
| payment adapter and commercial policy | rejected | marketplace and billing boundary |
|
|
21
|
+
| professional groups, subjects, services, studies and scheduling | rejected | delegated professional workflows |
|
|
22
|
+
| tenant, host, network, credentials and deployment values | rejected | private deployment configuration |
|
|
23
|
+
|
|
24
|
+
This extraction is a transport prerequisite, not proof of an organization
|
|
25
|
+
onboarding journey. A consumer still has to prove application submission,
|
|
26
|
+
review, verification, Offer, Order, activation, device registration and
|
|
27
|
+
authoritative readback through its real BFF and Gateway environment.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sos-sdk-node-ts",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
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",
|
|
@@ -51,6 +51,14 @@
|
|
|
51
51
|
"./host-organization-directory-client": {
|
|
52
52
|
"types": "./dist/host-organization-directory-client.d.ts",
|
|
53
53
|
"default": "./dist/host-organization-directory-client.js"
|
|
54
|
+
},
|
|
55
|
+
"./secure-organization-registration": {
|
|
56
|
+
"types": "./dist/secure-organization-registration.d.ts",
|
|
57
|
+
"default": "./dist/secure-organization-registration.js"
|
|
58
|
+
},
|
|
59
|
+
"./portable-professional-evidence": {
|
|
60
|
+
"types": "./dist/portable-professional-evidence.d.ts",
|
|
61
|
+
"default": "./dist/portable-professional-evidence.js"
|
|
54
62
|
}
|
|
55
63
|
},
|
|
56
64
|
"files": [
|
|
@@ -70,6 +78,8 @@
|
|
|
70
78
|
},
|
|
71
79
|
"dependencies": {
|
|
72
80
|
"@simplewebauthn/server": "13.3.2",
|
|
81
|
+
"gdc-common-utils-ts": "2.9.30",
|
|
82
|
+
"gdc-sdk-node-ts": "2.9.32",
|
|
73
83
|
"sos-data-utils-ts": "0.6.13",
|
|
74
84
|
"sos-sdk-core-ts": "0.5.2"
|
|
75
85
|
},
|