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 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.5",
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.1"
62
+ "sos-sdk-core-ts": "0.5.2"
55
63
  },
56
64
  "devDependencies": {
57
65
  "@types/node": "^24.10.0",