sos-sdk-node-ts 0.1.3 → 0.1.5
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 +13 -0
- package/dist/bundle-sync-service.d.ts +46 -0
- package/dist/bundle-sync-service.js +82 -0
- package/dist/confidential-channel-cache.d.ts +73 -0
- package/dist/confidential-channel-cache.js +109 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/package.json +10 -2
package/README.md
CHANGED
|
@@ -26,6 +26,19 @@ persistence, jobs and prompt-audio runtime behavior. It does not own
|
|
|
26
26
|
deterministic data contracts, common authorization or sector policy. Vet, UHC
|
|
27
27
|
and SOSChain may consume it without depending on one another.
|
|
28
28
|
|
|
29
|
+
`BundleSyncDraftService` is the trusted-runtime adapter for the shared durable
|
|
30
|
+
editor outbox. The application injects protected storage, payload protection
|
|
31
|
+
and the real provider executor. The service stores no clear clinical Bundle,
|
|
32
|
+
reuses the same idempotent draft after transient failures, and does not report
|
|
33
|
+
confirmation until the provider write has been followed by authoritative
|
|
34
|
+
readback.
|
|
35
|
+
|
|
36
|
+
It also does not duplicate the typed clinical Bundle editors exposed by the
|
|
37
|
+
GDC Node SDK. Add a SOS Node adapter only when a real shared channel workflow
|
|
38
|
+
requires runtime behavior, following the
|
|
39
|
+
[SOS/Vet/UHC clinical accessor adoption matrix](https://github.com/Fundacion-UNID/sos-data-utils-ts/blob/main/docs/CLINICAL_TYPED_ACCESSOR_ADOPTION.md).
|
|
40
|
+
Portals and assistants without that editor remain on their verified versions.
|
|
41
|
+
|
|
29
42
|
The first adapter, `SosEmergencyDispatchClient`, sends an already-authorized
|
|
30
43
|
emergency request over HTTPS with a stable idempotency key and validates the
|
|
31
44
|
provider acknowledgement. The bearer credential remains only in the trusted
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type BundleSyncDraft, type BundleSyncPayload, type BundleSyncState } from 'sos-sdk-core-ts/bundle-sync-draft';
|
|
2
|
+
export type ProtectedBundleSyncRecord = Readonly<{
|
|
3
|
+
id: string;
|
|
4
|
+
kind: BundleSyncDraft['kind'];
|
|
5
|
+
subjectReference: string;
|
|
6
|
+
state: BundleSyncState;
|
|
7
|
+
protectedPayload: string;
|
|
8
|
+
createdAt: string;
|
|
9
|
+
updatedAt: string;
|
|
10
|
+
}>;
|
|
11
|
+
export interface BundleSyncRepository {
|
|
12
|
+
put(record: ProtectedBundleSyncRecord): Promise<void>;
|
|
13
|
+
get(id: string): Promise<ProtectedBundleSyncRecord | undefined>;
|
|
14
|
+
list(): Promise<readonly ProtectedBundleSyncRecord[]>;
|
|
15
|
+
}
|
|
16
|
+
export interface BundleSyncProtection {
|
|
17
|
+
protect(draft: BundleSyncDraft): Promise<string>;
|
|
18
|
+
unprotect(protectedPayload: string): Promise<BundleSyncDraft>;
|
|
19
|
+
}
|
|
20
|
+
export interface BundleSyncExecutor {
|
|
21
|
+
submit(draft: BundleSyncDraft): Promise<BundleSyncPayload>;
|
|
22
|
+
readback(draft: BundleSyncDraft): Promise<BundleSyncPayload>;
|
|
23
|
+
}
|
|
24
|
+
export type BundleSyncDraftServiceOptions = Readonly<{
|
|
25
|
+
repository: BundleSyncRepository;
|
|
26
|
+
protection: BundleSyncProtection;
|
|
27
|
+
executor: BundleSyncExecutor;
|
|
28
|
+
now?: () => Date;
|
|
29
|
+
}>;
|
|
30
|
+
/** Deterministic test adapter. Production runtimes inject durable storage. */
|
|
31
|
+
export declare class BundleSyncRepositoryMemory implements BundleSyncRepository {
|
|
32
|
+
#private;
|
|
33
|
+
put(record: ProtectedBundleSyncRecord): Promise<void>;
|
|
34
|
+
get(id: string): Promise<ProtectedBundleSyncRecord | undefined>;
|
|
35
|
+
list(): Promise<readonly ProtectedBundleSyncRecord[]>;
|
|
36
|
+
}
|
|
37
|
+
/** Protected outbox shared by clinical, Consent, contact and Encounter Bundles. */
|
|
38
|
+
export declare class BundleSyncDraftService {
|
|
39
|
+
private readonly options;
|
|
40
|
+
constructor(options: BundleSyncDraftServiceOptions);
|
|
41
|
+
enqueue(draft: BundleSyncDraft): Promise<BundleSyncDraft>;
|
|
42
|
+
read(id: string): Promise<BundleSyncDraft | undefined>;
|
|
43
|
+
process(id: string): Promise<BundleSyncDraft>;
|
|
44
|
+
private persist;
|
|
45
|
+
private now;
|
|
46
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Copyright 2026 Connecting Solution & Applications Ltd under the Apache License, Version 2.0.
|
|
2
|
+
import { BundleSyncStates, confirmBundleSyncDraft, markBundleSyncRetryable, reconcileBundleSyncSubmission, submitBundleSyncDraft, } from 'sos-sdk-core-ts/bundle-sync-draft';
|
|
3
|
+
/** Deterministic test adapter. Production runtimes inject durable storage. */
|
|
4
|
+
export class BundleSyncRepositoryMemory {
|
|
5
|
+
#records = new Map();
|
|
6
|
+
async put(record) {
|
|
7
|
+
this.#records.set(record.id, structuredClone(record));
|
|
8
|
+
}
|
|
9
|
+
async get(id) {
|
|
10
|
+
const record = this.#records.get(id);
|
|
11
|
+
return record ? structuredClone(record) : undefined;
|
|
12
|
+
}
|
|
13
|
+
async list() {
|
|
14
|
+
return [...this.#records.values()].map((record) => structuredClone(record));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Protected outbox shared by clinical, Consent, contact and Encounter Bundles. */
|
|
18
|
+
export class BundleSyncDraftService {
|
|
19
|
+
options;
|
|
20
|
+
constructor(options) {
|
|
21
|
+
this.options = options;
|
|
22
|
+
}
|
|
23
|
+
async enqueue(draft) {
|
|
24
|
+
const current = await this.options.repository.get(draft.id);
|
|
25
|
+
if (current) {
|
|
26
|
+
const restored = await this.options.protection.unprotect(current.protectedPayload);
|
|
27
|
+
if (JSON.stringify(restored.changesBundle) !== JSON.stringify(draft.changesBundle)) {
|
|
28
|
+
throw new Error('bundle_sync_idempotency_conflict');
|
|
29
|
+
}
|
|
30
|
+
return restored;
|
|
31
|
+
}
|
|
32
|
+
await this.persist(draft);
|
|
33
|
+
return draft;
|
|
34
|
+
}
|
|
35
|
+
async read(id) {
|
|
36
|
+
const record = await this.options.repository.get(id);
|
|
37
|
+
return record ? this.options.protection.unprotect(record.protectedPayload) : undefined;
|
|
38
|
+
}
|
|
39
|
+
async process(id) {
|
|
40
|
+
const draft = await this.read(id);
|
|
41
|
+
if (!draft)
|
|
42
|
+
throw new Error('bundle_sync_draft_not_found');
|
|
43
|
+
if (draft.state === BundleSyncStates.Confirmed || draft.state === BundleSyncStates.Rejected)
|
|
44
|
+
return draft;
|
|
45
|
+
const submitting = submitBundleSyncDraft(draft, this.now());
|
|
46
|
+
await this.persist(submitting);
|
|
47
|
+
try {
|
|
48
|
+
const response = await this.options.executor.submit(submitting);
|
|
49
|
+
const reconciled = reconcileBundleSyncSubmission(submitting, response, this.now());
|
|
50
|
+
await this.persist(reconciled);
|
|
51
|
+
if (reconciled.state !== BundleSyncStates.Reconciling)
|
|
52
|
+
return reconciled;
|
|
53
|
+
const authoritativeBundle = await this.options.executor.readback(reconciled);
|
|
54
|
+
const confirmed = confirmBundleSyncDraft(reconciled, authoritativeBundle, this.now());
|
|
55
|
+
await this.persist(confirmed);
|
|
56
|
+
return confirmed;
|
|
57
|
+
}
|
|
58
|
+
catch (reason) {
|
|
59
|
+
const retryable = markBundleSyncRetryable(submitting, [classify(reason)], this.now());
|
|
60
|
+
await this.persist(retryable);
|
|
61
|
+
return retryable;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async persist(draft) {
|
|
65
|
+
await this.options.repository.put({
|
|
66
|
+
id: draft.id,
|
|
67
|
+
kind: draft.kind,
|
|
68
|
+
subjectReference: draft.subjectReference,
|
|
69
|
+
state: draft.state,
|
|
70
|
+
protectedPayload: await this.options.protection.protect(draft),
|
|
71
|
+
createdAt: draft.createdAt,
|
|
72
|
+
updatedAt: draft.updatedAt,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
now() {
|
|
76
|
+
return (this.options.now?.() ?? new Date()).toISOString();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function classify(reason) {
|
|
80
|
+
const message = reason instanceof Error ? reason.message : String(reason || 'bundle_sync_failed');
|
|
81
|
+
return message.slice(0, 300);
|
|
82
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sos-sdk-node-ts",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
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",
|
|
@@ -27,6 +27,14 @@
|
|
|
27
27
|
"./secure-message-crypto": {
|
|
28
28
|
"types": "./dist/secure-message-crypto.d.ts",
|
|
29
29
|
"default": "./dist/secure-message-crypto.js"
|
|
30
|
+
},
|
|
31
|
+
"./bundle-sync-service": {
|
|
32
|
+
"types": "./dist/bundle-sync-service.d.ts",
|
|
33
|
+
"default": "./dist/bundle-sync-service.js"
|
|
34
|
+
},
|
|
35
|
+
"./confidential-channel-cache": {
|
|
36
|
+
"types": "./dist/confidential-channel-cache.d.ts",
|
|
37
|
+
"default": "./dist/confidential-channel-cache.js"
|
|
30
38
|
}
|
|
31
39
|
},
|
|
32
40
|
"files": [
|
|
@@ -43,7 +51,7 @@
|
|
|
43
51
|
"prepack": "npm run check"
|
|
44
52
|
},
|
|
45
53
|
"dependencies": {
|
|
46
|
-
"sos-sdk-core-ts": "0.
|
|
54
|
+
"sos-sdk-core-ts": "0.5.1"
|
|
47
55
|
},
|
|
48
56
|
"devDependencies": {
|
|
49
57
|
"@types/node": "^24.10.0",
|