sos-sdk-node-ts 0.1.12 → 0.1.14

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,32 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.14 - 2026-10-21
6
+
7
+ - Add a product-neutral server-profile owner assertion for personal passkey
8
+ registration. It binds the selected profile to the authenticated Firebase
9
+ principal without misinterpreting an individual-member actor DID as a
10
+ professional employee identifier; mandatory passkey enrollment after PIN
11
+ verification remains unchanged.
12
+ - Accept the browser WebAuthn secure-context exception for local loopback
13
+ origins while continuing to reject plain HTTP origins on remote hosts.
14
+
15
+ - Adopt the product-neutral secure organization registration runtime previously
16
+ specialized in the clinical Node SDK: reviewed controller bootstrap-wallet
17
+ reconstruction, encrypted verification/reissuance transport, Offer-to-Order
18
+ confirmation and terminal OperationOutcome rejection. Portal forms, sector
19
+ admission policy, profile persistence and deployment values remain outside
20
+ this package.
21
+
22
+ ## 0.1.13 - 2026-10-21
23
+
24
+ - Add the trusted-BFF host organization directory client. It submits and polls
25
+ the canonical host `org.schema/Organization/_search` route, reads a
26
+ DIDComm-wrapped paginated Bundle, preserves opaque cursors and surfaces
27
+ Bundle `OperationOutcome` failures without accepting browser-selected host
28
+ authority. The same client performs status, suspend, reactivate and purge
29
+ operations and reads their authoritative Bundle results.
30
+
5
31
  ## 0.1.12 - 2026-10-21
6
32
 
7
33
  - Add the product-neutral server passkey state machine used by SOSChain, UHC
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
  ↓
@@ -69,6 +78,13 @@ BFF. It matches exact aliases inside `Organization.sameAs` CSV values and
69
78
  rejects removed animal-card DID layouts before UI mapping. See
70
79
  [the authorized subject directory contract](docs/AUTHORIZED_SUBJECT_DIRECTORY.md).
71
80
 
81
+ `sos-sdk-node-ts/host-organization-directory-client` is the separate trusted-
82
+ BFF client for a host operator's organization directory. It sends the
83
+ canonical `org.schema/Organization/_search` request, polls the matching
84
+ response route and returns the privacy-minimized, paginated Bundle projection.
85
+ The host jurisdiction and network come from trusted server configuration;
86
+ they are never browser-selected authority.
87
+
72
88
  ## Secure-message CSPRNG adapter
73
89
 
74
90
  `sos-sdk-node-ts/secure-message-crypto` exports `NodeSecureRandomSource` and
