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 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.startsWith('https://'))) {
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.13",
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
  },