sos-sdk-node-ts 0.1.12 → 0.1.13

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,6 +1,13 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.1.13 - 2026-10-21
4
+
5
+ - Add the trusted-BFF host organization directory client. It submits and polls
6
+ the canonical host `org.schema/Organization/_search` route, reads a
7
+ DIDComm-wrapped paginated Bundle, preserves opaque cursors and surfaces
8
+ Bundle `OperationOutcome` failures without accepting browser-selected host
9
+ authority. The same client performs status, suspend, reactivate and purge
10
+ operations and reads their authoritative Bundle results.
4
11
 
5
12
  ## 0.1.12 - 2026-10-21
6
13
 
package/README.md CHANGED
@@ -69,6 +69,13 @@ BFF. It matches exact aliases inside `Organization.sameAs` CSV values and
69
69
  rejects removed animal-card DID layouts before UI mapping. See
70
70
  [the authorized subject directory contract](docs/AUTHORIZED_SUBJECT_DIRECTORY.md).
71
71
 
72
+ `sos-sdk-node-ts/host-organization-directory-client` is the separate trusted-
73
+ BFF client for a host operator's organization directory. It sends the
74
+ canonical `org.schema/Organization/_search` request, polls the matching
75
+ response route and returns the privacy-minimized, paginated Bundle projection.
76
+ The host jurisdiction and network come from trusted server configuration;
77
+ they are never browser-selected authority.
78
+
72
79
  ## Secure-message CSPRNG adapter
73
80
 
74
81
  `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,4 @@ 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';
package/dist/index.js CHANGED
@@ -8,3 +8,4 @@ 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';
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.13",
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,10 @@
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"
50
54
  }
51
55
  },
52
56
  "files": [