@@ -0,0 +1,78 @@
1
+ export type HostOrganizationDirectoryRoute = Readonly<{
2
+ jurisdiction: string;
3
+ network: 'test' | 'local-network' | 'test-network' | 'network';
4
+ }>;
5
+ export type HostOrganizationDirectoryFilters = Readonly<{
6
+ jurisdiction?: string;
7
+ sector?: string;
8
+ status?: 'active' | 'suspended' | 'revoked';
9
+ identifierType?: string;
10
+ identifierValue?: string;
11
+ legalName?: string;
12
+ name?: string;
13
+ }>;
14
+ export type HostOrganizationDirectoryEntry = Readonly<{
15
+ id: string;
16
+ legalName: string;
17
+ name: string;
18
+ identifierType: string;
19
+ identifierValue: string;
20
+ jurisdiction: string;
21
+ sector: string;
22
+ status: 'active' | 'suspended' | 'revoked';
23
+ sameAs?: string;
24
+ claims: Readonly<Record<string, unknown>>;
25
+ }>;
26
+ export type HostOrganizationDirectoryPage = Readonly<{
27
+ total: number;
28
+ pageSize: number;
29
+ returned: number;
30
+ hasNext: boolean;
31
+ nextCursor?: string;
32
+ organizations: readonly HostOrganizationDirectoryEntry[];
33
+ }>;
34
+ export type HostOrganizationDirectorySearch = Readonly<{
35
+ filters?: HostOrganizationDirectoryFilters;
36
+ pageSize?: number;
37
+ cursor?: string;
38
+ requestThid?: string;
39
+ pollOptions?: Readonly<{
40
+ timeoutMs?: number;
41
+ intervalMs?: number;
42
+ }>;
43
+ }>;
44
+ export type HostOrganizationLifecycleAction = '_status' | '_disable' | '_enable' | '_purge';
45
+ export type HostOrganizationLifecycleTarget = Readonly<{
46
+ identifierType: string;
47
+ identifierValue: string;
48
+ sameAs?: string;
49
+ }>;
50
+ export type HostOrganizationLifecycleResult = Readonly<{
51
+ status: 'active' | 'suspended' | 'revoked';
52
+ disposition?: string;
53
+ organizationId?: string;
54
+ }>;
55
+ export type SosGatewayHostOrganizationDirectoryClientOptions = Readonly<{
56
+ baseUrl: string;
57
+ bearerToken?: string;
58
+ appId?: string;
59
+ appVersion?: string;
60
+ defaultHeaders?: Readonly<Record<string, string>>;
61
+ requestTimeoutMs?: number;
62
+ fetchImpl?: typeof fetch;
63
+ }>;
64
+ /** Trusted-BFF transport for the host-only Organization registry directory. */
65
+ export declare class SosGatewayHostOrganizationDirectoryClient {
66
+ private readonly baseUrl;
67
+ private readonly headers;
68
+ private readonly requestTimeoutMs;
69
+ private readonly fetchImpl;
70
+ constructor(options: SosGatewayHostOrganizationDirectoryClientOptions);
71
+ search(route: HostOrganizationDirectoryRoute, input?: HostOrganizationDirectorySearch): Promise<HostOrganizationDirectoryPage>;
72
+ lifecycle(route: HostOrganizationDirectoryRoute, action: HostOrganizationLifecycleAction, target: HostOrganizationLifecycleTarget, options?: Readonly<{
73
+ timeoutMs?: number;
74
+ intervalMs?: number;
75
+ }>): Promise<HostOrganizationLifecycleResult>;
76
+ private submitAndPoll;
77
+ private post;
78
+ }
@@ -0,0 +1,248 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { buildGatewayApplicationHeaders } from 'sos-data-utils-ts/gateway-application-identity';
3
+ const CLAIM_LEGAL_NAME = 'org.schema.Organization.legalName';
4
+ const CLAIM_NAME = 'org.schema.Organization.name';
5
+ const CLAIM_IDENTIFIER_TYPE = 'org.schema.Organization.identifier.additionalType';
6
+ const CLAIM_IDENTIFIER_VALUE = 'org.schema.Organization.identifier.value';
7
+ const CLAIM_JURISDICTION = 'org.schema.Organization.address.addressCountry';
8
+ const CLAIM_SAME_AS = 'org.schema.Organization.sameAs';
9
+ const CLAIM_SECTOR = 'org.schema.Service.category';
10
+ const CLAIM_STATUS = 'org.schema.Action.tenantAuthorization.status';
11
+ /** Trusted-BFF transport for the host-only Organization registry directory. */
12
+ export class SosGatewayHostOrganizationDirectoryClient {
13
+ baseUrl;
14
+ headers;
15
+ requestTimeoutMs;
16
+ fetchImpl;
17
+ constructor(options) {
18
+ this.baseUrl = normalizeBaseUrl(options.baseUrl);
19
+ this.requestTimeoutMs = positiveInteger(options.requestTimeoutMs ?? 15_000, 'request_timeout_invalid');
20
+ this.fetchImpl = options.fetchImpl ?? fetch;
21
+ const bearerToken = optional(options.bearerToken);
22
+ const appId = optional(options.appId);
23
+ this.headers = Object.freeze({
24
+ 'Content-Type': 'application/didcomm-plain+json',
25
+ Accept: 'application/didcomm-plain+json',
26
+ ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
27
+ ...(appId ? buildGatewayApplicationHeaders({ appId, appVersion: options.appVersion }) : {}),
28
+ ...(options.defaultHeaders ?? {}),
29
+ });
30
+ }
31
+ async search(route, input = {}) {
32
+ const context = normalizeRoute(route);
33
+ const thid = optional(input.requestThid) || `host-organization-directory-${randomUUID()}`;
34
+ const claims = filterClaims(input.filters);
35
+ const body = {
36
+ resourceType: 'Bundle',
37
+ type: 'searchset',
38
+ data: [{
39
+ type: 'Organization-search-v1.0',
40
+ request: { method: 'GET', url: 'Organization/_search' },
41
+ resource: { resourceType: 'Organization', meta: { claims } },
42
+ }],
43
+ meta: { pagination: {
44
+ ...(input.pageSize === undefined ? {} : { pageSize: positiveInteger(input.pageSize, 'page_size_invalid') }),
45
+ ...(optional(input.cursor) ? { cursor: optional(input.cursor) } : {}),
46
+ } },
47
+ };
48
+ const prefix = `/host/cds-${encodeURIComponent(context.jurisdiction)}/v1/${encodeURIComponent(context.network)}/registry/org.schema/Organization`;
49
+ const response = await this.submitAndPoll(`${prefix}/_search`, `${prefix}/_search-response`, { thid, body }, input.pollOptions);
50
+ return readPage(response.body);
51
+ }
52
+ async lifecycle(route, action, target, options) {
53
+ const context = normalizeRoute(route);
54
+ const identifierType = required(target.identifierType, 'host_lifecycle_identifier_type_required');
55
+ const identifierValue = required(target.identifierValue, 'host_lifecycle_identifier_value_required');
56
+ const thid = `host-organization-lifecycle-${randomUUID()}`;
57
+ const claims = {
58
+ [CLAIM_IDENTIFIER_TYPE]: identifierType,
59
+ [CLAIM_IDENTIFIER_VALUE]: identifierValue,
60
+ ...(optional(target.sameAs) ? { [CLAIM_SAME_AS]: optional(target.sameAs) } : {}),
61
+ };
62
+ const body = {
63
+ resourceType: 'Bundle',
64
+ type: 'batch',
65
+ data: [{
66
+ type: `Organization-${action.slice(1)}-request-v1.0`,
67
+ request: { method: 'POST', url: `Organization/${action}` },
68
+ resource: { resourceType: 'Organization', meta: { claims } },
69
+ }],
70
+ };
71
+ const prefix = `/host/cds-${encodeURIComponent(context.jurisdiction)}/v1/${encodeURIComponent(context.network)}/registry/org.schema/Organization`;
72
+ const response = await this.submitAndPoll(`${prefix}/${action}`, `${prefix}/${action}-response`, { thid, body }, options);
73
+ return readLifecycleResult(response.body);
74
+ }
75
+ async submitAndPoll(submitPath, pollPath, payload, options) {
76
+ const submit = await this.post(submitPath, payload);
77
+ requireSuccessfulResponse(submit, 'gateway_submit_failed');
78
+ const timeoutMs = positiveInteger(options?.timeoutMs ?? 30_000, 'poll_timeout_invalid');
79
+ const intervalMs = positiveInteger(options?.intervalMs ?? 250, 'poll_interval_invalid');
80
+ const started = Date.now();
81
+ for (;;) {
82
+ const poll = await this.post(pollPath, { thid: payload.thid });
83
+ if (poll.status !== 202) {
84
+ requireSuccessfulResponse(poll, 'gateway_poll_failed');
85
+ return poll;
86
+ }
87
+ if (Date.now() - started >= timeoutMs)
88
+ throw new Error('gateway_poll_timeout');
89
+ await new Promise(resolve => setTimeout(resolve, intervalMs));
90
+ }
91
+ }
92
+ async post(path, payload) {
93
+ const controller = new AbortController();
94
+ const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs);
95
+ try {
96
+ const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
97
+ method: 'POST', headers: this.headers, body: JSON.stringify(payload), signal: controller.signal,
98
+ });
99
+ const raw = await response.text();
100
+ let body = {};
101
+ if (raw) {
102
+ try {
103
+ body = JSON.parse(raw);
104
+ }
105
+ catch {
106
+ body = raw;
107
+ }
108
+ }
109
+ return { status: response.status, body };
110
+ }
111
+ finally {
112
+ clearTimeout(timeout);
113
+ }
114
+ }
115
+ }
116
+ function normalizeRoute(value) {
117
+ const keys = Object.keys(value);
118
+ if (keys.some(key => key !== 'jurisdiction' && key !== 'network'))
119
+ throw new Error('route_context_invalid');
120
+ const jurisdiction = segment(value.jurisdiction);
121
+ const network = segment(value.network);
122
+ if (!['test', 'local-network', 'test-network', 'network'].includes(network))
123
+ throw new Error('route_context_invalid');
124
+ return Object.freeze({ jurisdiction, network: network });
125
+ }
126
+ function filterClaims(filters = {}) {
127
+ return Object.freeze(Object.fromEntries([
128
+ [CLAIM_JURISDICTION, filters.jurisdiction],
129
+ [CLAIM_SECTOR, filters.sector],
130
+ [CLAIM_STATUS, filters.status],
131
+ [CLAIM_IDENTIFIER_TYPE, filters.identifierType],
132
+ [CLAIM_IDENTIFIER_VALUE, filters.identifierValue],
133
+ [CLAIM_LEGAL_NAME, filters.legalName],
134
+ [CLAIM_NAME, filters.name],
135
+ ].flatMap(([key, value]) => optional(value) ? [[key, optional(value)]] : [])));
136
+ }
137
+ function readPage(value) {
138
+ const root = record(value);
139
+ const bundle = record(root.body || root);
140
+ const data = Array.isArray(bundle.data) ? bundle.data : [];
141
+ const outcome = data.flatMap((entry) => {
142
+ const response = record(record(entry).response);
143
+ const result = record(response.outcome);
144
+ return result.resourceType === 'OperationOutcome' && Array.isArray(result.issue) ? result.issue : [];
145
+ }).map(record).find(issue => ['fatal', 'error'].includes(String(issue.severity || '').toLowerCase()));
146
+ if (outcome)
147
+ throw new Error(`host_directory_failed:${String(outcome.diagnostics || outcome.code || 'operation_outcome')}`);
148
+ if (bundle.resourceType !== 'Bundle' || bundle.type !== 'searchset')
149
+ throw new Error('host_directory_bundle_invalid');
150
+ const organizations = data.flatMap((entry) => {
151
+ const row = record(entry);
152
+ const response = record(row.response);
153
+ const resource = record(row.resource);
154
+ const claims = Object.freeze({ ...record(record(resource.meta).claims) });
155
+ if (resource.resourceType !== 'Organization' || !String(response.status || '').startsWith('2'))
156
+ return [];
157
+ const status = String(claims[CLAIM_STATUS] || '');
158
+ if (!['active', 'suspended', 'revoked'].includes(status))
159
+ throw new Error('host_directory_status_invalid');
160
+ return [Object.freeze({
161
+ id: String(resource.id || row.id || ''),
162
+ legalName: String(claims[CLAIM_LEGAL_NAME] || ''),
163
+ name: String(claims[CLAIM_NAME] || claims[CLAIM_LEGAL_NAME] || ''),
164
+ identifierType: String(claims[CLAIM_IDENTIFIER_TYPE] || ''),
165
+ identifierValue: String(claims[CLAIM_IDENTIFIER_VALUE] || ''),
166
+ jurisdiction: String(claims[CLAIM_JURISDICTION] || ''),
167
+ sector: String(claims[CLAIM_SECTOR] || ''),
168
+ status: status,
169
+ ...(optional(claims[CLAIM_SAME_AS]) ? { sameAs: optional(claims[CLAIM_SAME_AS]) } : {}),
170
+ claims,
171
+ })];
172
+ });
173
+ const pagination = record(record(bundle.meta).pagination);
174
+ return Object.freeze({
175
+ total: nonNegativeInteger(bundle.total, 'host_directory_total_invalid'),
176
+ pageSize: positiveInteger(pagination.pageSize, 'host_directory_page_size_invalid'),
177
+ returned: nonNegativeInteger(pagination.returned, 'host_directory_returned_invalid'),
178
+ hasNext: pagination.hasNext === true,
179
+ ...(optional(pagination.nextCursor) ? { nextCursor: optional(pagination.nextCursor) } : {}),
180
+ organizations: Object.freeze(organizations),
181
+ });
182
+ }
183
+ function readLifecycleResult(value) {
184
+ const root = record(value);
185
+ const bundle = record(root.body || root);
186
+ const data = Array.isArray(bundle.data) ? bundle.data : [];
187
+ const issue = data.flatMap((entry) => {
188
+ const outcome = record(record(record(entry).response).outcome);
189
+ return outcome.resourceType === 'OperationOutcome' && Array.isArray(outcome.issue) ? outcome.issue : [];
190
+ }).map(record).find(candidate => ['fatal', 'error'].includes(String(candidate.severity || '').toLowerCase()));
191
+ if (issue)
192
+ throw new Error(`host_lifecycle_failed:${String(issue.diagnostics || issue.code || 'operation_outcome')}`);
193
+ const entry = data.map(record).find(candidate => String(record(candidate.response).status || '').startsWith('2'));
194
+ if (!entry)
195
+ throw new Error('host_lifecycle_response_invalid');
196
+ const resource = record(entry.resource);
197
+ const claims = record(record(resource.meta).claims);
198
+ const status = String(claims[CLAIM_STATUS] || '');
199
+ if (!['active', 'suspended', 'revoked'].includes(status))
200
+ throw new Error('host_lifecycle_status_invalid');
201
+ return Object.freeze({
202
+ status: status,
203
+ ...(optional(claims['org.schema.Action.tenantAuthorization.lifecycleDisposition']) ? { disposition: optional(claims['org.schema.Action.tenantAuthorization.lifecycleDisposition']) } : {}),
204
+ ...(optional(resource.id) ? { organizationId: optional(resource.id) } : {}),
205
+ });
206
+ }
207
+ function requireSuccessfulResponse(response, code) {
208
+ if (response.status >= 200 && response.status < 300)
209
+ return;
210
+ throw new Error(`${code}:${response.status}`);
211
+ }
212
+ function normalizeBaseUrl(value) {
213
+ const url = new URL(value);
214
+ if (url.protocol !== 'https:' && url.hostname !== 'localhost' && url.hostname !== '127.0.0.1')
215
+ throw new Error('gateway_base_url_insecure');
216
+ return url.href.replace(/\/$/u, '');
217
+ }
218
+ function segment(value) {
219
+ const result = String(value ?? '').trim();
220
+ if (!result || result.includes('/') || result === '.' || result === '..')
221
+ throw new Error('route_context_invalid');
222
+ return result;
223
+ }
224
+ function record(value) {
225
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
226
+ }
227
+ function optional(value) {
228
+ const result = String(value ?? '').trim();
229
+ return result || undefined;
230
+ }
231
+ function required(value, code) {
232
+ const result = optional(value);
233
+ if (!result)
234
+ throw new Error(code);
235
+ return result;
236
+ }
237
+ function positiveInteger(value, code) {
238
+ const result = Number(value);
239
+ if (!Number.isInteger(result) || result < 1)
240
+ throw new Error(code);
241
+ return result;
242
+ }
243
+ function nonNegativeInteger(value, code) {
244
+ const result = Number(value);
245
+ if (!Number.isInteger(result) || result < 0)
246
+ throw new Error(code);
247
+ return result;
248
+ }
package/dist/index.d.ts CHANGED
@@ -8,3 +8,5 @@ export * from './encounter-transfer-audience-service.js';
8
8
  export * from './bff-email-assertion.js';
