sos-sdk-core-ts 0.2.0 → 0.3.0

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,41 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.3.0 - 2026-09-26
6
+
7
+ - Pin `sos-data-utils-ts@0.4.1`, which owns the Organization fallback target
8
+ and governed public-risk campaign criteria used by this release.
9
+ - Add idempotent fulfilled-Appointment to Encounter orchestration with
10
+ Appointment, subject, service provider and current Location continuity.
11
+ - Add fail-closed authorization for encounter-related voice, video and secure
12
+ messages. Calls never carry clinical Bundles, and preliminary Bundles may be
13
+ delivered only without updating the subject index.
14
+ - Create independent compact-coded emergency ServiceRequest and triage Task
15
+ entries targeting a HealthcareService or its owning Organization.
16
+ - Add frozen-cohort CommunicationRequest orchestration and per-recipient
17
+ delivery checks for blood-donation and governed FHIR SearchParameter
18
+ public-risk campaigns.
19
+ - Replace copied emergency Group members and split coding claims with the
20
+ canonical Group-reference and compact-code contract.
21
+ - Document the immutable main-tarball local Vet proof required before npm
22
+ publication and staging.
23
+
24
+ ## 0.2.2 - 2026-09-25
25
+
26
+ - Add runtime-neutral ML-KEM-768 multi-recipient encryption for written and
27
+ binary-audio messages with one SHA3-256-derived AES-256-GCM content key and
28
+ one independently protected CEK entry per recipient.
29
+ - Preserve the historical deterministic 32-byte message-seed derivation
30
+ contract and add recipient updates that rewrap the CEK without changing the
31
+ stored ciphertext, IV or authentication tag.
32
+ - Fail closed for duplicate or unknown recipients, malformed envelopes,
33
+ invalid random-source output and authenticated-ciphertext tampering.
34
+
35
+ ## 0.2.1
36
+
37
+ - Link the shared runtime layer to the blocking local-first release order: complete local proof, merge validated branches to main, publish bottom-up, pin exact registry artifacts, then stage.
38
+
3
39
  ## 0.2.0
4
40
 
5
41
  - Redefine `sos-sdk-core-ts` as the runtime-neutral orchestration layer consumed
package/README.md CHANGED
@@ -1,4 +1,16 @@
1
1
  # Shared SOS SDK Core
