sos-sdk-node-ts 0.1.6 → 0.1.8

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 ADDED
@@ -0,0 +1,80 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - Add the shared trusted-BFF authorized-subject directory client with canonical
6
+ SOS GW Organization/License routes, real submit/poll HTTP coverage,
7
+ independent `sameAs` alias matching and fail-closed animal-card DID
8
+ validation.
9
+ - Document multiple-controller ownership and future explicit principal
10
+ controller promotion without changing the child or animal subject identity.
11
+ - Add the 2026 development-owner notice and re-establish the public repository
12
+ from a clean source snapshot while
13
+ retaining this changelog as the package release record.
14
+
15
+ ## 0.1.7 - 2026-10-05
16
+
17
+ - Add a short-lived HMAC-protected email assertion for exact BFF-to-BFF
18
+ audiences and purposes. It normalizes the authenticated email and fails
19
+ closed for weak secrets, altered payloads, expiry and cross-audience reuse;
20
+ it is not a browser-selected mode or an authorization capability.
21
+
22
+ ## 0.1.6 - 2026-10-04
23
+
24
+ - Add one atomic accepted-handover persistence boundary for the
25
+ Encounter/EncounterHistory projection, future professional Group audience
26
+ revision and family-safe journey event.
27
+
28
+ ## 0.1.5 - 2026-10-04
29
+
30
+ - Add the product-neutral confidential channel cache shared by SOSChain,
31
+ VetChain and UHC Next.js BFFs. One profile-and-channel document stores
32
+ materialized FHIR Communication messages behind the unlocked profile's
33
+ confidential-storage adapter, skips already opened source JWEs and keeps
34
+ subject, channel, recipients and payload out of cleartext persistence.
35
+
36
+ ## 0.1.4 - 2026-10-04
37
+
38
+ - Add the injectable protected Bundle-sync service used by trusted runtimes for
39
+ clinical documents, Consent, emergency contacts and Encounter transfers.
40
+ It persists the exact retryable draft, enforces idempotency and confirms a
41
+ mutation only after authoritative provider readback.
42
+ - Pin `sos-sdk-core-ts@0.5.1`, which owns the portable synchronization state
43
+ and response reconciliation contract.
44
+
45
+ - Link the coordinated clinical accessor adoption boundary and keep optional
46
+ portal/assistant editor work out of the Node adapter until runtime behavior
47
+ is actually required.
48
+
49
+ ## 0.1.3 - 2026-09-27
50
+
51
+ - Serialize Node test files so package and publication gates cannot exhaust a
52
+ shared developer workstation with parallel workers.
53
+ - Require one version change only after grouped changes are merged and tested,
54
+ and preserve every exact published version on an immutable stable branch.
55
+
56
+ - Add the product-neutral immutable prompt-audio Node runtime with stable
57
+ SHA-256 object keys, durable filesystem storage, concurrent-miss coalescing,
58
+ sequential prewarming and an ElevenLabs multilingual-v2 adapter.
59
+ - Correct the package boundary: SOSChain, Vet and UHC may reuse this Node
60
+ runtime while keeping their sector policy in their own SDKs.
61
+ - Add a product-neutral, create-only GCS prompt store so identical reviewed
62
+ assets can be reused across Vet, UHC and Connect ICA without sharing live
63
+ OTPs, personalized speech or clinical data.
64
+
65
+ ## 0.1.2
66
+
67
+ - Add the Node CSPRNG adapter and factory for the shared multi-recipient ML-KEM
68
+ secure-message core used by written and binary-audio payloads.
69
+ - Keep Node limited to validated `node:crypto.randomBytes` output; ML-KEM, KDF,
70
+ CEK wrapping and AES content protection remain in runtime-neutral core.
71
+
72
+ ## 0.1.1
73
+
74
+ - Apply the blocking main-before-publish release contract to the SOSChain Node SDK documentation and repository skills.
75
+
76
+ ## 0.1.0
77
+
78
+ - Initialize the SOSChain-specific Node SDK above `sos-sdk-core-ts@0.2.0`.
79
+ - Add fail-closed authenticated emergency dispatch with HTTPS enforcement,
80
+ idempotency and acknowledgement validation.
package/NOTICE ADDED
@@ -0,0 +1,2 @@
1
+ sos-sdk-node-ts
2
+ Copyright 2026 Connecting Solution & Applications Ltd.
package/README.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # sos-sdk-node-ts
2
2
 
3
+ Copyright 2026 Connecting Solution & Applications Ltd.
4
+ Licensed under the Apache License, Version 2.0; see [`NOTICE`](NOTICE).
5
+
6
+ ## Public source history
7
+
8
+ The public repository begins from a clean 2026 source snapshot. The retained
9
+ [`CHANGELOG.md`](CHANGELOG.md) records package releases without republishing
10
+ the former Git push history.
11
+
3
12
  Accepted Encounter handover persistence and future channel-audience
4
13
  reconciliation are documented in
5
14
  [`docs/ENCOUNTER_TRANSFER_AUDIENCE_PERSISTENCE.md`](docs/ENCOUNTER_TRANSFER_AUDIENCE_PERSISTENCE.md).