9
9
  export * from './subject-card-directory-client.js';
10
10
  export * from './server-passkey-profile.js';
11
+ export * from './host-organization-directory-client.js';
12
+ export * from './secure-organization-registration.js';
package/dist/index.js CHANGED
@@ -8,3 +8,5 @@ export * from './encounter-transfer-audience-service.js';
8
8
  export * from './bff-email-assertion.js';
9
9
  export * from './subject-card-directory-client.js';
10
10
  export * from './server-passkey-profile.js';
11
+ export * from './host-organization-directory-client.js';
12
+ export * from './secure-organization-registration.js';
@@ -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.12",
3
+ "version": "0.1.14",
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",
@@ -47,6 +47,14 @@
47
47
  "./subject-card-directory-client": {
48
48
  "types": "./dist/subject-card-directory-client.d.ts",
49
49
  "default": "./dist/subject-card-directory-client.js"
50
+ },
51
+ "./host-organization-directory-client": {
52
+ "types": "./dist/host-organization-directory-client.d.ts",
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"
50
58
  }
51
59
  },
52
60
  "files": [
@@ -66,6 +74,8 @@
66
74
  },
67
75
  "dependencies": {
68
76
  "@simplewebauthn/server": "13.3.2",
77
+ "gdc-common-utils-ts": "2.9.30",
78
+ "gdc-sdk-node-ts": "2.9.32",
69
79
  "sos-data-utils-ts": "0.6.13",
70
80
  "sos-sdk-core-ts": "0.5.2"
71
81
  },