sos-sdk-node-ts 0.1.4 → 0.1.6

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/README.md CHANGED
@@ -1,4 +1,8 @@
1
1
  # sos-sdk-node-ts
2
+
3
+ Accepted Encounter handover persistence and future channel-audience
4
+ reconciliation are documented in
5
+ [`docs/ENCOUNTER_TRANSFER_AUDIENCE_PERSISTENCE.md`](docs/ENCOUNTER_TRANSFER_AUDIENCE_PERSISTENCE.md).
2
6
  ## Blocking local-first SDK release
3
7
 
4
8
  The canonical contract is [`SDK_LAYERING.md`](https://github.com/Fundacion-UNID/sos-data-utils-ts/blob/main/docs/SDK_LAYERING.md). Its
@@ -0,0 +1,73 @@
1
+ export type ConfidentialChannelSourceMessage<TEnvelope = unknown> = Readonly<{
2
+ id: string;
3
+ channelId: string;
4
+ sender: string;
5
+ recipients: readonly string[];
6
+ sent?: string;
7
+ envelope: TEnvelope;
8
+ }>;
9
+ export type ConfidentialChannelMessage = Readonly<{
10
+ id: string;
11
+ channelId: string;
12
+ sender: string;
13
+ recipients: readonly string[];
14
+ sent?: string;
15
+ messageKind: 'clinical-document' | 'text' | 'audio';
16
+ text?: string;
17
+ bundle?: unknown;
18
+ audioBase64?: string;
19
+ audioContentType?: string;
20
+ }>;
21
+ export type ConfidentialChannelDocument = Readonly<{
22
+ version: 1;
23
+ cursor?: string;
24
+ messages: readonly ConfidentialChannelMessage[];
25
+ }>;
26
+ export type ConfidentialChannelVault = Readonly<{
27
+ get(collection: string, id: string): Promise<unknown>;
28
+ put(collection: string, record: Readonly<Record<string, unknown> & {
29
+ id: string;
30
+ }>): Promise<unknown>;
31
+ query(collection: string, where: readonly Readonly<{
32
+ attribute: string;
33
+ equals: string;
34
+ }>[]): Promise<readonly unknown[]>;
35
+ }>;
36
+ export type ConfidentialChannelStorageAdapter = Readonly<{
37
+ protect(document: Readonly<{
38
+ id?: string;
39
+ content: unknown;
40
+ }>): Promise<unknown>;
41
+ unprotect(document: Readonly<{
42
+ id?: string;
43
+ jwe: unknown;
44
+ }>): Promise<unknown>;
45
+ }>;
46
+ /** Rebuildable encrypted materialization; canonical messages remain in their governed source. */
47
+ export declare class ConfidentialChannelCache {
48
+ private readonly vault;
49
+ constructor(vault: ConfidentialChannelVault);
50
+ synchronize<TEnvelope>(input: Readonly<{
51
+ profileId: string;
52
+ subjectId: string;
53
+ channelId: string;
54
+ adapter: ConfidentialChannelStorageAdapter;
55
+ messages: readonly ConfidentialChannelSourceMessage<TEnvelope>[];
56
+ open(envelope: TEnvelope): Promise<Readonly<{
57
+ contentType: string;
58
+ plaintext: Uint8Array;
59
+ }>>;
60
+ }>): Promise<ConfidentialChannelDocument>;
61
+ read(input: Readonly<{
62
+ profileId: string;
63
+ subjectId: string;
64
+ channelId: string;
65
+ adapter: ConfidentialChannelStorageAdapter;
66
+ }>): Promise<ConfidentialChannelDocument | undefined>;
67
+ listSubject(input: Readonly<{
68
+ profileId: string;
69
+ subjectId: string;
70
+ adapter: ConfidentialChannelStorageAdapter;
71
+ }>): Promise<ConfidentialChannelDocument[]>;
72
+ private stored;
73
+ }
@@ -0,0 +1,109 @@
1
+ import { createHash } from 'node:crypto';
2
+ const COLLECTION = 'channel_communication_cache';
3
+ /** Rebuildable encrypted materialization; canonical messages remain in their governed source. */
4
+ export class ConfidentialChannelCache {
5
+ vault;
6
+ constructor(vault) {
7
+ this.vault = vault;
8
+ }
9
+ async synchronize(input) {
10
+ const identity = cacheIdentity(input);
11
+ const current = await this.read(input) ?? emptyChannel();
12
+ const knownIds = new Set(current.messages.map(message => message.id));
13
+ const additions = [];
14
+ for (const source of input.messages) {
15
+ if (source.channelId !== input.channelId || knownIds.has(source.id))
16
+ continue;
17
+ additions.push(materialize(source, await input.open(source.envelope)));
18
+ }
19
+ const messages = [...current.messages, ...additions].sort(compareMessages);
20
+ const cursor = messages.reduce((latest, message) => {
21
+ if (!message.sent)
22
+ return latest;
23
+ return !latest || message.sent > latest ? message.sent : latest;
24
+ }, current.cursor);
25
+ const content = { version: 1, cursor, messages };
26
+ const protectedDocument = protectedResult(await input.adapter.protect({ id: identity.id, content }));
27
+ await this.vault.put(COLLECTION, {
28
+ id: identity.id,
29
+ profileHash: identity.profileHash,
30
+ subjectHash: identity.subjectHash,
31
+ updatedAt: new Date().toISOString(),
32
+ jwe: protectedDocument.jwe,
33
+ });
34
+ return content;
35
+ }
36
+ async read(input) {
37
+ const identity = cacheIdentity(input);
38
+ const stored = this.stored(await this.vault.get(COLLECTION, identity.id));
39
+ if (!stored || stored.profileHash !== identity.profileHash || stored.subjectHash !== identity.subjectHash)
40
+ return undefined;
41
+ return channelContent(openedResult(await input.adapter.unprotect({ id: identity.id, jwe: stored.jwe })).content);
42
+ }
43
+ async listSubject(input) {
44
+ const profileHash = digest(required(input.profileId, 'channel_cache_profile_required'));
45
+ const subjectHash = digest(required(input.subjectId, 'channel_cache_subject_required'));
46
+ const rows = await this.vault.query(COLLECTION, [
47
+ { attribute: 'profileHash', equals: profileHash },
48
+ { attribute: 'subjectHash', equals: subjectHash },
49
+ ]);
50
+ return Promise.all(rows.map(async (row) => {
51
+ const stored = this.stored(row);
52
+ if (!stored)
53
+ throw new Error('channel_cache_record_invalid');
54
+ return channelContent(openedResult(await input.adapter.unprotect({ id: stored.id, jwe: stored.jwe })).content);
55
+ }));
56
+ }
57
+ stored(value) {
58
+ if (!value || typeof value !== 'object' || !('id' in value) || !('jwe' in value))
59
+ return undefined;
60
+ return value;
61
+ }
62
+ }
63
+ function materialize(source, opened) {
64
+ const contentType = String(opened.contentType || '').trim().toLowerCase();
65
+ const common = {
66
+ id: source.id, channelId: source.channelId, sender: source.sender,
67
+ recipients: [...source.recipients], sent: source.sent,
68
+ };
69
+ if (contentType.startsWith('text/plain')) {
70
+ return { ...common, messageKind: 'text', text: Buffer.from(opened.plaintext).toString('utf8') };
71
+ }
72
+ if (contentType.startsWith('audio/')) {
73
+ return { ...common, messageKind: 'audio', audioBase64: Buffer.from(opened.plaintext).toString('base64'), audioContentType: contentType };
74
+ }
75
+ if (contentType === 'application/fhir+json') {
76
+ const bundle = JSON.parse(Buffer.from(opened.plaintext).toString('utf8'));
77
+ if (bundle?.resourceType !== 'Bundle')
78
+ throw new Error('channel_cache_bundle_required');
79
+ return { ...common, messageKind: 'clinical-document', bundle };
80
+ }
81
+ throw new Error('channel_cache_content_type_unsupported');
82
+ }
83
+ function cacheIdentity(input) {
84
+ const profileHash = digest(required(input.profileId, 'channel_cache_profile_required'));
85
+ const subjectHash = digest(required(input.subjectId, 'channel_cache_subject_required'));
86
+ const channelHash = digest(required(input.channelId, 'channel_cache_channel_required'));
87
+ return { id: digest(`${profileHash}\u0000${subjectHash}\u0000${channelHash}`), profileHash, subjectHash };
88
+ }
89
+ function digest(value) { return createHash('sha256').update(value, 'utf8').digest('hex'); }
90
+ function required(value, error) { const normalized = String(value || '').trim(); if (!normalized)
91
+ throw new Error(error); return normalized; }
92
+ function emptyChannel() { return { version: 1, messages: [] }; }
93
+ function compareMessages(left, right) { return String(left.sent || left.id).localeCompare(String(right.sent || right.id)); }
94
+ function protectedResult(value) {
95
+ if (!value || typeof value !== 'object' || !('jwe' in value))
96
+ throw new Error('channel_cache_protection_failed');
97
+ return value;
98
+ }
99
+ function openedResult(value) {
100
+ if (!value || typeof value !== 'object' || !('content' in value))
101
+ throw new Error('channel_cache_open_failed');
102
+ return value;
103
+ }
104
+ function channelContent(value) {
105
+ const candidate = value;
106
+ if (candidate?.version !== 1 || !Array.isArray(candidate.messages))
107
+ throw new Error('channel_cache_document_invalid');
108
+ return candidate;
109
+ }
@@ -0,0 +1,34 @@
1
+ import { type EncounterTransferOrchestrationInput } from 'sos-sdk-core-ts/encounter-transfer-communication';
2
+ export type AcceptedEncounterHandoverCommit = Readonly<{
3
+ idempotencyKey: string;
4
+ communicationThreadId: string;
5
+ projectionCommunication: Readonly<Record<string, unknown>>;
6
+ expectedProfessionalGroupReferences: readonly string[];
7
+ retainedProfessionalGroupReferences: readonly string[];
8
+ addedProfessionalGroupReferences: readonly string[];
9
+ removedProfessionalGroupReferences: readonly string[];
10
+ nextProfessionalGroupReferences: readonly string[];
11
+ familyJourneyEvent: Readonly<Record<string, unknown>>;
12
+ }>;
13
+ export type AcceptedEncounterHandoverReceipt = Readonly<{
14
+ idempotencyKey: string;
15
+ outcome: 'committed' | 'already-committed';
16
+ revision: string;
17
+ }>;
18
+ export type AcceptedEncounterHandoverPersistence = Readonly<{
19
+ /** Atomically persists every field or none; partial commits are forbidden. */
20
+ commitAcceptedHandover(commit: AcceptedEncounterHandoverCommit): Promise<AcceptedEncounterHandoverReceipt>;
21
+ }>;
22
+ /**
23
+ * Prepare and atomically persist one accepted Encounter handover.
24
+ *
25
+ * The injected adapter owns compare-and-swap/idempotency and must commit the
26
+ * provider projection, future professional audience and family-safe journey
27
+ * event as one unit. This service deliberately exposes no sequence of partial
28
+ * persistence callbacks. Authorization and service Group resolution occur in
29
+ * the product BFF before this call; historical Communications are untouched.
30
+ */
31
+ export declare function persistAcceptedEncounterHandover(input: EncounterTransferOrchestrationInput, persistence: AcceptedEncounterHandoverPersistence): Promise<Readonly<{
32
+ commit: AcceptedEncounterHandoverCommit;
33
+ receipt: AcceptedEncounterHandoverReceipt;
34
+ }>>;
@@ -0,0 +1,35 @@
1
+ import { prepareEncounterTransferOrchestration, } from 'sos-sdk-core-ts/encounter-transfer-communication';
2
+ /**
3
+ * Prepare and atomically persist one accepted Encounter handover.
4
+ *
5
+ * The injected adapter owns compare-and-swap/idempotency and must commit the
6
+ * provider projection, future professional audience and family-safe journey
7
+ * event as one unit. This service deliberately exposes no sequence of partial
8
+ * persistence callbacks. Authorization and service Group resolution occur in
9
+ * the product BFF before this call; historical Communications are untouched.
10
+ */
11
+ export async function persistAcceptedEncounterHandover(input, persistence) {
12
+ const plan = prepareEncounterTransferOrchestration(input);
13
+ const reconciliation = plan.audienceReconciliation;
14
+ const commit = Object.freeze({
15
+ idempotencyKey: reconciliation.idempotencyKey,
16
+ communicationThreadId: reconciliation.communicationThreadId,
17
+ projectionCommunication: Object.freeze(plan.projectionCommunication),
18
+ expectedProfessionalGroupReferences: Object.freeze([...input.currentProfessionalGroupReferences]),
19
+ retainedProfessionalGroupReferences: reconciliation.retainedProfessionalGroupReferences,
20
+ addedProfessionalGroupReferences: reconciliation.addedProfessionalGroupReferences,
21
+ removedProfessionalGroupReferences: reconciliation.removedProfessionalGroupReferences,
22
+ nextProfessionalGroupReferences: reconciliation.nextProfessionalGroupReferences,
23
+ familyJourneyEvent: reconciliation.familyJourneyEvent,
24
+ });
25
+ const receipt = await persistence.commitAcceptedHandover(commit);
26
+ if (receipt.idempotencyKey !== commit.idempotencyKey) {
27
+ throw new TypeError('encounter_handover_receipt_idempotency_mismatch');
28
+ }
29
+ if (receipt.outcome !== 'committed' && receipt.outcome !== 'already-committed') {
30
+ throw new TypeError('encounter_handover_receipt_outcome_invalid');
31
+ }
32
+ if (!String(receipt.revision || '').trim())
33
+ throw new TypeError('encounter_handover_receipt_revision_required');
34
+ return Object.freeze({ commit, receipt: Object.freeze({ ...receipt }) });
35
+ }
package/dist/index.d.ts CHANGED
@@ -2,4 +2,6 @@
2
2
  export * from './emergency-dispatch.js';
3
3
  export * from './prompt-audio-runtime.js';
4
4
  export * from './secure-message-crypto.js';
5
+ export * from './confidential-channel-cache.js';
5
6
  export * from './bundle-sync-service.js';
7
+ export * from './encounter-transfer-audience-service.js';
package/dist/index.js CHANGED
@@ -2,4 +2,6 @@
2
2
  export * from './emergency-dispatch.js';
3
3
  export * from './prompt-audio-runtime.js';
4
4
  export * from './secure-message-crypto.js';
5
+ export * from './confidential-channel-cache.js';
5
6
  export * from './bundle-sync-service.js';
7
+ export * from './encounter-transfer-audience-service.js';
@@ -0,0 +1,38 @@
1
+ # Accepted Encounter handover persistence
2
+
3
+ `persistAcceptedEncounterHandover(...)` is the product-neutral Node boundary
4
+ for one accepted patient movement. The product BFF authenticates the actor,
5
+ authorizes the transition and resolves exact source/destination
6
+ `HealthcareService` Group assignments before calling it.
7
+
8
+ The injected persistence adapter receives one indivisible commit containing:
9
+
10
+ - the completed Communication Bundle that projects both Encounter updates and
11
+ both EncounterHistory milestones;
12
+ - the expected current professional Groups and retained/added/removed/next
13
+ future audience;
14
+ - the family-safe journey event;
15
+ - one idempotency key and communication-thread identifier.
16
+
17
+ The adapter must compare-and-swap the expected audience and commit every field
18
+ or none. Retrying the same idempotency key returns `already-committed`. It must
19
+ not expose separate public callbacks that could persist the Encounter while
20
+ failing to update the channel, or vice versa.
21
+
22
+ ```ts
23
+ const persisted = await persistAcceptedEncounterHandover(
24
+ authorizedEncounterTransfer,
25
+ {
26
+ commitAcceptedHandover: commit => handoverRepository.commitAtomically(commit),
27
+ },
28
+ )
29
+
30
+ if (persisted.receipt.outcome === 'already-committed') {
31
+ return persisted.receipt
32
+ }
33
+ ```
34
+
35
+ Historical Communications and their recipient snapshots are never modified.
36
+ Subsequent Communications use `nextProfessionalGroupReferences`. The
37
+ family-facing channel receives only `familyJourneyEvent` after its independent
38
+ recipient and purpose checks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sos-sdk-node-ts",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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",
@@ -31,6 +31,14 @@
31
31
  "./bundle-sync-service": {
32
32
  "types": "./dist/bundle-sync-service.d.ts",
33
33
  "default": "./dist/bundle-sync-service.js"
34
+ },
35
+ "./encounter-transfer-audience-service": {
36
+ "types": "./dist/encounter-transfer-audience-service.d.ts",
37
+ "default": "./dist/encounter-transfer-audience-service.js"
38
+ },
39
+ "./confidential-channel-cache": {
40
+ "types": "./dist/confidential-channel-cache.d.ts",
41
+ "default": "./dist/confidential-channel-cache.js"
34
42
  }
35
43
  },
36
44
  "files": [
@@ -47,7 +55,7 @@
47
55
  "prepack": "npm run check"
48
56
  },
49
57
  "dependencies": {
50
- "sos-sdk-core-ts": "0.5.1"
58
+ "sos-sdk-core-ts": "0.5.2"
51
59
  },
52
60
  "devDependencies": {
53
61
  "@types/node": "^24.10.0",