sos-sdk-node-ts 0.1.5 → 0.1.7
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 +10 -0
- package/dist/bff-email-assertion.d.ts +33 -0
- package/dist/bff-email-assertion.js +113 -0
- package/dist/encounter-transfer-audience-service.d.ts +34 -0
- package/dist/encounter-transfer-audience-service.js +35 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/docs/ENCOUNTER_TRANSFER_AUDIENCE_PERSISTENCE.md +38 -0
- package/package.json +10 -2
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
|
|
@@ -44,6 +48,12 @@ emergency request over HTTPS with a stable idempotency key and validates the
|
|
|
44
48
|
provider acknowledgement. The bearer credential remains only in the trusted
|
|
45
49
|
Node runtime.
|
|
46
50
|
|
|
51
|
+
`sos-sdk-node-ts/bff-email-assertion` lets one authenticated BFF present a
|
|
52
|
+
normalized email to one exact receiving BFF for a short-lived, named purpose.
|
|
53
|
+
The receiving application still authorizes the operation and should reject
|
|
54
|
+
replay where it matters. Never expose the shared secret or let a browser set
|
|
55
|
+
the trusted issuer, audience, purpose or identity claims.
|
|
56
|
+
|
|
47
57
|
## Secure-message CSPRNG adapter
|
|
48
58
|
|
|
49
59
|
`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
|
+
}
|
|
@@ -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
|
@@ -4,3 +4,5 @@ export * from './prompt-audio-runtime.js';
|
|
|
4
4
|
export * from './secure-message-crypto.js';
|
|
5
5
|
export * from './confidential-channel-cache.js';
|
|
6
6
|
export * from './bundle-sync-service.js';
|
|
7
|
+
export * from './encounter-transfer-audience-service.js';
|
|
8
|
+
export * from './bff-email-assertion.js';
|
package/dist/index.js
CHANGED
|
@@ -4,3 +4,5 @@ export * from './prompt-audio-runtime.js';
|
|
|
4
4
|
export * from './secure-message-crypto.js';
|
|
5
5
|
export * from './confidential-channel-cache.js';
|
|
6
6
|
export * from './bundle-sync-service.js';
|
|
7
|
+
export * from './encounter-transfer-audience-service.js';
|
|
8
|
+
export * from './bff-email-assertion.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.
|
|
3
|
+
"version": "0.1.7",
|
|
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",
|
|
@@ -32,9 +32,17 @@
|
|
|
32
32
|
"types": "./dist/bundle-sync-service.d.ts",
|
|
33
33
|
"default": "./dist/bundle-sync-service.js"
|
|
34
34
|
},
|
|
35
|
+
"./encounter-transfer-audience-service": {
|
|
36
|
+
"types": "./dist/encounter-transfer-audience-service.d.ts",
|
|
37
|
+
"default": "./dist/encounter-transfer-audience-service.js"
|
|
38
|
+
},
|
|
35
39
|
"./confidential-channel-cache": {
|
|
36
40
|
"types": "./dist/confidential-channel-cache.d.ts",
|
|
37
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"
|
|
38
46
|
}
|
|
39
47
|
},
|
|
40
48
|
"files": [
|
|
@@ -51,7 +59,7 @@
|
|
|
51
59
|
"prepack": "npm run check"
|
|
52
60
|
},
|
|
53
61
|
"dependencies": {
|
|
54
|
-
"sos-sdk-core-ts": "0.5.
|
|
62
|
+
"sos-sdk-core-ts": "0.5.2"
|
|
55
63
|
},
|
|
56
64
|
"devDependencies": {
|
|
57
65
|
"@types/node": "^24.10.0",
|