@@ -13,7 +22,7 @@ deterministic failures and never commit a local package reference. Staging
13
22
  begins only after exact registry packages are installed and verified.
14
23
 
15
24
 
16
- Product-neutral Node adapters shared by SOSChain, Vet and UHC trusted runtimes.
25
+ Product-neutral Node adapters for trusted SOSCHAIN runtimes.
17
26
 
18
27
  ```text
19
28
  gdc-* frozen
@@ -27,8 +36,8 @@ sos-sdk-node-ts
27
36
 
28
37
  This package owns reusable server-side Node transport, credentials,
29
38
  persistence, jobs and prompt-audio runtime behavior. It does not own
30
- deterministic data contracts, common authorization or sector policy. Vet, UHC
31
- and SOSChain may consume it without depending on one another.
39
+ deterministic data contracts, common authorization or sector policy. Downstream
40
+ applications consume it without creating dependencies between one another.
32
41
 
33
42
  `BundleSyncDraftService` is the trusted-runtime adapter for the shared durable
34
43
  editor outbox. The application injects protected storage, payload protection
@@ -40,7 +49,7 @@ readback.
40
49
  It also does not duplicate the typed clinical Bundle editors exposed by the
41
50
  GDC Node SDK. Add a SOS Node adapter only when a real shared channel workflow
42
51
  requires runtime behavior, following the
43
- [SOS/Vet/UHC clinical accessor adoption matrix](https://github.com/Fundacion-UNID/sos-data-utils-ts/blob/main/docs/CLINICAL_TYPED_ACCESSOR_ADOPTION.md).
52
+ [clinical accessor adoption matrix](https://github.com/Fundacion-UNID/sos-data-utils-ts/blob/main/docs/CLINICAL_TYPED_ACCESSOR_ADOPTION.md).
44
53
  Portals and assistants without that editor remain on their verified versions.
45
54
 
46
55
  The first adapter, `SosEmergencyDispatchClient`, sends an already-authorized
@@ -48,6 +57,18 @@ emergency request over HTTPS with a stable idempotency key and validates the
48
57
  provider acknowledgement. The bearer credential remains only in the trusted
49
58
  Node runtime.
50
59
 
60
+ `sos-sdk-node-ts/bff-email-assertion` lets one authenticated BFF present a
61
+ normalized email to one exact receiving BFF for a short-lived, named purpose.
62
+ The receiving application still authorizes the operation and should reject
63
+ replay where it matters. Never expose the shared secret or let a browser set
64
+ the trusted issuer, audience, purpose or identity claims.
65
+
66
+ `sos-sdk-node-ts/subject-card-directory-client` owns the complete asynchronous
67
+ SOS GW Organization/License search and polling protocol used by every portal
68
+ BFF. It matches exact aliases inside `Organization.sameAs` CSV values and
69
+ rejects removed animal-card DID layouts before UI mapping. See
70
+ [the authorized subject directory contract](docs/AUTHORIZED_SUBJECT_DIRECTORY.md).
71
+
51
72
  ## Secure-message CSPRNG adapter
52
73
 
53
74
  `sos-sdk-node-ts/secure-message-crypto` exports `NodeSecureRandomSource` and
@@ -0,0 +1,33 @@
1
+ export type BffEmailAssertion = Readonly<{
2
+ issuer: string;
3
+ audience: string;
4
+ purpose: string;
5
+ email: string;
6
+ assertionId: string;
7
+ issuedAt: number;
8
+ expiresAt: number;
9
+ }>;
10
+ type CreateInput = Readonly<{
11
+ issuer: string;
12
+ audience: string;
13
+ purpose: string;
14
+ email: string;
15
+ secret: string;
16
+ now?: Date;
17
+ lifetimeSeconds?: number;
18
+ }>;
19
+ type VerifyInput = Readonly<{
20
+ issuer: string;
21
+ audience: string;
22
+ purpose: string;
23
+ secret: string;
24
+ now?: Date;
25
+ }>;
26
+ /**
27
+ * Issues a minimal, short-lived identity assertion from one trusted BFF to an
28
+ * exact receiving BFF. It is not a browser mode switch or an authorization
29
+ * capability; the receiver must still enforce the requested operation.
30
+ */
31
+ export declare function createBffEmailAssertion(input: CreateInput): string;
32
+ export declare function verifyBffEmailAssertion(assertion: string, input: VerifyInput): BffEmailAssertion;
33
+ export {};
@@ -0,0 +1,113 @@
1
+ import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
2
+ const MAXIMUM_LIFETIME_SECONDS = 5 * 60;
3
+ /**
4
+ * Issues a minimal, short-lived identity assertion from one trusted BFF to an
5
+ * exact receiving BFF. It is not a browser mode switch or an authorization
6
+ * capability; the receiver must still enforce the requested operation.
7
+ */
8
+ export function createBffEmailAssertion(input) {
9
+ const secret = validatedSecret(input.secret);
10
+ const issuedAt = epochSeconds(input.now ?? new Date());
11
+ const lifetimeSeconds = input.lifetimeSeconds ?? 120;
12
+ if (!Number.isInteger(lifetimeSeconds) || lifetimeSeconds < 1 || lifetimeSeconds > MAXIMUM_LIFETIME_SECONDS) {
13
+ throw new Error('bff_assertion_lifetime_invalid');
14
+ }
15
+ const payload = Object.freeze({
16
+ issuer: absoluteUrl(input.issuer, 'bff_assertion_issuer_invalid'),
17
+ audience: absoluteUrl(input.audience, 'bff_assertion_audience_invalid'),
18
+ purpose: required(input.purpose, 'bff_assertion_purpose_invalid'),
19
+ email: normalizedEmail(input.email),
20
+ assertionId: randomUUID(),
21
+ issuedAt,
22
+ expiresAt: issuedAt + lifetimeSeconds,
23
+ });
24
+ const encoded = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
25
+ return `${encoded}.${signature(encoded, secret)}`;
26
+ }
27
+ export function verifyBffEmailAssertion(assertion, input) {
28
+ const secret = validatedSecret(input.secret);
29
+ const [encoded, receivedSignature, extra] = assertion.split('.');
30
+ if (!encoded || !receivedSignature || extra)
31
+ throw new Error('bff_assertion_format_invalid');
32
+ const expectedSignature = signature(encoded, secret);
33
+ const received = Buffer.from(receivedSignature, 'base64url');
34
+ const expected = Buffer.from(expectedSignature, 'base64url');
35
+ if (received.length !== expected.length || !timingSafeEqual(received, expected))
36
+ throw new Error('bff_assertion_signature_invalid');
37
+ const payload = parsePayload(encoded);
38
+ if (payload.issuer !== absoluteUrl(input.issuer, 'bff_assertion_issuer_invalid'))
39
+ throw new Error('bff_assertion_issuer_mismatch');
40
+ if (payload.audience !== absoluteUrl(input.audience, 'bff_assertion_audience_invalid'))
41
+ throw new Error('bff_assertion_audience_mismatch');
42
+ if (payload.purpose !== required(input.purpose, 'bff_assertion_purpose_invalid'))
43
+ throw new Error('bff_assertion_purpose_mismatch');
44
+ const now = epochSeconds(input.now ?? new Date());
45
+ if (payload.expiresAt <= now)
46
+ throw new Error('bff_assertion_expired');
47
+ if (payload.issuedAt > now || payload.expiresAt - payload.issuedAt > MAXIMUM_LIFETIME_SECONDS)
48
+ throw new Error('bff_assertion_time_invalid');
49
+ return payload;
50
+ }
51
+ function parsePayload(encoded) {
52
+ try {
53
+ const value = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
54
+ if (!value || typeof value !== 'object'
55
+ || typeof value.issuer !== 'string' || typeof value.audience !== 'string'
56
+ || typeof value.purpose !== 'string' || typeof value.email !== 'string'
57
+ || typeof value.assertionId !== 'string' || typeof value.issuedAt !== 'number'
58
+ || typeof value.expiresAt !== 'number')
59
+ throw new Error('invalid');
60
+ return Object.freeze({
61
+ issuer: absoluteUrl(value.issuer, 'bff_assertion_payload_invalid'),
62
+ audience: absoluteUrl(value.audience, 'bff_assertion_payload_invalid'),
63
+ purpose: required(value.purpose, 'bff_assertion_payload_invalid'),
64
+ email: normalizedEmail(value.email),
65
+ assertionId: required(value.assertionId, 'bff_assertion_payload_invalid'),
66
+ issuedAt: value.issuedAt,
67
+ expiresAt: value.expiresAt,
68
+ });
69
+ }
70
+ catch (reason) {
71
+ if (reason instanceof Error && reason.message.startsWith('bff_assertion_'))
72
+ throw reason;
73
+ throw new Error('bff_assertion_payload_invalid');
74
+ }
75
+ }
76
+ function signature(encoded, secret) {
77
+ return createHmac('sha256', secret).update(encoded).digest('base64url');
78
+ }
79
+ function validatedSecret(value) {
80
+ const secret = Buffer.from(value, 'utf8');
81
+ if (secret.length < 32)
82
+ throw new Error('bff_assertion_secret_invalid');
83
+ return secret;
84
+ }
85
+ function normalizedEmail(value) {
86
+ const email = value.trim().toLowerCase();
87
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
88
+ throw new Error('bff_assertion_email_invalid');
89
+ return email;
90
+ }
91
+ function required(value, issue) {
92
+ const normalized = value.trim();
93
+ if (!normalized)
94
+ throw new Error(issue);
95
+ return normalized;
96
+ }
97
+ function absoluteUrl(value, issue) {
98
+ try {
99
+ const url = new URL(value);
100
+ if (url.protocol !== 'https:' && url.hostname !== 'localhost')
101
+ throw new Error(issue);
102
+ return url.href.replace(/\/$/, '');
103
+ }
104
+ catch {
105
+ throw new Error(issue);
106
+ }
107
+ }
108
+ function epochSeconds(value) {
109
+ const milliseconds = value.getTime();
110
+ if (!Number.isFinite(milliseconds))
111
+ throw new Error('bff_assertion_time_invalid');
112
+ return Math.floor(milliseconds / 1_000);
113
+ }
package/dist/index.d.ts CHANGED
@@ -5,3 +5,5 @@ export * from './secure-message-crypto.js';
5
5
  export * from './confidential-channel-cache.js';
6
6
  export * from './bundle-sync-service.js';
7
7
  export * from './encounter-transfer-audience-service.js';
8
+ export * from './bff-email-assertion.js';
9
+ export * from './subject-card-directory-client.js';
package/dist/index.js CHANGED
@@ -5,3 +5,5 @@ export * from './secure-message-crypto.js';
5
5
  export * from './confidential-channel-cache.js';
6
6
  export * from './bundle-sync-service.js';
7
7
  export * from './encounter-transfer-audience-service.js';
8
+ export * from './bff-email-assertion.js';
9
+ export * from './subject-card-directory-client.js';
@@ -0,0 +1,51 @@
1
+ export type SosGatewayRouteContext = Readonly<{
2
+ tenantId: string;
3
+ jurisdiction: string;
4
+ sector: string;
5
+ }>;
6
+ export type AuthorizedSubjectVerifiedContact = Readonly<{
7
+ email?: string;
8
+ telephone?: string;
9
+ }>;
10
+ export type AuthorizedIndividualSubjectDirectoryInput = Readonly<{
11
+ verifiedContact: AuthorizedSubjectVerifiedContact;
12
+ requestThid?: string;
13
+ pollOptions?: Readonly<{
14
+ timeoutMs?: number;
15
+ intervalMs?: number;
16
+ }>;
17
+ }>;
18
+ export type AuthorizedIndividualSubject = Readonly<{
19
+ subjectDid: string;
20
+ organizationId?: string;
21
+ role?: string;
22
+ grantClaims: Readonly<Record<string, unknown>>;
23
+ authorizationEvidenceId?: string;
24
+ relatedPersonId?: string;
25
+ issuerDid?: string;
26
+ subjectClaims: Readonly<Record<string, unknown>>;
27
+ }>;
28
+ export type SosGatewaySubjectDirectoryClientOptions = Readonly<{
29
+ baseUrl: string;
30
+ bearerToken?: string;
31
+ appId?: string;
32
+ appVersion?: string;
33
+ defaultHeaders?: Readonly<Record<string, string>>;
34
+ requestTimeoutMs?: number;
35
+ fetchImpl?: typeof fetch;
36
+ }>;
37
+ /**
38
+ * Shared trusted-BFF client for the canonical asynchronous SOS GW subject
39
+ * directory. The verified contact is discovery evidence only; results never
40
+ * grant a protected clinical or emergency action.
41
+ */
42
+ export declare class SosGatewaySubjectDirectoryClient {
43
+ private readonly baseUrl;
44
+ private readonly headers;
45
+ private readonly requestTimeoutMs;
46
+ private readonly fetchImpl;
47
+ constructor(options: SosGatewaySubjectDirectoryClientOptions);
48
+ listAuthorizedIndividualSubjects(routeContext: SosGatewayRouteContext, input: AuthorizedIndividualSubjectDirectoryInput): Promise<AuthorizedIndividualSubject[]>;
49
+ private submitAndPoll;
50
+ private post;
51
+ }
@@ -0,0 +1,288 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { parseAnimalCardDidWeb } from 'sos-data-utils-ts/animal-card-interoperability';
3
+ import { buildGatewayApplicationHeaders } from 'sos-data-utils-ts/gateway-application-identity';
4
+ const CLAIM_AUDIENCE_TYPE = 'org.schema.IndividualProduct.audience.audienceType';
5
+ const CLAIM_ITEM_CONDITION = 'org.schema.IndividualProduct.itemCondition';
6
+ const CLAIM_PERSON_EMAIL = 'org.schema.Person.email';
7
+ const CLAIM_PERSON_TELEPHONE = 'org.schema.Person.telephone';
8
+ const CLAIM_ORGANIZATION_SAME_AS = 'org.schema.Organization.sameAs';
9
+ const CLAIM_ORGANIZATION_OWNER_EMAIL = 'org.schema.Organization.owner.email';
10
+ const CLAIM_ORGANIZATION_OWNER_TELEPHONE = 'org.schema.Organization.owner.telephone';
11
+ const CLAIM_ORGANIZATION_OWNER_IDENTIFIER = 'org.schema.Organization.owner.identifier.value';
12
+ const CLAIM_ORGANIZATION_MEMBER_ROLE = 'org.schema.Organization.member.role';
13
+ const CLAIM_RELATED_PERSON_ROLE = 'RelatedPerson.role';
14
+ const CLAIM_OCCUPATION_IDENTIFIER = 'org.schema.Person.hasOccupation.identifier.value';
15
+ /**
16
+ * Shared trusted-BFF client for the canonical asynchronous SOS GW subject
17
+ * directory. The verified contact is discovery evidence only; results never
18
+ * grant a protected clinical or emergency action.
19
+ */
20
+ export class SosGatewaySubjectDirectoryClient {
21
+ baseUrl;
22
+ headers;
23
+ requestTimeoutMs;
24
+ fetchImpl;
25
+ constructor(options) {
26
+ this.baseUrl = normalizeBaseUrl(options.baseUrl);
27
+ this.requestTimeoutMs = positiveInteger(options.requestTimeoutMs ?? 15_000, 'request_timeout_invalid');
28
+ this.fetchImpl = options.fetchImpl ?? fetch;
29
+ this.headers = Object.freeze({
30
+ 'Content-Type': 'application/json',
31
+ Accept: 'application/json',
32
+ ...(requiredOptional(options.bearerToken) ? { Authorization: `Bearer ${requiredOptional(options.bearerToken)}` } : {}),
33
+ ...(requiredOptional(options.appId)
34
+ ? buildGatewayApplicationHeaders({
35
+ appId: requiredOptional(options.appId),
36
+ appVersion: options.appVersion,
37
+ })
38
+ : {}),
39
+ ...(options.defaultHeaders ?? {}),
40
+ });
41
+ }
42
+ async listAuthorizedIndividualSubjects(routeContext, input) {
43
+ const context = normalizeRouteContext(routeContext);
44
+ const email = String(input.verifiedContact?.email || '').trim().toLowerCase();
45
+ const telephone = String(input.verifiedContact?.telephone || '').trim();
46
+ if (!email && !telephone)
47
+ throw new Error('verified_contact_required');
48
+ const organizationPath = gatewayPath(context, 'Organization', '_search');
49
+ const organizationPollPath = gatewayPath(context, 'Organization', '_search-response');
50
+ const ownerResult = await this.submitAndPoll(organizationPath, organizationPollPath, {
51
+ thid: input.requestThid || threadId('authorized-subject-owner'),
52
+ body: searchBundle('Family-search-v1.0', {
53
+ '@context': 'org.schema',
54
+ ...(email ? { [CLAIM_ORGANIZATION_OWNER_EMAIL]: email } : {}),
55
+ ...(telephone ? { [CLAIM_ORGANIZATION_OWNER_TELEPHONE]: telephone } : {}),
56
+ }),
57
+ }, input.pollOptions);
58
+ const ownedSubjects = readOwnedSubjects(ownerResult.body, context, { email, telephone });
59
+ const subjectsByDid = new Map(ownedSubjects.map((subject) => [subject.subjectDid, subject]));
60
+ const licensePath = gatewayPath(context, 'License', '_search');
61
+ const licensePollPath = gatewayPath(context, 'License', '_search-response');
62
+ const licenseResult = await this.submitAndPoll(licensePath, licensePollPath, {
63
+ thid: input.requestThid || threadId('authorized-subject-license'),
64
+ body: {
65
+ resourceType: 'Bundle',
66
+ type: 'batch',
67
+ data: [{
68
+ request: { method: 'POST' },
69
+ resource: { meta: { status: 'active', claims: {
70
+ '@context': 'org.schema',
71
+ [CLAIM_AUDIENCE_TYPE]: 'Individual',
72
+ [CLAIM_ITEM_CONDITION]: 'https://schema.org/UsedCondition',
73
+ ...(email ? { [CLAIM_PERSON_EMAIL]: email } : {}),
74
+ ...(telephone ? { [CLAIM_PERSON_TELEPHONE]: telephone } : {}),
75
+ } } },
76
+ }],
77
+ },
78
+ }, input.pollOptions);
79
+ const licenses = readAcceptedLicenses(licenseResult.body, context);
80
+ for (const license of licenses) {
81
+ if (subjectsByDid.has(license.subjectDid))
82
+ continue;
83
+ const organizationResult = await this.submitAndPoll(organizationPath, organizationPollPath, {
84
+ thid: threadId('authorized-subject-record'),
85
+ body: searchBundle('Family-search-v1.0', {
86
+ '@context': 'org.schema',
87
+ [CLAIM_ORGANIZATION_SAME_AS]: license.subjectDid,
88
+ }),
89
+ }, input.pollOptions);
90
+ const organization = readFirstOrganization(organizationResult.body);
91
+ if (!publicAliases(organization.claims[CLAIM_ORGANIZATION_SAME_AS]).includes(license.subjectDid))
92
+ continue;
93
+ subjectsByDid.set(license.subjectDid, Object.freeze({
94
+ subjectDid: license.subjectDid,
95
+ ...(organization.organizationId ? { organizationId: organization.organizationId } : {}),
96
+ ...(license.role ? { role: license.role } : {}),
97
+ grantClaims: license.grantClaims,
98
+ ...(license.authorizationEvidenceId ? { authorizationEvidenceId: license.authorizationEvidenceId } : {}),
99
+ ...(license.relatedPersonId ? { relatedPersonId: license.relatedPersonId } : {}),
100
+ ...(license.issuerDid ? { issuerDid: license.issuerDid } : {}),
101
+ subjectClaims: organization.claims,
102
+ }));
103
+ }
104
+ return [...subjectsByDid.values()];
105
+ }
106
+ async submitAndPoll(submitPath, pollPath, payload, options) {
107
+ const submit = await this.post(submitPath, payload);
108
+ requireSuccessfulResponse(submit, 'gateway_submit_failed');
109
+ const timeoutMs = positiveInteger(options?.timeoutMs ?? 30_000, 'poll_timeout_invalid');
110
+ const intervalMs = positiveInteger(options?.intervalMs ?? 250, 'poll_interval_invalid');
111
+ const started = Date.now();
112
+ for (;;) {
113
+ const poll = await this.post(pollPath, { thid: payload.thid });
114
+ if (poll.status !== 202) {
115
+ requireSuccessfulResponse(poll, 'gateway_poll_failed');
116
+ return poll;
117
+ }
118
+ if (Date.now() - started >= timeoutMs)
119
+ throw new Error('gateway_poll_timeout');
120
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
121
+ }
122
+ }
123
+ async post(path, payload) {
124
+ const controller = new AbortController();
125
+ const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs);
126
+ try {
127
+ const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
128
+ method: 'POST',
129
+ headers: this.headers,
130
+ body: JSON.stringify(payload),
131
+ signal: controller.signal,
132
+ });
133
+ const raw = await response.text();
134
+ let body = {};
135
+ if (raw) {
136
+ try {
137
+ body = JSON.parse(raw);
138
+ }
139
+ catch {
140
+ body = raw;
141
+ }
142
+ }
143
+ return { status: response.status, body };
144
+ }
145
+ finally {
146
+ clearTimeout(timeout);
147
+ }
148
+ }
149
+ }
150
+ function searchBundle(type, claims) {
151
+ return {
152
+ resourceType: 'Bundle',
153
+ type: 'batch',
154
+ data: [{ type, request: { method: 'POST' }, resource: { meta: { claims } } }],
155
+ };
156
+ }
157
+ function readOwnedSubjects(body, context, verified) {
158
+ const resources = readData(body).flatMap((entry) => entry?.resource?.resourceType === 'Bundle' && Array.isArray(entry.resource.entry)
159
+ ? entry.resource.entry.map((item) => item?.resource).filter(Boolean)
160
+ : []);
161
+ return resources.flatMap((resource) => {
162
+ const claims = objectRecord(resource?.meta?.claims);
163
+ const ownerEmail = String(claims[CLAIM_ORGANIZATION_OWNER_EMAIL] || '').trim().toLowerCase();
164
+ const ownerTelephone = String(claims[CLAIM_ORGANIZATION_OWNER_TELEPHONE] || '').trim();
165
+ if (!((verified.email && ownerEmail === verified.email) || (verified.telephone && ownerTelephone === verified.telephone)))
166
+ return [];
167
+ const subjectDid = selectSubjectCardAlias(claims[CLAIM_ORGANIZATION_SAME_AS], context);
168
+ if (!subjectDid)
169
+ return [];
170
+ const role = context.sector === 'animal-care'
171
+ ? 'RESPRSN'
172
+ : String(claims[CLAIM_ORGANIZATION_MEMBER_ROLE] || '').trim();
173
+ const relatedPersonId = String(claims[CLAIM_ORGANIZATION_OWNER_IDENTIFIER] || '').trim();
174
+ return [Object.freeze({
175
+ subjectDid,
176
+ ...(String(resource?.id || '').trim() ? { organizationId: String(resource.id).trim() } : {}),
177
+ ...(role ? { role } : {}),
178
+ ...(relatedPersonId ? { relatedPersonId } : {}),
179
+ grantClaims: Object.freeze({}),
180
+ subjectClaims: claims,
181
+ })];
182
+ });
183
+ }
184
+ function readAcceptedLicenses(body, context) {
185
+ const rows = readData(body)[0]?.resource?.data;
186
+ if (!Array.isArray(rows))
187
+ return [];
188
+ return rows.flatMap((row) => {
189
+ const meta = objectRecord(row?.meta || row?.resource?.meta);
190
+ const subjectDid = String(meta.authorizedSubjectDid || '').trim();
191
+ if (!subjectDid)
192
+ return [];
193
+ requireCanonicalSubjectDid(subjectDid, context);
194
+ const claims = objectRecord(meta.claims);
195
+ const relatedRoles = String(claims[CLAIM_RELATED_PERSON_ROLE] || '')
196
+ .split(',').map((value) => value.trim().toUpperCase()).filter(Boolean);
197
+ const occupation = String(claims[CLAIM_OCCUPATION_IDENTIFIER] || '').trim();
198
+ const role = relatedRoles.includes('CAREGIVER') ? 'CAREGIVER' : relatedRoles[0] || occupation;
199
+ return [Object.freeze({
200
+ subjectDid,
201
+ ...(role ? { role } : {}),
202
+ grantClaims: claims,
203
+ ...(requiredOptional(meta.identifier) ? { authorizationEvidenceId: requiredOptional(meta.identifier) } : {}),
204
+ ...(requiredOptional(meta.relatedPersonId) ? { relatedPersonId: requiredOptional(meta.relatedPersonId) } : {}),
205
+ ...(requiredOptional(meta.issuerDid) ? { issuerDid: requiredOptional(meta.issuerDid) } : {}),
206
+ })];
207
+ });
208
+ }
209
+ function readFirstOrganization(body) {
210
+ const entry = readData(body)[0];
211
+ const claims = objectRecord(entry?.resource?.meta?.claims || entry?.meta?.claims);
212
+ const organizationId = String(entry?.resource?.id || entry?.id || '').trim();
213
+ return { claims, ...(organizationId ? { organizationId } : {}) };
214
+ }
215
+ function readData(body) {
216
+ const root = objectRecord(body);
217
+ const payload = objectRecord(root.body || root);
218
+ return Array.isArray(payload.data) ? payload.data : [];
219
+ }
220
+ function selectSubjectCardAlias(value, context) {
221
+ const aliases = publicAliases(value);
222
+ const candidate = aliases.find((alias) => context.sector === 'animal-care'
223
+ ? alias.includes(':card:vetchain:')
224
+ : alias.includes(':card:uhc:'));
225
+ if (!candidate)
226
+ return undefined;
227
+ requireCanonicalSubjectDid(candidate, context);
228
+ return candidate;
229
+ }
230
+ function requireCanonicalSubjectDid(subjectDid, context) {
231
+ if (context.sector === 'animal-care' && !parseAnimalCardDidWeb(subjectDid)) {
232
+ throw new Error('animal_card_did_invalid');
233
+ }
234
+ if (context.sector !== 'animal-care' && !/^did:web:[^\s]+:card:uhc:(?:personal|professional):[^\s]+$/i.test(subjectDid)) {
235
+ throw new Error('human_card_did_invalid');
236
+ }
237
+ }
238
+ function publicAliases(value) {
239
+ const values = Array.isArray(value) ? value : String(value ?? '').split(',');
240
+ return [...new Set(values.map((alias) => String(alias).trim()).filter(Boolean))];
241
+ }
242
+ function gatewayPath(context, resource, action) {
243
+ return `/${context.tenantId}/cds-${context.jurisdiction}/v1/${context.sector}/individual/org.schema/${resource}/${action}`;
244
+ }
245
+ function normalizeRouteContext(value) {
246
+ const segment = (input) => {
247
+ const normalized = String(input ?? '').trim();
248
+ if (!normalized || normalized.includes('/') || normalized === '.' || normalized === '..')
249
+ throw new Error('gateway_route_context_invalid');
250
+ return normalized;
251
+ };
252
+ return Object.freeze({
253
+ tenantId: segment(value.tenantId),
254
+ jurisdiction: segment(value.jurisdiction),
255
+ sector: segment(value.sector),
256
+ });
257
+ }
258
+ function normalizeBaseUrl(value) {
259
+ const url = new URL(value);
260
+ if (url.protocol !== 'https:' && url.hostname !== 'localhost' && url.hostname !== '127.0.0.1') {
261
+ throw new Error('gateway_base_url_insecure');
262
+ }
263
+ return url.href.replace(/\/$/, '');
264
+ }
265
+ function requireSuccessfulResponse(response, code) {
266
+ if (response.status >= 200 && response.status < 300)
267
+ return;
268
+ const issue = objectRecord(response.body);
269
+ const diagnostics = Array.isArray(issue.issue)
270
+ ? issue.issue.map((entry) => String(objectRecord(entry).diagnostics || '').trim()).find(Boolean)
271
+ : undefined;
272
+ throw new Error(`${code}:${response.status}${diagnostics ? `:${diagnostics}` : ''}`);
273
+ }
274
+ function objectRecord(value) {
275
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
276
+ }
277
+ function requiredOptional(value) {
278
+ const normalized = String(value ?? '').trim();
279
+ return normalized || undefined;
280
+ }
281
+ function positiveInteger(value, code) {
282
+ if (!Number.isInteger(value) || value < 1)
283
+ throw new Error(code);
284
+ return value;
285
+ }
286
+ function threadId(prefix) {
287
+ return `${prefix}-${randomUUID()}`;
288
+ }
@@ -0,0 +1,48 @@
1
+ # Authorized subject directory
2
+
3
+ `SosGatewaySubjectDirectoryClient` is the single Node/BFF adapter for listing
4
+ human and animal cards already associated with a verified contact. Portals do
5
+ not build gateway paths, search Bundles or polling loops themselves.
6
+
7
+ The client submits and polls these SOS GW routes:
8
+
9
+ - `individual/org.schema/Organization/_search` and `_search-response` for
10
+ owner/contact lookup and exact `sameAs` resolution;
11
+ - `individual/org.schema/License/_search` and `_search-response` for active,
12
+ accepted relationship evidence.
13
+
14
+ `Organization.sameAs` remains the comma-separated flat claim used by the
15
+ confidential resource. SOS GW indexes every normalized non-empty alias as its
16
+ own protected indexed attribute. Exact resolution therefore searches one
17
+ alias; a returned CSV is accepted only when it contains that exact alias.
18
+ Animal entries accept only the current species-bound card profile, in which a
19
+ seven-digit taxonomy context precedes the complete 21-digit card number. The
20
+ removed split `jurisdiction5:animalId16` form fails closed. The exact profile
21
+ literal remains owned and documented by the gateway that issues the card.
22
+
23
+ Discovery is not authorization. A signed OpenID token is verified by the BFF;
24
+ the SDK receives only the verified contact and uses the token as server-side
25
+ transport evidence. Selecting a returned card creates no relationship and
26
+ grants no clinical, communication or emergency access.
27
+
28
+ ## Multiple controllers and future rotation
29
+
30
+ One child or animal may have multiple active controllers, such as two parents,
31
+ two legal guardians or two responsible keepers. Each controller must have a
32
+ separate governed `RelatedPerson` relationship and accepted licence/evidence.
33
+ The subject card remains in the subject organization's
34
+ `org.schema.Organization.sameAs`; it must not become
35
+ `org.schema.Person.sameAs` of either controller.
36
+
37
+ A later lifecycle contract may designate one active relationship as the
38
+ principal administrative controller. Revoking that controller must revoke only
39
+ that person's relationship, licence and controller keys, then explicitly
40
+ promote another eligible active relationship. It must not rotate the child or
41
+ animal card DID, replace the subject organization, or infer succession from
42
+ CSV ordering. This principal-controller promotion flow is documented here but
43
+ is not implemented by the directory client.
44
+
45
+ Employees are a different subject. Their own professional aliases belong in
46
+ `org.schema.Person.sameAs`; SOS GW normalizes that CSV, indexes every alias
47
+ independently and projects the same aliases into the employee DID Document's
48
+ `alsoKnownAs` alongside its organization-employee authorization URN.
@@ -23,10 +23,10 @@ developer machine.
23
23
  `FileSystemPromptAudioStore` supports local and demo execution. Production
24
24
  applications use `GcsPromptAudioStore` or implement `PromptAudioObjectStore`
25
25
  over another approved object store without changing the cache lifecycle.
26
- One GCS bucket may be shared by Vet, UHC and Connect ICA: the default object
27
- name has no product prefix and reuse occurs only when the complete canonical
28
- descriptor matches. Deployments may configure an isolation prefix when policy
29
- requires separate storage. `ElevenLabsPromptAudioSynthesizer`
26
+ One object bucket may be shared by compatible trusted runtimes: the default
27
+ object name has no product prefix and reuse occurs only when the complete
28
+ canonical descriptor matches. Deployments may configure an isolation prefix
29
+ when policy requires separate storage. `ElevenLabsPromptAudioSynthesizer`
30
30
  defaults to `eleven_multilingual_v2`; API keys stay in the trusted runtime and
31
31
  are neither logged nor included in an error.
32
32
 
@@ -6,17 +6,13 @@ flowchart TB
6
6
  DATA --> CORE["sos-sdk-core-ts<br/>runtime-neutral authorization"]
7
7
  DATA --> NODE["sos-sdk-node-ts<br/>product-neutral Node adapters"]
8
8
  CORE --> NODE
9
- NODE --> RUNTIME["SOSChain · Vet · UHC trusted Node runtimes"]
10
- CORE --> VET["vet-sdk-core-ts"]
11
- CORE --> UHC["uhc-sdk-core-ts"]
12
- DATA --> VET
13
- DATA --> UHC
9
+ NODE --> RUNTIME["SOSCHAIN trusted Node runtimes"]
14
10
  ```
