sos-sdk-node-ts 0.1.4 → 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.
|
@@ -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",
|
|
@@ -31,6 +31,10 @@
|
|
|
31
31
|
"./bundle-sync-service": {
|
|
32
32
|
"types": "./dist/bundle-sync-service.d.ts",
|
|
33
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"
|
|
34
38
|
}
|
|
35
39
|
},
|
|
36
40
|
"files": [
|