Skip to content

Commit c237e67

Browse files
committed
v0.8.0 — system stream MVP + appTemplates existingStreamRefs (plan 45)
CollectorRequest: new top-level existingStreamRefs[] (Plan 45 §2.9 mode-3) for requesting access on pre-existing streams without provisioning. addExistingStreamRef() validates input. Type ExistingStreamRef exported. CollectorClient.accept(): processes existingStreamRefs[] — bootstrap-provisions app-system / app-system-out / app-system-in streams (idempotent), appends requested permissions to the granted access, surfaces system-stream wiring on responseContent.system = { streamOut?, streamIn? }. CollectorInvite: hasSystem getter, systemSettings, systemPostAlert(), systemPollAcks(), systemEventInfos(). Posts message/system-alert and reads message/system-ack. Collector.checkInbox(): also propagates responseEvent.content.system to the operator's invite content (was only copying chat). Without this the operator's hasSystem stayed false even when the patient's accept flow surfaced the system wiring — discovered during the smoke test, end-to-end verified programmatically. Tests: 8 new in apptemplatesRequest.test.js covering existingStreamRefs validation, serialization, mixed-mode requests. Requires data-model ≥ 1.6.0.
1 parent 9106a97 commit c237e67

8 files changed

Lines changed: 314 additions & 5 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ Every value/type intended for external consumption is exported from [`ts/index.t
9797

9898
- **`tsc` stale output**: `tsc` may skip re-emitting a `.js` file if it already exists and didn't notice an internal change. After `npm run prepare` / `npm run build:ts`, **verify the relevant file in `js/` actually contains your change** before committing. If not, delete the stale `js/<path>.js` and re-run.
9999
- **Vite cache in consumer apps**: when you change a public type or export and a consumer app (Vite-based) doesn't pick it up, clear `<consumer>/node_modules/.vite` and restart the dev server.
100-
- **npm-link traps**: when an HDS app is npm-linked to `hds-lib`, both the app and any nested package (e.g. `hds-forms-js`'s [`src-test-app/`](https://github.com/healthdatasafe/hds-forms-js/tree/main/src-test-app)) must link the **same** `hds-lib` to avoid duplicate singletons (settings, model). If you see "two HDSModels" symptoms (e.g. `getHDSModel()` returning empty), this is the cause. **Run `cd _local && npm run check-links && npm run verify-live-source`** to diagnose. Full methodology: `_macro/_claude-memory/conventions.md § Live cross-repo development — quickstart` + `_plans/49-local-dev-dependency-graph-study/PLAN.md`.
100+
- **npm-link traps**: when an HDS app is npm-linked to `hds-lib`, both the app and any nested package (e.g. `hds-forms-js`'s [`src-test-app/`](https://github.com/healthdatasafe/hds-forms-js/tree/main/src-test-app)) must link the **same** `hds-lib` to avoid duplicate singletons (settings, model). If you see "two HDSModels" symptoms (e.g. `getHDSModel()` returning empty), this is the cause. **Run `cd _local && npm run check-links && npm run verify-live-source`** to diagnose. Full methodology: `_claude-memory/conventions.md § Live cross-repo development — quickstart` + `_plans/49-local-dev-dependency-graph-done/PLAN.md`.
101101
- **`exports.import` MUST point at TS source** (`./ts/index.ts` here). Vite resolves the `import` condition in dev mode; pointing at compiled JS causes downstream libs (hds-forms-js, hds-react-timeline) to inline a second copy of `hds-lib` → duplicate-singleton bug. Verify with `cd _local && npm run verify-live-source`.
102102
- **Initialization order**: in apps that call `initBoiler(name, configDir)` (server side) or `pryv.Browser.AuthController` (client side), do so **before** any `getHDSModel()` / `HDSSettings` lookup.
103103

CHANGELOG.md

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,41 @@
22

33
## [Unreleased]
44

5+
## [0.8.0] - 2026-04-28
6+
7+
### Added — Plan 45 Phase 3a (system stream MVP slice)
8+
9+
#### `CollectorRequest` (`ts/appTemplates/CollectorRequest.ts`)
10+
- New top-level field `existingStreamRefs[]` (Plan 45 §2.9 mode-3): request access on pre-existing streams without provisioning. Each ref carries `{ streamId, permissions: ('read'|'manage'|'contribute')[], purpose? }`.
11+
- New methods: `addExistingStreamRef(ref)` with input validation (rejects non-string streamId, empty permissions, unknown permission levels) and `existingStreamRefs` getter.
12+
- `content` getter now serializes `existingStreamRefs[]` when non-empty (omitted otherwise — backwards-compatible).
13+
- New exported type: `ExistingStreamRef`.
14+
15+
#### `CollectorClient` (`ts/appTemplates/CollectorClient.ts`)
16+
- `accept()` now processes `requestData.existingStreamRefs[]` after the existing chat-stream block:
17+
1. **Bootstrap-provision** `app-system` / `app-system-out` / `app-system-in` streams if a ref points at the system pair and the streams don't exist yet. The new streams carry `clientData.hdsSystemFeature` declaring `message/system-alert` (on `out`) and `message/system-ack` (on `in`). Idempotent — `item-already-exists` errors from `streams.create` are tolerated.
18+
2. Append the requested permissions to the access being granted.
19+
3. Surface system-stream wiring on `responseContent.system = { streamOut?, streamIn? }` when `app-system-*` refs are present.
20+
21+
#### `CollectorInvite` (`ts/appTemplates/CollectorInvite.ts`)
22+
- New properties: `hasSystem`, `systemSettings` (returns `{ streamOut?, streamIn? }`).
23+
- New methods (mirroring `chatPost` / `chatEventInfos`):
24+
- `systemPostAlert({ level, title, body, ackRequired?, ackId? })` — posts a `message/system-alert` to `streamOut`. Auto-generates a UUID `ackId` when `ackRequired: true` and none supplied.
25+
- `systemPollAcks({ ackId?, limit? })` — fetches `message/system-ack` events from `streamIn`, optionally filtered by `ackId`. Returns events sorted ascending by creation time.
26+
- `systemEventInfos(event)` — identifies the source of a system event (`'me'`/`'user'`/`'unknown'`).
27+
28+
### Changed — `package.json.exports.import` → TS source (Plan 49)
29+
- `exports[.].import` switched from `./js/index.js` (compiled JS) to `./ts/index.ts` (TS source). Added wildcard `./js/*` subpath export. `default` still points at compiled JS for non-Vite/CJS consumers.
30+
- This brings hds-lib in line with `_claude-memory/conventions.md § Package exports: TS source for bundlers` and is the prerequisite for live cross-repo dev (the previous `js/index.js` import path created duplicate-singleton bugs when downstream libs like hds-forms-js re-imported `hds-lib` via `require()`). See `_plans/49-local-dev-dependency-graph-done/PLAN.md` for the full rationale.
31+
32+
### Notes
33+
- The Plan-45 Phase 3a additions are the **MVP slice** of system-stream support. Full Phase 3 work (custom-fields helpers `resolveStreamCustomField` + `streamCustomFieldToVirtualItem`, the appTemplates loader with Ajv schema, sandbox-prefix enforcement) lands in a follow-up commit.
34+
- Designed to be consumed by:
35+
- **`doctor-dashboard`** Phase 6a — operator UI calling `invite.systemPostAlert(...)` and `invite.systemPollAcks(...)`.
36+
- **`hds-webapp`** Phase 7a — patient-side inbox provisioning the `app-system/*` streams at boot (or relying on `CollectorClient`'s bootstrap fallback) and rendering alerts with an Acknowledge button that posts `message/system-ack`.
37+
- Requires `data-model` ≥ 1.4.0 (the new `message/system-alert` + `message/system-ack` event types).
38+
- See `_plans/45-custom-fields-appTemplates-paused/spec.md` for the locked design.
39+
540
## [0.7.2] - 2026-04-28
641

742
### Added — runtime support for `deprecated: true` itemDefs (Plan 50)
@@ -22,7 +57,7 @@ Contract documented in `data-model/AGENTS.md § "deprecated: true on items"`.
2257

2358
**Why.** Vite resolves the `import` condition in dev mode. With `import` pointing at compiled JS, live edits to `ts/*.ts` weren't reflected in npm-linked consumers without rebuilding hds-lib-js. Pointing `import` at TS source enables true live cross-repo development. This also avoids the duplicate-singleton bug surfaced by Plan 45 (a downstream lib's pre-built bundle inlining a second copy of `hds-lib`'s `HDSModel`). Production builds and CJS consumers are unaffected (still hit `default`).
2459

25-
Brings `hds-lib` in line with `_claude-memory/conventions.md § Package exports: TS source for bundlers`. See `_plans/49-local-dev-dependency-graph-study/PLAN.md` for the full rationale.
60+
Brings `hds-lib` in line with `_claude-memory/conventions.md § Package exports: TS source for bundlers`. See `_plans/49-local-dev-dependency-graph-done/PLAN.md` for the full rationale.
2661

2762
## [0.7.0] - 2026-04-27
2863

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hds-lib",
3-
"version": "0.7.2",
3+
"version": "0.8.0",
44
"description": "Health Data Safe - Library",
55
"type": "module",
66
"engines": {

tests/apptemplatesRequest.test.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,4 +304,78 @@ describe('[APRX] appTemplates Requests', function () {
304304
};
305305
assert.deepEqual(requestContent, expectedContent);
306306
});
307+
308+
describe('[APRES] CollectorRequest existingStreamRefs (Plan 45)', function () {
309+
it('[ARES1] should parse existingStreamRefs from content', () => {
310+
const request = new CollectorRequest({
311+
existingStreamRefs: [
312+
{ streamId: 'app-system-out', permissions: ['manage'], purpose: 'system-out' },
313+
{ streamId: 'app-system-in', permissions: ['read'], purpose: 'system-in' }
314+
]
315+
});
316+
assert.equal(request.existingStreamRefs.length, 2);
317+
assert.equal(request.existingStreamRefs[0].streamId, 'app-system-out');
318+
assert.deepEqual(request.existingStreamRefs[0].permissions, ['manage']);
319+
assert.equal(request.existingStreamRefs[1].streamId, 'app-system-in');
320+
});
321+
322+
it('[ARES2] should serialize existingStreamRefs in content', () => {
323+
const request = new CollectorRequest({});
324+
request.addExistingStreamRef({ streamId: 'app-system-out', permissions: ['manage'] });
325+
assert.ok(request.content.existingStreamRefs);
326+
assert.equal(request.content.existingStreamRefs.length, 1);
327+
assert.equal(request.content.existingStreamRefs[0].streamId, 'app-system-out');
328+
});
329+
330+
it('[ARES3] should not serialize existingStreamRefs when empty', () => {
331+
const request = new CollectorRequest({});
332+
assert.equal(request.content.existingStreamRefs, undefined);
333+
});
334+
335+
it('[ARES4] should reject non-string streamId', () => {
336+
const request = new CollectorRequest({});
337+
try {
338+
request.addExistingStreamRef({ streamId: 123, permissions: ['read'] });
339+
throw new Error('Should throw error');
340+
} catch (e) {
341+
assert.match(e.message, /streamId must be a non-empty string/);
342+
}
343+
});
344+
345+
it('[ARES5] should reject empty permissions array', () => {
346+
const request = new CollectorRequest({});
347+
try {
348+
request.addExistingStreamRef({ streamId: 'app-system-out', permissions: [] });
349+
throw new Error('Should throw error');
350+
} catch (e) {
351+
assert.match(e.message, /permissions must be a non-empty array/);
352+
}
353+
});
354+
355+
it('[ARES6] should reject invalid permission level', () => {
356+
const request = new CollectorRequest({});
357+
try {
358+
request.addExistingStreamRef({ streamId: 'app-system-out', permissions: ['admin'] });
359+
throw new Error('Should throw error');
360+
} catch (e) {
361+
assert.match(e.message, /Invalid permission level "admin"/);
362+
}
363+
});
364+
365+
it('[ARES7] should accept all three permission levels', () => {
366+
const request = new CollectorRequest({});
367+
request.addExistingStreamRef({ streamId: 's1', permissions: ['read'] });
368+
request.addExistingStreamRef({ streamId: 's2', permissions: ['manage'] });
369+
request.addExistingStreamRef({ streamId: 's3', permissions: ['contribute'] });
370+
assert.equal(request.existingStreamRefs.length, 3);
371+
});
372+
373+
it('[ARES8] should round-trip through setContent', () => {
374+
const r1 = new CollectorRequest({});
375+
r1.addExistingStreamRef({ streamId: 'app-system-out', permissions: ['manage'], purpose: 'system' });
376+
const content1 = r1.content;
377+
const r2 = new CollectorRequest(content1);
378+
assert.deepEqual(r2.existingStreamRefs, r1.existingStreamRefs);
379+
});
380+
});
307381
});

ts/appTemplates/Collector.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,12 +219,14 @@ export class Collector {
219219
(updateInvite as any).streamIds = [this.streamIdFor(Collector.STREAMID_SUFFIXES.active)];
220220
updateInvite.content.apiEndpoint = responseEvent.content.apiEndpoint;
221221
if (responseEvent.content.chat) updateInvite.content.chat = responseEvent.content.chat;
222+
if (responseEvent.content.system) updateInvite.content.system = responseEvent.content.system;
222223
break;
223224
case 'update-accept':
224225
// Patient accepted an access update — new apiEndpoint, stays active
225226
(updateInvite as any).streamIds = [this.streamIdFor(Collector.STREAMID_SUFFIXES.active)];
226227
updateInvite.content.apiEndpoint = responseEvent.content.apiEndpoint;
227228
if (responseEvent.content.chat) updateInvite.content.chat = responseEvent.content.chat;
229+
if (responseEvent.content.system) updateInvite.content.system = responseEvent.content.system;
228230
break;
229231
case 'update-refuse':
230232
// Patient refused the update — invite stays active with current permissions

ts/appTemplates/CollectorClient.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ export class CollectorClient {
196196
* @param {boolean} forceAndSkipAccessCreation - internal temporary option,
197197
*/
198198
async accept (forceAndSkipAccessCreation = false) {
199-
const responseContent: { apiEndpoint?: string, chat?: any } = {};
199+
const responseContent: { apiEndpoint?: string, chat?: any, system?: any } = {};
200200
if (this.accessData && this.accessData.deleted == null && this.status !== 'Active') {
201201
forceAndSkipAccessCreation = true;
202202
logger.error('CollectorClient.accept TODO fix accept when access valid');
@@ -240,6 +240,72 @@ export class CollectorClient {
240240
// ---------- end chat ---------- //
241241
}
242242

243+
// ------------- existingStreamRefs (Plan 45 mode-3) ------------------------ //
244+
const existingStreamRefs: Array<{ streamId: string, permissions: string[], purpose?: string }> =
245+
this.requestData.existingStreamRefs || [];
246+
if (existingStreamRefs.length > 0) {
247+
// 1. Bootstrap-provision `app-system-*` streams if any ref points there and they don't yet exist.
248+
// (Defensive — `hds-webapp` provisions them at account setup; this safety net handles users
249+
// reaching HDS via an invite without ever opening hds-webapp first.)
250+
const needsAppSystem = existingStreamRefs.some((r) =>
251+
r.streamId === 'app-system-out' || r.streamId === 'app-system-in'
252+
);
253+
if (needsAppSystem) {
254+
const appSystemBootstrap = [
255+
{ method: 'streams.create', params: { name: 'System', id: 'app-system' } },
256+
{
257+
method: 'streams.create',
258+
params: {
259+
name: 'System out',
260+
id: 'app-system-out',
261+
parentId: 'app-system',
262+
clientData: {
263+
hdsSystemFeature: {
264+
'message/system-alert': { version: 'v1', levels: ['info', 'warning', 'critical'] }
265+
}
266+
}
267+
}
268+
},
269+
{
270+
method: 'streams.create',
271+
params: {
272+
name: 'System in',
273+
id: 'app-system-in',
274+
parentId: 'app-system',
275+
clientData: {
276+
hdsSystemFeature: {
277+
'message/system-ack': { version: 'v1' }
278+
}
279+
}
280+
}
281+
}
282+
];
283+
const bootstrapResults = await this.app.connection.api(appSystemBootstrap);
284+
bootstrapResults.forEach((r) => {
285+
if (r.stream?.id || r.error?.id === 'item-already-exists') return;
286+
throw new HDSLibError('Failed bootstrapping app-system streams', bootstrapResults);
287+
});
288+
}
289+
290+
// 2. Append the requested permissions to the access being granted.
291+
for (const ref of existingStreamRefs) {
292+
for (const level of ref.permissions) {
293+
cleanedPermissions.push({ streamId: ref.streamId, level });
294+
}
295+
}
296+
297+
// 3. Surface system-stream wiring on responseContent.system if app-system-* refs are present.
298+
const outRef = existingStreamRefs.find((r) => r.streamId === 'app-system-out');
299+
const inRef = existingStreamRefs.find((r) => r.streamId === 'app-system-in');
300+
if (outRef || inRef) {
301+
responseContent.system = {
302+
...(outRef ? { streamOut: 'app-system-out' } : {}),
303+
...(inRef ? { streamIn: 'app-system-in' } : {})
304+
};
305+
}
306+
}
307+
// ---------- end existingStreamRefs ---------- //
308+
243309
const accessCreateData = {
244310
name: this.key,
245311
type: 'shared',

ts/appTemplates/CollectorInvite.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,21 @@ import { pryv } from '../patchedPryv.ts';
22
import { HDSLibError } from '../errors.ts';
33
import type { ContactSource } from './interfaces.ts';
44

5+
/** Generate a v4-ish UUID. Uses crypto.randomUUID() when available, falls back to a manual impl. */
6+
function newUuid (): string {
7+
if (typeof globalThis.crypto?.randomUUID === 'function') return globalThis.crypto.randomUUID();
8+
// RFC4122-ish v4 fallback
9+
const hex = '0123456789abcdef';
10+
let s = '';
11+
for (let i = 0; i < 36; i++) {
12+
if (i === 8 || i === 13 || i === 18 || i === 23) s += '-';
13+
else if (i === 14) s += '4';
14+
else if (i === 19) s += hex[(Math.random() * 4 | 0) + 8];
15+
else s += hex[Math.random() * 16 | 0];
16+
}
17+
return s;
18+
}
19+
520
/**
621
* Collector Invite
722
* There is one Collector Invite per Collector => Enduser connection
@@ -75,6 +90,73 @@ export class CollectorInvite {
7590
return await this.connection.apiOne('events.create', newEvent, 'event');
7691
}
7792

93+
// -------------------- system stream (Plan 45) ----------------- //
94+
/** Whether this invite has system-stream access (operator → user alerts + user → operator acks). */
95+
get hasSystem (): boolean {
96+
return this.eventData.content.system != null;
97+
}
98+
99+
/** Stream wiring for the system feature. streamOut is the operator's write target; streamIn is read. */
100+
get systemSettings (): { streamOut?: string, streamIn?: string } {
101+
return this.eventData.content.system || {};
102+
}
103+
104+
/** Identify the source of a system event. */
105+
systemEventInfos (event: pryv.Event): { source: 'me' | 'user' | 'unknown' } {
106+
const s = this.systemSettings;
107+
if (s.streamOut && event.streamIds.includes(s.streamOut)) return { source: 'me' };
108+
if (s.streamIn && event.streamIds.includes(s.streamIn)) return { source: 'user' };
109+
return { source: 'unknown' };
110+
}
111+
112+
/**
113+
* Post a system alert to the user. Generates ackId if ackRequired and not provided.
114+
* Requires `hasSystem === true` and `systemSettings.streamOut` (manage permission).
115+
*/
116+
async systemPostAlert (alert: {
117+
level: 'info' | 'warning' | 'critical',
118+
title: string,
119+
body: string,
120+
ackRequired?: boolean,
121+
ackId?: string
122+
}): Promise<pryv.Event> {
123+
const s = this.systemSettings;
124+
if (!s.streamOut) throw new HDSLibError('No system streamOut on this invite');
125+
const content: any = { level: alert.level, title: alert.title, body: alert.body };
126+
if (alert.ackRequired) {
127+
content.ackRequired = true;
128+
content.ackId = alert.ackId || newUuid();
129+
} else if (alert.ackId) {
130+
content.ackId = alert.ackId;
131+
}
132+
const newEvent = {
133+
type: 'message/system-alert',
134+
streamIds: [s.streamOut],
135+
content
136+
};
137+
return await this.connection.apiOne('events.create', newEvent, 'event');
138+
}
139+
140+
/**
141+
* Read system-ack events for this invite, optionally filtered by alert ackId.
142+
* Returns events sorted ascending by `created`.
143+
*/
144+
async systemPollAcks (filter: { ackId?: string, limit?: number } = {}): Promise<pryv.Event[]> {
145+
const s = this.systemSettings;
146+
if (!s.streamIn) throw new HDSLibError('No system streamIn on this invite');
147+
const params: any = {
148+
streams: [s.streamIn],
149+
types: ['message/system-ack'],
150+
limit: filter.limit ?? 100,
151+
sortAscending: true
152+
};
153+
const events = await this.connection.apiOne('events.get', params, 'events');
154+
if (filter.ackId) {
155+
return (events as pryv.Event[]).filter((e: any) => e.content?.ackId === filter.ackId);
156+
}
157+
return events as pryv.Event[];
158+
}
159+
78160
/**
79161
* Check if connection is valid. (only if active)
80162
* If result is "forbidden" update and set as revoked

0 commit comments

Comments
 (0)