15
11
 
16
12
  `sos-sdk-node-ts` is the common Node-runtime layer; it is not the SOSChain
17
13
  product SDK. Product-neutral storage, transport, credential, job and audio
18
- adapters belong here. Sector policy remains in the corresponding Vet, UHC or
19
- SOSChain package, and no shared package may depend back on a product SDK.
14
+ adapters belong here. Sector policy remains in downstream applications, and no
15
+ shared package may depend back on a product SDK.
20
16
 
21
17
  For immutable prompt audio, `sos-data-utils-ts/prompt-audio` produces the
22
18
  canonical descriptor and localized numeric-code fragments. This package
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sos-sdk-node-ts",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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",
@@ -39,11 +39,21 @@
39
39
  "./confidential-channel-cache": {
40
40
  "types": "./dist/confidential-channel-cache.d.ts",
41
41
  "default": "./dist/confidential-channel-cache.js"
42
+ },
43
+ "./bff-email-assertion": {
44
+ "types": "./dist/bff-email-assertion.d.ts",
45
+ "default": "./dist/bff-email-assertion.js"
46
+ },
47
+ "./subject-card-directory-client": {
48
+ "types": "./dist/subject-card-directory-client.d.ts",
49
+ "default": "./dist/subject-card-directory-client.js"
42
50
  }
43
51
  },
44
52
  "files": [
45
53
  "dist",
46
54
  "README.md",
55
+ "CHANGELOG.md",
56
+ "NOTICE",
47
57
  "docs"
48
58
  ],
49
59
  "scripts": {
@@ -55,6 +65,7 @@
55
65
  "prepack": "npm run check"
56
66
  },
57
67
  "dependencies": {
68
+ "sos-data-utils-ts": "0.6.13",
58
69
  "sos-sdk-core-ts": "0.5.2"
59
70
  },
60
71
  "devDependencies": {