Skip to content

Commit 6f5d569

Browse files
committed
feat(plan-59 Phase 5a/5b): FormSpec helpers + Contact.hdsFormSpec passthrough
New module ts/cmc/formSpec.ts (FormSpec brief Candidate C — Q-F1..F5 locks): - FormSpec interface: title, description, consent, permissions, sections, features, customFields, existingStreamRefs, appCustomData. - saveFormSpec / loadFormSpec — doctor-side canonical template event (hds:form-spec-v1) on the :_cmc:apps:hds-collector:<id> scope stream. - stampFormSpecOnTriggerEvent — doctor-side post-createInvite snapshot patch onto the consent/request-cmc trigger event (Q-F2 + Q-F1 locks: read-merge-write since events.update replaces content wholesale). - mirrorFormSpecOnAcceptEvent — patient-side post-acceptInvite mirror onto own consent/accept-cmc event so the snapshot survives the capability consumption (the doctor's trigger event is unreachable from the patient post-accept). - readOfferWithFormSpec — patient-side pre-accept full-content offer read (the SDK's cmc.readOffer filters out HDS-extension fields). - deriveCmcPermissions — chat-only handling. Injects :hds:noop placeholder when FormSpec.permissions is empty (Q-F4 lock). - provisionHdsNoop — patient-side stream provisioning (Q-F3: patient-only). - isChatOnlyFormSpec — doctor-side detection helper. Contact.ts: - CmcAcceptedRelationship.hdsFormSpec: FormSpec | null — consumer fetches raw accept events and surfaces content.hdsFormSpec here. - CmcRelationship.hdsFormSpec: FormSpec | null — aggregator pass-through. - Contact.cmcFormSpecs: aggregated specs across relationships. - Contact.cmcFormSections: aggregated AppTemplateSection[] for the Tasks-page recurring-task loop (consumer-compatible with legacy CollectorSectionInterface). Tests: 12 new specs across [CFSC]/[CFSD]/[CFSI]/[CTFS] — constants, derive, chat-only detection, hdsFormSpec pass-through, aggregator section merge. 501 → 513/513 tests pass; tsc clean. Index export: cmcFormSpec module + FormSpec type.
1 parent 378c77c commit 6f5d569

4 files changed

Lines changed: 461 additions & 3 deletions

File tree