2
+ ## Blocking local-first SDK release
3
+
4
+ The canonical contract is [`SDK_LAYERING.md`](https://github.com/Fundacion-UNID/sos-data-utils-ts/blob/main/docs/SDK_LAYERING.md).
5
+ Merge and push validated shared dependency sources to `main`, create immutable
6
+ unpublished tarballs from those main commits with `npm pack`, and install them
7
+ temporarily with `--no-save`. Prove the real local Vet channel (portal, phone
8
+ service, chatbot or other supported channel) -> BFF or channel service ->
9
+ high-level SDK -> GW VET journey without skips. Then merge the consumer source
10
+ to `main`, publish the exact commits bottom-up, pin exact registry versions and
11
+ lockfiles, repeat affected local gates, and only then promote to staging.
12
+ Never commit a tarball or `file:`, Git or workspace dependency.
13
+
2
14
 
3
15
  Runtime-neutral shared SDK for SOSChain, VetChain and UHC. Deterministic
4
16
  cross-product record types and builders come from `sos-data-utils-ts`; this SDK
@@ -26,13 +38,11 @@ jobs; those adapters do not belong here.
26
38
  Git, workspace, vendored and `file:` dependencies are forbidden in committed
27
39
  or deployed dependency state.
28
40
 
29
- The emergency-group contract treats `emergencyGroupId`, `subjectId` and
30
- `callerId` as opaque business identifiers. A group assistance request is one
31
- flat `ServiceRequest.*` claims under `resource.meta.claims`. A FHIR R5, R6 or
32
- other resource is projected only at an integration boundary.
33
-
34
- Groups are named collections. A scanned card is attached to one selected group
35
- before that group's identifiers are projected into a single service request.
41
+ The emergency-group contract stores subject references only on an enumerated
42
+ FHIR Group. A group assistance request references that Group from one standard,
43
+ compact-coded ServiceRequest; it never copies members or persists split coding
44
+ claims. Flat claims remain the canonical storage representation and native
45
+ FHIR is projected only at an explicit boundary.
36
46
 
37
47
  `authorizeEmergencyAction` separately checks trusted evidence for every
38
48
  member and for `sos:call` or `sos:request`. Identity verification alone never
@@ -48,6 +58,34 @@ to the same types and implementation; they are not a second contract.
48
58
  `buildSoschainPlaceCardAssociationClaims` records a UHC/VetChain card link to
49
59
  a canonical SOSChain Place or vehicle without merging either identity.
50
60
 
61
+ ## Care, emergency and alert orchestration
62
+
63
+ `sos-sdk-core-ts/care-emergency-alert-orchestration` supplies the shared,
64
+ runtime-neutral plans for Appointment-to-Encounter continuity, protected
65
+ pre-publication calls/messages, resilient emergency ServiceRequest plus triage
66
+ Task creation, and governed donation/public-risk campaigns. Transport,
67
+ persistence, authorization evidence acquisition and product policy remain in
68
+ the channel service, Node adapter and Vet/UHC/SOSChain layers.
69
+
70
+ See [docs/CARE_EMERGENCY_AND_ALERT_ORCHESTRATION.md](docs/CARE_EMERGENCY_AND_ALERT_ORCHESTRATION.md)
71
+ for lifecycle, authorization and persistence invariants.
72
+
73
+ ## Multi-recipient secure messages
74
+
75
+ `sos-sdk-core-ts/secure-message-crypto` protects written and binary-audio
76
+ messages with the same runtime-neutral byte API. It derives one AES-256 content
77
+ key from one random 32-byte message seed, encrypts the content once, and wraps
78
+ that CEK independently for each ML-KEM-768 recipient. The ML-KEM shared secret
79
+ is recipient-specific; the resulting content key and ciphertext are common to
80
+ all authorized recipients.
81
+
82
+ `updateRecipients(...)` rewraps the existing CEK for added device/channel keys
83
+ and can remove obsolete or temporary service entries without changing the
84
+ stored ciphertext, IV or authentication tag. The caller remains responsible
85
+ for proving authorization to change recipients. Runtime packages inject only
86
+ `SecureRandomSource`; Node, Expo and browser crypto adapters must not duplicate
87
+ ML-KEM, HKDF or AES logic. See [docs/SECURE_MESSAGES.md](docs/SECURE_MESSAGES.md).
88
+
51
89
  ```bash
52
90
  npm install
53
91
  npm run check
@@ -0,0 +1,84 @@
1
+ import { type CareJourneyTransition, type EmergencyIntake, type GovernedAlertCampaign, type GovernedAlertCohortSnapshot, type GovernedAlertDeliveryAudit } from 'sos-data-utils-ts/care-emergency-alert-records';
2
+ import { type ClinicalCoding } from 'fhir-data-utils-ts/care-emergency-alert';
3
+ import type { FlatClaimResourceEntry } from 'fhir-data-utils-ts/flat-claim-resource-graph';
4
+ export type FulfilledAppointmentEncounterPlan = Readonly<{
5
+ created: false;
6
+ encounterReference: string;
7
+ }> | Readonly<{
8
+ created: true;
9
+ encounterReference: string;
10
+ encounterEntry: FlatClaimResourceEntry;
11
+ }>;
12
+ /**
13
+ * Plans one idempotent Appointment-to-Encounter transition. Persistence must
14
+ * enforce `idempotencyKey`; an existing reference suppresses duplicate writes.
15
+ */
16
+ export declare function orchestrateFulfilledAppointment(input: Readonly<{
17
+ transition: CareJourneyTransition;
18
+ encounterIdentifier: string;
19
+ existingEncounterReference?: string;
20
+ }>): FulfilledAppointmentEncounterPlan;
21
+ export type EncounterCommunicationAuthorization = Readonly<{
22
+ encounterReference: string;
23
+ actorReference: string;
24
+ recipientReferences: readonly string[];
25
+ channel: 'voice' | 'video' | 'encrypted-message';
26
+ includesClinicalBundle: boolean;
27
+ clinicalStatus?: 'preliminary' | 'final';
28
+ updateIndex: boolean;
29
+ }>;
30
+ /**
31
+ * Authorizes only the channel boundary. It never attests clinical data.
32
+ * Preliminary encrypted Bundles may be delivered directly but cannot update
33
+ * the subject index; voice/video signaling cannot carry a clinical Bundle.
34
+ */
35
+ export declare function authorizeEncounterCommunication(input: Readonly<{
36
+ encounterReference: string;
37
+ actorReference: string;
38
+ recipientReferences: readonly string[];
39
+ actorCanCommunicate: boolean;
40
+ encounterActive: boolean;
41
+ channel: EncounterCommunicationAuthorization['channel'];
42
+ includesClinicalBundle: boolean;
43
+ clinicalStatus?: 'preliminary' | 'final';
44
+ updateIndex: boolean;
45
+ }>): EncounterCommunicationAuthorization;
46
+ /** Creates independent standard ServiceRequest and durable triage Task entries. */
47
+ export declare function orchestrateEmergencyRequest(input: Readonly<{
48
+ intake: EmergencyIntake;
49
+ serviceRequestIdentifier: string;
50
+ triageTaskIdentifier: string;
51
+ assistanceCode: ClinicalCoding;
52
+ }>): Readonly<{
53
+ intake: EmergencyIntake;
54
+ serviceRequestEntry: FlatClaimResourceEntry;
55
+ triageTaskEntry: FlatClaimResourceEntry;
56
+ }>;
57
+ export type GovernedAlertCampaignPlan = Readonly<{
58
+ campaign: GovernedAlertCampaign;
59
+ cohortSnapshot: GovernedAlertCohortSnapshot;
60
+ communicationRequestEntry: FlatClaimResourceEntry;
61
+ }>;
62
+ /** Builds one campaign request bound to the frozen evaluated Group snapshot. */
63
+ export declare function prepareGovernedAlertCampaign(input: Readonly<{
64
+ campaign: GovernedAlertCampaign;
65
+ snapshotGroupReference: string;
66
+ evaluatedAt: string;
67
+ evaluationFingerprint: string;
68
+ eligibleSubjectReferences: readonly string[];
69
+ communicationRequestEntryId: string;
70
+ communicationRequestIdentifier: string;
71
+ category: ClinicalCoding;
72
+ }>): GovernedAlertCampaignPlan;
73
+ /** Rechecks frozen membership before recording the data-layer delivery audit. */
74
+ export declare function prepareGovernedAlertDelivery(input: Readonly<{
75
+ campaignPlan: GovernedAlertCampaignPlan;
76
+ recipientReference: string;
77
+ communicationReference: string;
78
+ evaluatedAt: string;
79
+ eligibilityCurrent: boolean;
80
+ exclusionCurrent: boolean;
81
+ consentReference?: string;
82
+ channelAuthorizationReference?: string;
83
+ outcome: 'sent' | 'suppressed';
84
+ }>): GovernedAlertDeliveryAudit;
@@ -0,0 +1,139 @@
1
+ import { freezeGovernedAlertCohort, normalizeCareJourneyTransition, normalizeEmergencyIntake, normalizeGovernedAlertCampaign, recordGovernedAlertDelivery, } from 'sos-data-utils-ts/care-emergency-alert-records';
2
+ import { buildCohortCommunicationRequestFlatEntry, buildEncounterCareJourneyFlatGraph, buildServiceRequestFlatEntry, buildServiceRequestTriageTaskFlatEntry, } from 'fhir-data-utils-ts/care-emergency-alert';
3
+ /**
4
+ * Plans one idempotent Appointment-to-Encounter transition. Persistence must
5
+ * enforce `idempotencyKey`; an existing reference suppresses duplicate writes.
6
+ */
7
+ export function orchestrateFulfilledAppointment(input) {
8
+ const transition = normalizeCareJourneyTransition(input.transition);
9
+ if (input.existingEncounterReference) {
10
+ return Object.freeze({
11
+ created: false,
12
+ encounterReference: reference(input.existingEncounterReference, 'Encounter', 'existing_encounter_reference_invalid'),
13
+ });
14
+ }
15
+ const encounterEntry = buildEncounterCareJourneyFlatGraph({
16
+ parent: {
17
+ entryId: transition.encounterEntryId,
18
+ identifier: input.encounterIdentifier,
19
+ status: 'in-progress',
20
+ subjectReference: transition.subjectReference,
21
+ appointmentReference: transition.appointmentReference,
22
+ serviceProviderReference: transition.serviceProviderReference,
23
+ periodStart: transition.occurredAt,
24
+ locationReference: transition.locationReference,
25
+ },
26
+ })[0];
27
+ return Object.freeze({ created: true, encounterReference: encounterEntry.reference, encounterEntry });
28
+ }
29
+ /**
30
+ * Authorizes only the channel boundary. It never attests clinical data.
31
+ * Preliminary encrypted Bundles may be delivered directly but cannot update
32
+ * the subject index; voice/video signaling cannot carry a clinical Bundle.
33
+ */
34
+ export function authorizeEncounterCommunication(input) {
35
+ if (!input.actorCanCommunicate)
36
+ throw new Error('encounter_communication_actor_forbidden');
37
+ if (!input.encounterActive)
38
+ throw new Error('encounter_communication_inactive_encounter');
39
+ if ((input.channel === 'voice' || input.channel === 'video') && input.includesClinicalBundle) {
40
+ throw new Error('encounter_call_clinical_bundle_forbidden');
41
+ }
42
+ if (input.includesClinicalBundle && !input.clinicalStatus)
43
+ throw new Error('encounter_communication_clinical_status_required');
44
+ if (input.clinicalStatus === 'preliminary' && input.updateIndex)
45
+ throw new Error('preliminary_clinical_index_update_forbidden');
46
+ const recipientReferences = uniqueReferences(input.recipientReferences, 'encounter_communication_recipient_required');
47
+ return Object.freeze({
48
+ encounterReference: reference(input.encounterReference, 'Encounter', 'encounter_communication_encounter_reference_invalid'),
49
+ actorReference: reference(input.actorReference, undefined, 'encounter_communication_actor_reference_invalid'),
50
+ recipientReferences,
51
+ channel: input.channel,
52
+ includesClinicalBundle: input.includesClinicalBundle,
53
+ ...(input.clinicalStatus ? { clinicalStatus: input.clinicalStatus } : {}),
54
+ updateIndex: input.updateIndex,
55
+ });
56
+ }
57
+ /** Creates independent standard ServiceRequest and durable triage Task entries. */
58
+ export function orchestrateEmergencyRequest(input) {
59
+ const intake = normalizeEmergencyIntake(input.intake);
60
+ const serviceRequestEntry = buildServiceRequestFlatEntry({
61
+ entryId: intake.serviceRequestEntryId,
62
+ identifier: input.serviceRequestIdentifier,
63
+ status: 'active',
64
+ intent: 'order',
65
+ priority: 'stat',
66
+ code: input.assistanceCode,
67
+ subjectReference: intake.subjectReference,
68
+ requesterReference: intake.requesterReference,
69
+ performerReferences: [intake.targetServiceReference],
70
+ authoredAt: intake.receivedAt,
71
+ });
72
+ const triageTaskEntry = buildServiceRequestTriageTaskFlatEntry({
73
+ entryId: intake.triageTaskEntryId,
74
+ identifier: input.triageTaskIdentifier,
75
+ serviceRequestReference: serviceRequestEntry.reference,
76
+ subjectReference: intake.subjectReference,
77
+ requesterReference: intake.requesterReference,
78
+ ownerReference: intake.triageOwnerReference,
79
+ authoredAt: intake.receivedAt,
80
+ });
81
+ return Object.freeze({ intake, serviceRequestEntry, triageTaskEntry });
82
+ }
83
+ /** Builds one campaign request bound to the frozen evaluated Group snapshot. */
84
+ export function prepareGovernedAlertCampaign(input) {
85
+ const campaign = normalizeGovernedAlertCampaign(input.campaign);
86
+ const cohortSnapshot = freezeGovernedAlertCohort({
87
+ campaign,
88
+ snapshotGroupReference: input.snapshotGroupReference,
89
+ evaluatedAt: input.evaluatedAt,
90
+ evaluationFingerprint: input.evaluationFingerprint,
91
+ eligibleSubjectReferences: input.eligibleSubjectReferences,
92
+ });
93
+ const communicationRequestEntry = buildCohortCommunicationRequestFlatEntry({
94
+ entryId: input.communicationRequestEntryId,
95
+ identifier: input.communicationRequestIdentifier,
96
+ status: 'active',
97
+ priority: 'routine',
98
+ category: input.category,
99
+ subjectReference: cohortSnapshot.snapshotGroupReference,
100
+ requesterReference: campaign.requesterReference,
101
+ authoredAt: cohortSnapshot.evaluatedAt,
102
+ reasonReferences: [campaign.groupDefinitionReference],
103
+ });
104
+ return Object.freeze({ campaign, cohortSnapshot, communicationRequestEntry });
105
+ }
106
+ /** Rechecks frozen membership before recording the data-layer delivery audit. */
107
+ export function prepareGovernedAlertDelivery(input) {
108
+ const recipientReference = reference(input.recipientReference, undefined, 'alert_delivery_recipient_reference_invalid');
109
+ if (!input.campaignPlan.cohortSnapshot.eligibleSubjectReferences.includes(recipientReference)) {
110
+ throw new Error('alert_delivery_recipient_not_in_snapshot');
111
+ }
112
+ return recordGovernedAlertDelivery({
113
+ campaignId: input.campaignPlan.campaign.campaignId,
114
+ snapshotGroupReference: input.campaignPlan.cohortSnapshot.snapshotGroupReference,
115
+ recipientReference,
116
+ communicationReference: input.communicationReference,
117
+ evaluatedAt: input.evaluatedAt,
118
+ eligibilityCurrent: input.eligibilityCurrent,
119
+ exclusionCurrent: input.exclusionCurrent,
120
+ consentReference: input.consentReference,
121
+ channelAuthorizationReference: input.channelAuthorizationReference,
122
+ outcome: input.outcome,
123
+ });
124
+ }
125
+ function uniqueReferences(values, error) {
126
+ const references = [...new Set(values.map(value => reference(value, undefined, error)))];
127
+ if (!references.length)
128
+ throw new TypeError(error);
129
+ return Object.freeze(references);
130
+ }
131
+ function reference(value, expectedType, error) {
132
+ if (typeof value !== 'string')
133
+ throw new TypeError(error);
134
+ const normalized = value.trim();
135
+ const match = /^([A-Z][A-Za-z0-9]+)\/([A-Za-z0-9.-]{1,128})$/.exec(normalized);
136
+ if (!match || (expectedType && match[1] !== expectedType))
137
+ throw new TypeError(error);
138
+ return normalized;
139
+ }
package/dist/index.d.ts CHANGED
@@ -3,4 +3,6 @@ export { EmergencyCapabilities, EvidenceDomains, EvidenceRelationships, Soschain
3
3
  export type { EvidenceRelationship, SoschainEvidenceRelationship, } from 'sos-data-utils-ts/emergency-evidence';
4
4
  export * from './emergency-group.js';
5
5
  export * from './emergency-authorization.js';
6
+ export * from './care-emergency-alert-orchestration.js';
6
7
  export * from './place-card-association.js';
8
+ export * from './secure-message-crypto.js';
package/dist/index.js CHANGED
@@ -2,4 +2,6 @@
2
2
  export { EmergencyCapabilities, EvidenceDomains, EvidenceRelationships, SoschainEmergencyCapabilities, SoschainEvidenceDomains, SoschainEvidenceRelationships, } from 'sos-data-utils-ts/emergency-evidence';
3
3
  export * from './emergency-group.js';
4
4
  export * from './emergency-authorization.js';
5
+ export * from './care-emergency-alert-orchestration.js';
5
6
  export * from './place-card-association.js';
7
+ export * from './secure-message-crypto.js';
@@ -0,0 +1,49 @@
1
+ import { type SecureMessageEnvelope } from 'sos-data-utils-ts/secure-message-envelope';
2
+ export interface SecureRandomSource {
3
+ getRandomBytes(length: number): Promise<Uint8Array>;
4
+ }
5
+ export interface SecureMessageRecipientPublicKey {
6
+ readonly keyId: string;
7
+ readonly publicKey: Uint8Array;
8
+ }
9
+ export interface SecureMessageRecipientKeyPair extends SecureMessageRecipientPublicKey {
10
+ readonly secretKey: Uint8Array;
11
+ }
12
+ export interface OpenedSecureMessage {
13
+ readonly contentType: string;
14
+ readonly plaintext: Uint8Array;
15
+ }
16
+ /**
17
+ * Preserve the historical secure-message contract: one random 32-byte message
18
+ * seed deterministically derives the AES-256 content-encryption key. The CEK is
19
+ * then wrapped independently for every current ML-KEM recipient.
20
+ */
21
+ export declare const deriveSecureMessageContentEncryptionKey: (messageSeed: Uint8Array) => Uint8Array;
22
+ /** Runtime-neutral ML-KEM multi-recipient encryption for text or binary audio messages. */
23
+ export declare class MultiRecipientSecureMessageCrypto {
24
+ private readonly randomSource;
25
+ constructor(randomSource: SecureRandomSource);
26
+ generateRecipientKeyPair(keyId: string): Promise<SecureMessageRecipientKeyPair>;
27
+ seal(input: Readonly<{
28
+ plaintext: Uint8Array;
29
+ contentType: string;
30
+ recipients: readonly SecureMessageRecipientPublicKey[];
31
+ }>): Promise<SecureMessageEnvelope>;
32
+ open(input: Readonly<{
33
+ envelope: SecureMessageEnvelope;
34
+ recipient: SecureMessageRecipientKeyPair;
35
+ }>): Promise<OpenedSecureMessage>;
36
+ /**
37
+ * Rewrap the existing CEK without opening or re-encrypting message content.
38
+ * Business authorization for adding or removing a DCR remains with the caller.
39
+ */
40
+ updateRecipients(input: Readonly<{
41
+ envelope: SecureMessageEnvelope;
42
+ authorizingRecipient: SecureMessageRecipientKeyPair;
43
+ addRecipients?: readonly SecureMessageRecipientPublicKey[];
44
+ removeRecipientKeyIds?: readonly string[];
45
+ }>): Promise<SecureMessageEnvelope>;
46
+ private assertUniqueRecipientKeys;
47
+ private wrapContentEncryptionKey;
48
+ private unwrapContentEncryptionKey;
49
+ }
@@ -0,0 +1,306 @@
1
+ import { gcm } from '@noble/ciphers/aes.js';
2
+ import { hkdf } from '@noble/hashes/hkdf.js';
3
+ import { sha256 } from '@noble/hashes/sha2.js';
4
+ import { sha3_256 } from '@noble/hashes/sha3.js';
5
+ import { ml_kem768 } from '@noble/post-quantum/ml-kem.js';
6
+ import { SecureMessageContentEncryptionAlgorithm, SecureMessageEnvelopeProfile, SecureMessageKeyManagementAlgorithm, assertSecureMessageEnvelope, } from 'sos-data-utils-ts/secure-message-envelope';
7
+ const textEncoder = new TextEncoder();
8
+ const textDecoder = new TextDecoder(undefined, { fatal: true });
9
+ const AES_KEY_BYTES = 32;
10
+ const AES_GCM_NONCE_BYTES = 12;
11
+ const AES_GCM_TAG_BYTES = 16;
12
+ const ML_KEM_KEYGEN_SEED_BYTES = 64;
13
+ const ML_KEM_ENCAPSULATION_SEED_BYTES = 32;
14
+ const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
15
+ const WRAP_VERSION = 'mlkem-cek-wrap-v1';
16
+ const assertRandomBytes = (bytes, expectedLength) => {
17
+ if (!(bytes instanceof Uint8Array) || bytes.length !== expectedLength) {
18
+ throw new Error('secure_random_source_invalid');
19
+ }
20
+ return bytes;
21
+ };
22
+ const assertKeyId = (keyId) => {
23
+ const normalized = String(keyId).trim();
24
+ if (!normalized)
25
+ throw new Error('secure_message_recipient_key_id_required');
26
+ return normalized;
27
+ };
28
+ const encodeBase64Url = (bytes) => {
29
+ let output = '';
30
+ for (let offset = 0; offset < bytes.length; offset += 3) {
31
+ const first = bytes[offset];
32
+ const second = bytes[offset + 1];
33
+ const third = bytes[offset + 2];
34
+ const combined = (first << 16) | ((second ?? 0) << 8) | (third ?? 0);
35
+ output += BASE64URL_ALPHABET[(combined >>> 18) & 63];
36
+ output += BASE64URL_ALPHABET[(combined >>> 12) & 63];
37
+ if (second !== undefined)
38
+ output += BASE64URL_ALPHABET[(combined >>> 6) & 63];
39
+ if (third !== undefined)
40
+ output += BASE64URL_ALPHABET[combined & 63];
41
+ }
42
+ return output;
43
+ };
44
+ const decodeBase64Url = (value) => {
45
+ if (!/^[A-Za-z0-9_-]+$/u.test(value))
46
+ throw new Error('secure_message_base64url_invalid');
47
+ const output = new Uint8Array(Math.floor((value.length * 6) / 8));
48
+ let accumulator = 0;
49
+ let bits = 0;
50
+ let outputOffset = 0;
51
+ for (const character of value) {
52
+ const alphabetIndex = BASE64URL_ALPHABET.indexOf(character);
53
+ if (alphabetIndex < 0)
54
+ throw new Error('secure_message_base64url_invalid');
55
+ accumulator = (accumulator << 6) | alphabetIndex;
56
+ bits += 6;
57
+ if (bits >= 8) {
58
+ bits -= 8;
59
+ output[outputOffset] = (accumulator >>> bits) & 0xff;
60
+ outputOffset += 1;
61
+ }
62
+ }
63
+ return output;
64
+ };
65
+ const encodeJson = (value) => encodeBase64Url(textEncoder.encode(JSON.stringify(value)));
66
+ const decodeJson = (value) => {
67
+ try {
68
+ return JSON.parse(textDecoder.decode(decodeBase64Url(value)));
69
+ }
70
+ catch {
71
+ throw new Error('secure_message_json_invalid');
72
+ }
73
+ };
74
+ const splitCiphertextAndTag = (sealed) => {
75
+ if (sealed.length < AES_GCM_TAG_BYTES)
76
+ throw new Error('secure_message_ciphertext_invalid');
77
+ return {
78
+ ciphertext: sealed.slice(0, -AES_GCM_TAG_BYTES),
79
+ tag: sealed.slice(-AES_GCM_TAG_BYTES),
80
+ };
81
+ };
82
+ const joinCiphertextAndTag = (ciphertext, tag) => {
83
+ if (tag.length !== AES_GCM_TAG_BYTES)
84
+ throw new Error('secure_message_tag_invalid');
85
+ const sealed = new Uint8Array(ciphertext.length + tag.length);
86
+ sealed.set(ciphertext);
87
+ sealed.set(tag, ciphertext.length);
88
+ return sealed;
89
+ };
90
+ const protectedHeader = (contentType) => {
91
+ const normalizedContentType = String(contentType).trim();
92
+ if (!normalizedContentType || normalizedContentType.length > 255 || /[\u0000-\u001f\u007f]/u.test(normalizedContentType)) {
93
+ throw new Error('secure_message_content_type_invalid');
94
+ }
95
+ return {
96
+ typ: 'application/jose+json',
97
+ cty: normalizedContentType,
98
+ enc: SecureMessageContentEncryptionAlgorithm.A256GCM,
99
+ profile: SecureMessageEnvelopeProfile.MultirecipientMlKemV1,
100
+ };
101
+ };
102
+ const parseProtectedHeader = (encoded) => {
103
+ const header = decodeJson(encoded);
104
+ if (header.typ !== 'application/jose+json'
105
+ || typeof header.cty !== 'string'
106
+ || header.enc !== SecureMessageContentEncryptionAlgorithm.A256GCM
107
+ || header.profile !== SecureMessageEnvelopeProfile.MultirecipientMlKemV1) {
108
+ throw new Error('secure_message_protected_header_invalid');
109
+ }
110
+ return protectedHeader(header.cty);
111
+ };
112
+ const deriveRecipientWrappingKey = (sharedSecret, encodedProtectedHeader, recipientKeyId) => hkdf(sha256, sharedSecret, textEncoder.encode('multi-recipient-ml-kem-v1'), textEncoder.encode(`${encodedProtectedHeader}.${recipientKeyId}.cek`), AES_KEY_BYTES);
113
+ /**
114
+ * Preserve the historical secure-message contract: one random 32-byte message
115
+ * seed deterministically derives the AES-256 content-encryption key. The CEK is
116
+ * then wrapped independently for every current ML-KEM recipient.
117
+ */
118
+ export const deriveSecureMessageContentEncryptionKey = (messageSeed) => {
119
+ if (!(messageSeed instanceof Uint8Array) || messageSeed.length !== AES_KEY_BYTES) {
120
+ throw new Error('secure_message_seed_invalid');
121
+ }
122
+ return sha3_256(messageSeed);
123
+ };
124
+ /** Runtime-neutral ML-KEM multi-recipient encryption for text or binary audio messages. */
125
+ export class MultiRecipientSecureMessageCrypto {
126
+ constructor(randomSource) {
127
+ this.randomSource = randomSource;
128
+ }
129
+ async generateRecipientKeyPair(keyId) {
130
+ const seed = assertRandomBytes(await this.randomSource.getRandomBytes(ML_KEM_KEYGEN_SEED_BYTES), ML_KEM_KEYGEN_SEED_BYTES);
131
+ try {
132
+ const { publicKey, secretKey } = ml_kem768.keygen(seed);
133
+ return { keyId: assertKeyId(keyId), publicKey, secretKey };
134
+ }
135
+ finally {
136
+ seed.fill(0);
137
+ }
138
+ }
139
+ async seal(input) {
140
+ if (!(input.plaintext instanceof Uint8Array) || input.plaintext.length === 0) {
141
+ throw new Error('secure_message_plaintext_required');
142
+ }
143
+ this.assertUniqueRecipientKeys(input.recipients);
144
+ const messageSeed = assertRandomBytes(await this.randomSource.getRandomBytes(AES_KEY_BYTES), AES_KEY_BYTES);
145
+ const cek = deriveSecureMessageContentEncryptionKey(messageSeed);
146
+ const iv = assertRandomBytes(await this.randomSource.getRandomBytes(AES_GCM_NONCE_BYTES), AES_GCM_NONCE_BYTES);
147
+ const encodedProtectedHeader = encodeJson(protectedHeader(input.contentType));
148
+ try {
149
+ const sealed = gcm(cek, iv, textEncoder.encode(encodedProtectedHeader)).encrypt(input.plaintext);
150
+ const { ciphertext, tag } = splitCiphertextAndTag(sealed);
151
+ const recipients = [];
152
+ for (const recipient of input.recipients) {
153
+ recipients.push(await this.wrapContentEncryptionKey(cek, encodedProtectedHeader, recipient));
154
+ }
155
+ const envelope = {
156
+ profile: SecureMessageEnvelopeProfile.MultirecipientMlKemV1,
157
+ protected: encodedProtectedHeader,
158
+ recipients,
159
+ iv: encodeBase64Url(iv),
160
+ ciphertext: encodeBase64Url(ciphertext),
161
+ tag: encodeBase64Url(tag),
162
+ };
163
+ assertSecureMessageEnvelope(envelope);
164
+ return envelope;
165
+ }
166
+ finally {
167
+ messageSeed.fill(0);
168
+ cek.fill(0);
169
+ iv.fill(0);
170
+ }
171
+ }
172
+ async open(input) {
173
+ assertSecureMessageEnvelope(input.envelope);
174
+ const header = parseProtectedHeader(input.envelope.protected);
175
+ const cek = await this.unwrapContentEncryptionKey(input.envelope, input.recipient);
176
+ try {
177
+ const plaintext = gcm(cek, decodeBase64Url(input.envelope.iv), textEncoder.encode(input.envelope.protected)).decrypt(joinCiphertextAndTag(decodeBase64Url(input.envelope.ciphertext), decodeBase64Url(input.envelope.tag)));
178
+ return { contentType: header.cty, plaintext };
179
+ }
180
+ finally {
181
+ cek.fill(0);
182
+ }
183
+ }
184
+ /**
185
+ * Rewrap the existing CEK without opening or re-encrypting message content.
186
+ * Business authorization for adding or removing a DCR remains with the caller.
187
+ */
188
+ async updateRecipients(input) {
189
+ assertSecureMessageEnvelope(input.envelope);
190
+ parseProtectedHeader(input.envelope.protected);
191
+ const addRecipients = input.addRecipients ?? [];
192
+ this.assertUniqueRecipientKeys(addRecipients, false);
193
+ const removedKeyIds = new Set((input.removeRecipientKeyIds ?? []).map(assertKeyId));
194
+ const existingKeyIds = new Set(input.envelope.recipients.map(({ header }) => header.kid));
195
+ for (const removedKeyId of removedKeyIds) {
196
+ if (!existingKeyIds.has(removedKeyId)) {
197
+ throw new Error('secure_message_recipient_remove_not_found');
198
+ }
199
+ }
200
+ const retainedRecipients = input.envelope.recipients.filter(({ header }) => !removedKeyIds.has(header.kid));
201
+ const retainedKeyIds = new Set(retainedRecipients.map(({ header }) => header.kid));
202
+ for (const recipient of addRecipients) {
203
+ const keyId = assertKeyId(recipient.keyId);
204
+ if (retainedKeyIds.has(keyId))
205
+ throw new Error('secure_message_recipient_duplicate');
206
+ retainedKeyIds.add(keyId);
207
+ }
208
+ if (retainedRecipients.length + addRecipients.length === 0) {
209
+ throw new Error('secure_message_recipients_required');
210
+ }
211
+ const cek = await this.unwrapContentEncryptionKey(input.envelope, input.authorizingRecipient);
212
+ try {
213
+ const addedRecipients = [];
214
+ for (const recipient of addRecipients) {
215
+ addedRecipients.push(await this.wrapContentEncryptionKey(cek, input.envelope.protected, recipient));
216
+ }
217
+ const updatedEnvelope = {
218
+ ...input.envelope,
219
+ recipients: [...retainedRecipients, ...addedRecipients],
220
+ };
221
+ assertSecureMessageEnvelope(updatedEnvelope);
222
+ return updatedEnvelope;
223
+ }
224
+ finally {
225
+ cek.fill(0);
226
+ }
227
+ }
228
+ assertUniqueRecipientKeys(recipients, requireAtLeastOne = true) {
229
+ if (!Array.isArray(recipients) || (requireAtLeastOne && recipients.length === 0)) {
230
+ throw new Error('secure_message_recipients_required');
231
+ }
232
+ const keyIds = new Set();
233
+ for (const recipient of recipients) {
234
+ const keyId = assertKeyId(recipient.keyId);
235
+ if (!(recipient.publicKey instanceof Uint8Array))
236
+ throw new Error('secure_message_recipient_public_key_invalid');
237
+ if (keyIds.has(keyId))
238
+ throw new Error('secure_message_recipient_duplicate');
239
+ keyIds.add(keyId);
240
+ }
241
+ }
242
+ async wrapContentEncryptionKey(cek, encodedProtectedHeader, recipient) {
243
+ const recipientKeyId = assertKeyId(recipient.keyId);
244
+ const encapsulationSeed = assertRandomBytes(await this.randomSource.getRandomBytes(ML_KEM_ENCAPSULATION_SEED_BYTES), ML_KEM_ENCAPSULATION_SEED_BYTES);
245
+ let sharedSecret;
246
+ let wrappingKey;
247
+ const wrappingIv = assertRandomBytes(await this.randomSource.getRandomBytes(AES_GCM_NONCE_BYTES), AES_GCM_NONCE_BYTES);
248
+ try {
249
+ const encapsulated = ml_kem768.encapsulate(recipient.publicKey, encapsulationSeed);
250
+ sharedSecret = encapsulated.sharedSecret;
251
+ wrappingKey = deriveRecipientWrappingKey(sharedSecret, encodedProtectedHeader, recipientKeyId);
252
+ const wrapAad = textEncoder.encode(`${encodedProtectedHeader}.${recipientKeyId}.cek`);
253
+ const sealedCek = gcm(wrappingKey, wrappingIv, wrapAad).encrypt(cek);
254
+ const { ciphertext, tag } = splitCiphertextAndTag(sealedCek);
255
+ const wrappedCek = {
256
+ v: WRAP_VERSION,
257
+ kem: 'ML-KEM-768',
258
+ kdf: 'HKDF-SHA-256',
259
+ wrap: 'A256GCM',
260
+ kemCiphertext: encodeBase64Url(encapsulated.cipherText),
261
+ iv: encodeBase64Url(wrappingIv),
262
+ ciphertext: encodeBase64Url(ciphertext),
263
+ tag: encodeBase64Url(tag),
264
+ };
265
+ return {
266
+ header: {
267
+ alg: SecureMessageKeyManagementAlgorithm.MlKem768HkdfSha256A256GcmKw,
268
+ kid: recipientKeyId,
269
+ },
270
+ encrypted_key: encodeJson(wrappedCek),
271
+ };
272
+ }
273
+ finally {
274
+ encapsulationSeed.fill(0);
275
+ wrappingIv.fill(0);
276
+ sharedSecret?.fill(0);
277
+ wrappingKey?.fill(0);
278
+ }
279
+ }
280
+ async unwrapContentEncryptionKey(envelope, recipientKeyPair) {
281
+ const recipientKeyId = assertKeyId(recipientKeyPair.keyId);
282
+ const recipient = envelope.recipients.find(({ header }) => header.kid === recipientKeyId);
283
+ if (!recipient)
284
+ throw new Error('secure_message_recipient_not_found');
285
+ const wrappedCek = decodeJson(recipient.encrypted_key);
286
+ if (wrappedCek.v !== WRAP_VERSION
287
+ || wrappedCek.kem !== 'ML-KEM-768'
288
+ || wrappedCek.kdf !== 'HKDF-SHA-256'
289
+ || wrappedCek.wrap !== 'A256GCM'
290
+ || typeof wrappedCek.kemCiphertext !== 'string'
291
+ || typeof wrappedCek.iv !== 'string'
292
+ || typeof wrappedCek.ciphertext !== 'string'
293
+ || typeof wrappedCek.tag !== 'string') {
294
+ throw new Error('secure_message_recipient_wrap_invalid');
295
+ }
296
+ const sharedSecret = ml_kem768.decapsulate(decodeBase64Url(wrappedCek.kemCiphertext), recipientKeyPair.secretKey);
297
+ const wrappingKey = deriveRecipientWrappingKey(sharedSecret, envelope.protected, recipientKeyId);
298
+ try {
299
+ return gcm(wrappingKey, decodeBase64Url(wrappedCek.iv), textEncoder.encode(`${envelope.protected}.${recipientKeyId}.cek`)).decrypt(joinCiphertextAndTag(decodeBase64Url(wrappedCek.ciphertext), decodeBase64Url(wrappedCek.tag)));
300
+ }
301
+ finally {
302
+ sharedSecret.fill(0);
303
+ wrappingKey.fill(0);
304
+ }
305
+ }
306
+ }
@@ -0,0 +1,74 @@
1
+ # Care, Emergency and Alert Orchestration
2
+
3
+ This package owns runtime-neutral authorization and orchestration shared by
4
+ VetChain, UHC and SOSChain. FHIR flat-claim builders belong to
5
+ `fhir-data-utils-ts`; deterministic records belong to `sos-data-utils-ts`;
6
+ transport and persistence belong to BFF/channel and Node services; animal and
7
+ human policy stays in Vet and UHC.
8
+
9
+ ## 1. Care continuity and protected contact
10
+
11
+ 1. Appointment `fulfilled` -> Encounter is an idempotent transition.
12
+ 2. The Encounter preserves the selected subject, Appointment, service-provider
13
+ Organization, start time and current Location.
14
+ 3. Admission, diagnostics, intensive care, ward and discharge are represented
15
+ by Encounter/location continuity. Controllers see only what current Consent
16
+ and actor/subject authorization permit.
17
+ 4. An authorized practitioner may start voice/video contact or send an
18
+ encrypted message to the controller or permitted related recipients before
19
+ publishing stressful results.
20
+ 5. Voice/video signaling carries no clinical Bundle. A preliminary clinical
21
+ Bundle may be delivered by encrypted message only with `updateIndex=false`.
22
+ A call or preliminary message must not publish, attest or update the subject
23
+ index. Final clinical publication is a separate workflow.
24
+ 6. A missed call may create an encrypted text/audio message in the same thread;
25
+ its recipients and thread authorization are revalidated by the service.
26
+
27
+ The orchestrator returns a plan. The persistence owner must enforce the
28
+ idempotency key and must not treat a returned plan as authorization evidence.
29
+
30
+ ## 2. Resilient emergency assistance
31
+
32
+ When normal telephone service is unavailable, an authenticated controller or
33
+ authorized requester submits an emergency request to a HealthcareService or
34
+ its owning Organization. The BFF resolves the official target and creates two
35
+ independent resources:
36
+
37
+ - a compact-coded ServiceRequest containing the care request, requester,
38
+ subject and performer target;
39
+ - a Task containing durable intake/triage ownership and linking back through
40
+ `Task.focus`.
41
+
42
+ The emergency console can claim and update the Task, then open an authorized
43
+ voice/video callback or encrypted thread. Raw phone/email destinations,
44
+ WebRTC SDP/ICE/TURN state and encryption keys never enter either FHIR resource.
45
+ The request grants no read access by itself; subject access still requires the
46
+ current emergency or ordinary authorization contract.
47
+
48
+ ## 3. Governed alerts
49
+
50
+ The common flow supports both `blood-donation` and `public-risk` campaigns.
51
+ The server evaluates a governed definitional Group, freezes an enumerated Group
52
+ snapshot and binds a CommunicationRequest to that snapshot. Subscription wakes
53
+ evaluation; it is not Consent, cohort-query authority or send authority.
54
+
55
+ Blood-donation campaigns require compact `system|code` blood-group filters and
56
+ the minimum elapsed days since the last donation. Public-risk campaigns require
57
+ server-authorized FHIR `resourceType`, `searchParameter` and `value` criteria;
58
+ clients cannot submit executable queries.
59
+
60
+ Immediately before each delivery, the service must verify snapshot membership,
61
+ current eligibility, current exclusions, Consent and channel authorization.
62
+ Only then may it create/send the Communication and record the audit. Group and
63
+ CommunicationRequest resources never replace those per-recipient checks.
64
+
65
+ ## Release proof
66
+
67
+ Merge validated shared dependency sources to `main`, create immutable
68
+ unpublished tarballs from those commits, and install them temporarily with
69
+ `--no-save`. Prove the affected real local Vet channel (portal, phone service,
70
+ chatbot or another supported channel) -> BFF/channel service -> high-level SDK
71
+ -> GW VET journey without skips. Merge the validated consumer source to
72
+ `main`, publish exact packages bottom-up, pin registry versions, repeat affected
73
+ local gates, and only then promote to staging. Never commit local tarballs or
74
+ local dependency references.
@@ -0,0 +1,59 @@
1
+ # Multi-recipient secure messages
2
+
3
+ ## Public API
4
+
5
+ Create `MultiRecipientSecureMessageCrypto` with a runtime-provided
6
+ `SecureRandomSource`. The runtime supplies cryptographically secure random
7
+ bytes only. The shared SDK owns ML-KEM-768, HKDF-SHA-256, SHA3-256 content-key
8
+ derivation and AES-256-GCM.
9
+
10
+ The byte-oriented methods are:
11
+
12
+ - `generateRecipientKeyPair(keyId)` creates an ML-KEM recipient key pair.
13
+ - `seal({ plaintext, contentType, recipients })` encrypts content once and
14
+ protects the CEK independently for every recipient public key.
15
+ - `open({ envelope, recipient })` authenticates and returns the original bytes
16
+ plus protected content type.
17
+ - `updateRecipients(...)` adds and removes recipient wraps without changing
18
+ the content ciphertext.
19
+
20
+ Text callers encode/decode UTF-8 at their UI boundary. Audio callers pass the
21
+ recorded binary bytes directly. The cryptographic layer never transcribes,
22
+ converts or interprets either payload.
23
+
24
+ ## Key model
25
+
26
+ One random 32-byte message seed deterministically derives one AES-256 CEK using
27
+ SHA3-256. Content is encrypted once with AES-256-GCM. For each recipient:
28
+
29
+ 1. ML-KEM-768 independently produces a recipient-specific shared secret and
30
+ KEM ciphertext.
31
+ 2. HKDF-SHA-256 binds a wrapping key to the protected header and recipient
32
+ `kid`.
33
+ 3. AES-256-GCM protects the common CEK with that recipient wrapping key.
34
+
35
+ The common CEK is not the ML-KEM shared secret. This distinction permits every
36
+ authorized recipient to open the same content ciphertext while preserving an
37
+ independent post-quantum encapsulation for each recipient key.
38
+
39
+ ## Recipient changes
40
+
41
+ `updateRecipients(...)` proves possession of one current recipient secret key,
42
+ unwraps the CEK in memory, adds independently wrapped CEKs for new public keys,
43
+ and removes selected old entries. It does not decrypt or re-encrypt message
44
+ content. Temporary buffers holding seeds, shared secrets, CEKs and wrapping
45
+ keys are cleared after use where the JavaScript runtime permits it.
46
+
47
+ This method is a cryptographic mechanism, not an authorization decision. A BFF
48
+ or product service must first enforce the current subject, device/channel,
49
+ Consent and recovery policy. It must reject an empty final recipient list.
50
+
51
+ ## Storage and channels
52
+
53
+ Storage persists the opaque envelope from `sos-data-utils-ts`. It need not
54
+ retain a service recipient once the current user devices have received their
55
+ wraps. Web, telephone, WhatsApp or future channel DCRs use distinct `kid` and
56
+ ML-KEM keys, but the message remains one ciphertext.
57
+
58
+ Audio duration, file size, retention, notification email and voicemail access
59
+ are product-level policies. They do not change this envelope or core API.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sos-sdk-core-ts",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Runtime-neutral shared orchestration for SOS, Vet and UHC products",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Connecting Solution & Applications Ltd",
@@ -20,6 +20,10 @@
20
20
  "types": "./dist/emergency-group.d.ts",
21
21
  "default": "./dist/emergency-group.js"
22
22
  },
23
+ "./care-emergency-alert-orchestration": {
24
+ "types": "./dist/care-emergency-alert-orchestration.d.ts",
25
+ "default": "./dist/care-emergency-alert-orchestration.js"
26
+ },
23
27
  "./emergency-authorization": {
24
28
  "types": "./dist/emergency-authorization.d.ts",
25
29
  "default": "./dist/emergency-authorization.js"
@@ -27,9 +31,18 @@
27
31
  "./place-card-association": {
28
32
  "types": "./dist/place-card-association.d.ts",
29
33
  "default": "./dist/place-card-association.js"
34
+ },
35
+ "./secure-message-crypto": {
36
+ "types": "./dist/secure-message-crypto.d.ts",
37
+ "default": "./dist/secure-message-crypto.js"
30
38
  }
31
39
  },
32
- "files": ["dist", "README.md", "CHANGELOG.md"],
40
+ "files": [
41
+ "dist",
42
+ "README.md",
43
+ "CHANGELOG.md",
44
+ "docs"
45
+ ],
33
46
  "scripts": {
34
47
  "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
35
48
  "build": "npm run clean && tsc -p tsconfig.json",
@@ -39,7 +52,11 @@
39
52
  "prepack": "npm run check"
40
53
  },
41
54
  "dependencies": {
42
- "sos-data-utils-ts": "0.3.2"
55
+ "@noble/ciphers": "2.4.0",
56
+ "@noble/hashes": "2.4.0",
57
+ "@noble/post-quantum": "0.7.1",
58
+ "fhir-data-utils-ts": "0.3.7",
59
+ "sos-data-utils-ts": "0.4.1"
43
60
  },
44
61
  "devDependencies": {
45
62
  "@types/node": "^22.5.0",