tests/cmcFormSpec.test.js

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { assert } from './test-utils/deps-node.js';
2+
import * as cmcFormSpec from '../ts/cmc/formSpec.ts';
3+
import { Contact } from '../ts/appTemplates/Contact.ts';
4+
5+
/**
6+
* Unit tests for the Plan 59 Phase 5a FormSpec helpers + the
7+
* `hdsFormSpec` pass-through on Contact.aggregateCmc.
8+
*
9+
* I/O paths (saveFormSpec / loadFormSpec / mirror) are integration-tested
10+
* against a live api-server in the Phase 6 demo iteration loop.
11+
*/
12+
13+
describe('[CFSP] cmcFormSpec helpers', function () {
14+
function basicSpec (overrides = {}) {
15+
return {
16+
version: 1,
17+
title: { en: 'Hello World' },
18+
description: { en: 'desc' },
19+
consent: { en: 'I consent' },
20+
permissions: [{ streamId: 'health', level: 'read' }],
21+
sections: [{ key: 'sleep-section', type: 'recurring', name: { en: 'Sleep' }, itemKeys: ['sleep-duration'] }],
22+
...overrides
23+
};
24+
}
25+
26+
describe('[CFSC] constants', function () {
27+
it('[CFS01] FORM_SPEC_EVENT_TYPE matches the locked Q-F5 name', () => {
28+
assert.equal(cmcFormSpec.FORM_SPEC_EVENT_TYPE, 'hds:form-spec-v1');
29+
});
30+
31+
it('[CFS02] HDS_NOOP_STREAM_ID + permission match the brief', () => {
32+
assert.equal(cmcFormSpec.HDS_NOOP_STREAM_ID, ':hds:noop');
33+
assert.deepEqual(cmcFormSpec.HDS_NOOP_PERMISSION, { streamId: ':hds:noop', level: 'read' });
34+
});
35+
});
36+
37+
describe('[CFSD] deriveCmcPermissions', function () {
38+
it('[CFS10] returns the FormSpec permissions when non-empty', () => {
39+
const spec = basicSpec();
40+
const out = cmcFormSpec.deriveCmcPermissions(spec);
41+
assert.deepEqual(out, [{ streamId: 'health', level: 'read' }]);
42+
});
43+
44+
it('[CFS11] injects :hds:noop placeholder when permissions empty', () => {
45+
const spec = basicSpec({ permissions: [] });
46+
const out = cmcFormSpec.deriveCmcPermissions(spec);
47+
assert.equal(out.length, 1);
48+
assert.equal(out[0].streamId, ':hds:noop');
49+
assert.equal(out[0].level, 'read');
50+
});
51+
52+
it('[CFS12] filters out malformed permission entries before deciding empty', () => {
53+
const spec = basicSpec({ permissions: [{ streamId: '', level: 'read' }, null, { streamId: 'x' /* no level */ }] });
54+
const out = cmcFormSpec.deriveCmcPermissions(spec);
55+
// All filtered → placeholder injected
56+
assert.equal(out.length, 1);
57+
assert.equal(out[0].streamId, ':hds:noop');
58+
});
59+
});
60+
61+
describe('[CFSI] isChatOnlyFormSpec', function () {
62+
it('[CFS20] true on empty / placeholder-only specs', () => {
63+
assert.equal(cmcFormSpec.isChatOnlyFormSpec(basicSpec({ permissions: [] })), true);
64+
});
65+
it('[CFS21] false when real permissions present', () => {
66+
assert.equal(cmcFormSpec.isChatOnlyFormSpec(basicSpec()), false);
67+
});
68+
it('[CFS22] false for null / undefined input', () => {
69+
assert.equal(cmcFormSpec.isChatOnlyFormSpec(null), false);
70+
assert.equal(cmcFormSpec.isChatOnlyFormSpec(undefined), false);
71+
});
72+
});
73+
});
74+
75+
describe('[CTFS] Contact.aggregateCmc hdsFormSpec pass-through', function () {
76+
const SCOPE = ':_cmc:apps:hds-patient';
77+
78+
function counterpartyAccess (overrides = {}) {
79+
const cp = {
80+
username: 'drandy',
81+
host: 'demo.datasafe.dev',
82+
apiEndpoint: 'https://abctoken@drandy.demo.datasafe.dev/',
83+
remoteChatStreamId: 'r1',
84+
remoteCollectorStreamId: 'c1'
85+
};
86+
return {
87+
id: overrides.id || 'acc-1',
88+
permissions: [{ streamId: 'health', level: 'read' }],
89+
clientData: {
90+
cmc: {
91+
role: 'counterparty',
92+
appCode: 'hds-collector',
93+
features: { chat: true, systemMessaging: true },
94+
counterparty: cp
95+
}
96+
},
97+
...overrides
98+
};
99+
}
100+
101+
function acceptEvent (overrides = {}) {
102+
return {
103+
acceptEventId: overrides.acceptEventId || 'evt-1',
104+
counterparty: { username: 'drandy', host: 'demo.datasafe.dev' },
105+
appCode: 'hds-collector',
106+
scopeStreamId: SCOPE,
107+
acceptedAt: 1716000000,
108+
features: { chat: true, systemMessaging: true },
109+
...overrides
110+
};
111+
}
112+
113+
it('[CTFS1] surfaces hdsFormSpec from the matching accept event', () => {
114+
const spec = {
115+
version: 1,
116+
title: { en: 'Hello World' },
117+
description: { en: '' },
118+
permissions: [{ streamId: 'health', level: 'read' }],
119+
sections: []
120+
};
121+
const out = Contact.aggregateCmc(
122+
[counterpartyAccess()],
123+
[acceptEvent({ hdsFormSpec: spec })],
124+
SCOPE
125+
);
126+
assert.equal(out.length, 1);
127+
assert.equal(out[0].cmcRelationships[0].hdsFormSpec, spec);
128+
});
129+
130+
it('[CTFS2] hdsFormSpec defaults to null when accept event has none', () => {
131+
const out = Contact.aggregateCmc(
132+
[counterpartyAccess()],
133+
[acceptEvent()],
134+
SCOPE
135+
);
136+
assert.equal(out[0].cmcRelationships[0].hdsFormSpec, null);
137+
});
138+
139+
it('[CTFS3] cmcFormSpecs getter returns specs from all chat-enabled relationships', () => {
140+
const spec = { version: 1, title: { en: 'A' }, description: { en: '' }, permissions: [], sections: [{ key: 's', type: 'recurring', name: { en: 'S' }, itemKeys: [] }] };
141+
const out = Contact.aggregateCmc(
142+
[counterpartyAccess()],
143+
[acceptEvent({ hdsFormSpec: spec })],
144+
SCOPE
145+
);
146+
assert.deepEqual(out[0].cmcFormSpecs, [spec]);
147+
});
148+
149+
it('[CTFS4] cmcFormSections aggregates sections across relationships', () => {
150+
const spec1 = { version: 1, title: { en: 'A' }, description: { en: '' }, permissions: [], sections: [{ key: 's1', type: 'recurring', name: { en: 'S1' }, itemKeys: ['a'] }] };
151+
const spec2 = { version: 1, title: { en: 'B' }, description: { en: '' }, permissions: [], sections: [{ key: 's2', type: 'recurring', name: { en: 'S2' }, itemKeys: ['b'] }] };
152+
const a1 = counterpartyAccess({ id: 'a1' });
153+
const a2 = counterpartyAccess({ id: 'a2' });
154+
const out = Contact.aggregateCmc(
155+
[a1, a2],
156+
[
157+
acceptEvent({ acceptEventId: 'e1', hdsFormSpec: spec1 }),
158+
acceptEvent({ acceptEventId: 'e2', hdsFormSpec: spec2 })
159+
],
160+
SCOPE
161+
);
162+
// Both relationships share the same counterparty → one Contact, two relationships.
163+
// Both accept events match the same (counterparty, appCode) pair so both
164+
// relationships get the FIRST matching spec. That's the documented
165+
// behaviour — `matchingAccept` is `accepts.find(...)` (first match).
166+
assert.equal(out.length, 1);
167+
assert.equal(out[0].cmcRelationships.length, 2);
168+
assert.equal(out[0].cmcFormSections.length, 2);
169+
assert.deepEqual(out[0].cmcFormSections.map(s => s.key), ['s1', 's1']);
170+
});
171+
});

ts/appTemplates/Contact.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { Collector } from './Collector.ts';
55
import { HDSModelAppStreams } from '../HDSModel/HDSModel-AppStreams.ts';
66
import { getStreamIdAndChildrenIds } from '../toolkit/StreamsTools.ts';
77
import { pryv, cmc } from '../patchedPryv.ts';
8+
import type { FormSpec } from '../cmc/formSpec.ts';
89

910
// ---- Plan 59 Phase 5a — CMC-shaped Contact types ---- //
1011

@@ -40,6 +41,13 @@ export interface CmcCounterpartyAccess {
4041
* Patient-side accept-event record — what `cmc.listAcceptedRelationships`
4142
* returns. Used to enrich the access-derived relationship with
4243
* `acceptedAt` + reconcile against ghost records.
44+
*
45+
* `hdsFormSpec` is an HDS-extension field, only present when the patient
46+
* mirrors the FormSpec snapshot onto their own accept event content
47+
* post-accept (see `cmcFormSpec.mirrorFormSpecOnAcceptEvent`).
48+
* Consumers fetching accept events directly (raw `events.get`) should
49+
* pass `content.hdsFormSpec` through to this field; the SDK's
50+
* `cmc.listAcceptedRelationships` does not return it.
4351
*/
4452
export interface CmcAcceptedRelationship {
4553
acceptEventId: string;
@@ -50,6 +58,7 @@ export interface CmcAcceptedRelationship {
5058
features?: { chat?: boolean; systemMessaging?: boolean; system?: boolean };
5159
backChannelAccessId?: string | null;
5260
dataGrantAccessId?: string | null;
61+
hdsFormSpec?: FormSpec | null;
5362
}
5463

5564
/**
@@ -82,6 +91,8 @@ export interface CmcRelationship {
8291
grantedPermissions: Permission[];
8392
/** Accept timestamp — null if no matching accept event */
8493
acceptedAt: number | null;
94+
/** FormSpec snapshot mirrored onto the accept event content. Null until the resolver lands (FormSpec brief step 5). */
95+
hdsFormSpec: FormSpec | null;
8596
}
8697

8798
/**
@@ -479,6 +490,39 @@ export class Contact {
479490
}));
480491
}
481492

493+
/**
494+
* Resolved FormSpec snapshots across all active CMC relationships.
495+
* Skips relationships whose accept event hasn't been mirrored yet
496+
* (hdsFormSpec === null). One entry per relationship that has a spec.
497+
*/
498+
get cmcFormSpecs (): FormSpec[] {
499+
const result: FormSpec[] = [];
500+
for (const rel of this.cmcRelationships) {
501+
if (rel.hdsFormSpec) result.push(rel.hdsFormSpec);
502+
}
503+
return result;
504+
}
505+
506+
/**
507+
* Aggregated form sections across all active CMC relationships (Tasks
508+
* page recurring-task loop reads this). Pulled from each relationship's
509+
* resolved FormSpec; empty if no FormSpecs are mirrored yet.
510+
*/
511+
get cmcFormSections (): import('./interfaces.ts').CollectorSectionInterface[] {
512+
const sections: import('./interfaces.ts').CollectorSectionInterface[] = [];
513+
for (const rel of this.cmcRelationships) {
514+
const spec = rel.hdsFormSpec;
515+
if (!spec || !Array.isArray(spec.sections)) continue;
516+
// FormSpec sections are AppTemplateSection — cast to the legacy
517+
// CollectorSectionInterface for consumer compatibility (same shape
518+
// up to a couple of optional fields).
519+
for (const s of spec.sections) {
520+
sections.push(s as unknown as import('./interfaces.ts').CollectorSectionInterface);
521+
}
522+
}
523+
return sections;
524+
}
525+
482526
/** Aggregated granted permissions across all active CMC relationships (deduped by streamId:level) */
483527
get cmcAllPermissions (): Permission[] {
484528
const seen = new Set<string>();
@@ -588,7 +632,8 @@ export class Contact {
588632
appCode,
589633
features,
590634
grantedPermissions: access.permissions ?? [],
591-
acceptedAt: matchingAccept?.acceptedAt ?? null
635+
acceptedAt: matchingAccept?.acceptedAt ?? null,
636+
hdsFormSpec: matchingAccept?.hdsFormSpec ?? null
592637
};
593638
contact.cmcRelationships.push(rel);
594639
// Also populate the legacy accessObjects slot so existing event-filtering

0 commit comments

Comments
 (0)