Skip to content

Commit 752c81a

Browse files
committed
refactor(plan-60 B1+B2): hoist ensureAppScope + add cmcConstants module
B1 (S1) — `ts/cmc/appScope.ts`: canonical `ensureAppScope(conn, appCode, subPath?)` from doctor-dashboard's defensive version (tolerates `item-already-exists` + `forbidden`). Also exports `pryvErrorCode(e)` (B4 folded in). Replaces 3 drifting copies (doctor-dashboard, bridge-mira, archived migration). B2 (S2) — `ts/cmc/constants.ts`: `CMC_APP_CODES` (PATIENT/COLLECTOR/ BRIDGE_MIRA/BRIDGE_ATHENA), `CMC_EVENT_TYPES` (INVITE_TRIGGER/ACCEPT), `appSubScope(appCode, sub)` builder + `extractAppSubScopeSuffix(streamId, appCode)` extractor (preserves the original `.*?` leading-prefix tolerance). Locks the values consumers were duplicating as literals. `CMC_CLIENTDATA` from the plan example dropped — paths don't exist in active code. `formSpec.ts`: 2 `consent/request-cmc` literals → `CMC_EVENT_TYPES. INVITE_TRIGGER` via relative import. `index.ts`: exports `cmcAppScope` + `cmcConstants` (namespace pattern, matches `cmcFormSpec`). Tests: `tests/appScope.test.js` (12), `tests/cmcConstants.test.js` (8). 532/532 hds-lib tests pass. package-lock: stale 0.11.0 → 0.12.1 sync from earlier npm runs.
1 parent ed0cd5f commit 752c81a

7 files changed

Lines changed: 329 additions & 6 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/appScope.test.js

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { assert } from './test-utils/deps-node.js';
2+
import { ensureAppScope, pryvErrorCode } from '../ts/cmc/appScope.ts';
3+
4+
/**
5+
* Unit tests for the Plan 60 B1 hoisted `cmcAppScope.ensureAppScope`.
6+
*
7+
* The helper wraps two `streams.create` calls (appScope + optional subPath)
8+
* and tolerates idempotency / permission errors against the `:_cmc:apps`
9+
* plugin-managed namespace. See `ts/cmc/appScope.ts` for the rationale.
10+
*/
11+
12+
const APP_CODE = 'hds-collector';
13+
const EXPECTED_APP_SCOPE = ':_cmc:apps:' + APP_CODE;
14+
15+
function makeConn (responder) {
16+
const calls = [];
17+
return {
18+
calls,
19+
async apiOne (method, params) {
20+
calls.push({ method, params });
21+
return responder(method, params, calls.length - 1);
22+
}
23+
};
24+
}
25+
26+
function pryvError (id) {
27+
const e = new Error('pryv-api: ' + id);
28+
e.innerObject = { id };
29+
return e;
30+
}
31+
32+
describe('[CAS] cmcAppScope.ensureAppScope', function () {
33+
describe('[CASE] pryvErrorCode', function () {
34+
it('[CAS01] reads innerObject.id when present', () => {
35+
assert.equal(pryvErrorCode(pryvError('item-already-exists')), 'item-already-exists');
36+
});
37+
it('[CAS02] falls back to top-level id when no innerObject', () => {
38+
assert.equal(pryvErrorCode({ id: 'forbidden' }), 'forbidden');
39+
});
40+
it('[CAS03] returns undefined for unrelated values', () => {
41+
assert.equal(pryvErrorCode(new Error('x')), undefined);
42+
assert.equal(pryvErrorCode(null), undefined);
43+
assert.equal(pryvErrorCode(undefined), undefined);
44+
});
45+
});
46+
47+
describe('[CASH] happy paths', function () {
48+
it('[CAS10] provisions appScope only (no subPath)', async () => {
49+
const conn = makeConn(() => ({ stream: { id: EXPECTED_APP_SCOPE } }));
50+
const out = await ensureAppScope(conn, APP_CODE);
51+
assert.equal(out, EXPECTED_APP_SCOPE);
52+
assert.equal(conn.calls.length, 1);
53+
assert.equal(conn.calls[0].method, 'streams.create');
54+
assert.deepEqual(conn.calls[0].params, {
55+
id: EXPECTED_APP_SCOPE,
56+
parentId: ':_cmc:apps',
57+
name: APP_CODE
58+
});
59+
});
60+
61+
it('[CAS11] provisions appScope + subPath', async () => {
62+
const conn = makeConn(() => ({ stream: {} }));
63+
const out = await ensureAppScope(conn, APP_CODE, 'abc123');
64+
assert.equal(out, EXPECTED_APP_SCOPE + ':abc123');
65+
assert.equal(conn.calls.length, 2);
66+
assert.deepEqual(conn.calls[1].params, {
67+
id: EXPECTED_APP_SCOPE + ':abc123',
68+
parentId: EXPECTED_APP_SCOPE,
69+
name: 'abc123'
70+
});
71+
});
72+
});
73+
74+
describe('[CAST] tolerated errors', function () {
75+
it('[CAS20] tolerates item-already-exists on appScope', async () => {
76+
const conn = makeConn(() => { throw pryvError('item-already-exists'); });
77+
const out = await ensureAppScope(conn, APP_CODE);
78+
assert.equal(out, EXPECTED_APP_SCOPE);
79+
});
80+
81+
it('[CAS21] tolerates forbidden on appScope (OAuth-narrow access)', async () => {
82+
const conn = makeConn(() => { throw pryvError('forbidden'); });
83+
const out = await ensureAppScope(conn, APP_CODE);
84+
assert.equal(out, EXPECTED_APP_SCOPE);
85+
});
86+
87+
it('[CAS22] tolerates item-already-exists on subPath', async () => {
88+
const conn = makeConn((method, params, idx) => {
89+
if (idx === 0) return { stream: {} };
90+
throw pryvError('item-already-exists');
91+
});
92+
const out = await ensureAppScope(conn, APP_CODE, 'abc123');
93+
assert.equal(out, EXPECTED_APP_SCOPE + ':abc123');
94+
assert.equal(conn.calls.length, 2);
95+
});
96+
});
97+
98+
describe('[CASR] rethrows on unrelated errors', function () {
99+
it('[CAS30] rethrows non-tolerated pryv error on appScope', async () => {
100+
const conn = makeConn(() => { throw pryvError('invalid-parameters-format'); });
101+
await assert.rejects(
102+
ensureAppScope(conn, APP_CODE),
103+
(e) => e.innerObject?.id === 'invalid-parameters-format'
104+
);
105+
});
106+
107+
it('[CAS31] rethrows forbidden on subPath (only appScope tolerates forbidden)', async () => {
108+
const conn = makeConn((method, params, idx) => {
109+
if (idx === 0) return { stream: {} };
110+
throw pryvError('forbidden');
111+
});
112+
await assert.rejects(
113+
ensureAppScope(conn, APP_CODE, 'abc123'),
114+
(e) => e.innerObject?.id === 'forbidden'
115+
);
116+
});
117+
118+
it('[CAS32] rethrows plain Errors with no pryv id', async () => {
119+
const conn = makeConn(() => { throw new Error('boom'); });
120+
await assert.rejects(ensureAppScope(conn, APP_CODE), /boom/);
121+
});
122+
});
123+
});

tests/cmcConstants.test.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { assert } from './test-utils/deps-node.js';
2+
import {
3+
CMC_APP_CODES,
4+
CMC_EVENT_TYPES,
5+
appSubScope,
6+
extractAppSubScopeSuffix
7+
} from '../ts/cmc/constants.ts';
8+
9+
/**
10+
* Unit tests for the Plan 60 B2 shared CMC constants + scope helpers.
11+
*
12+
* The constants are locked per Plan 59 Decisions; tests assert the exact
13+
* values so any drift becomes a deliberate change with an explicit diff.
14+
*/
15+
16+
describe('[CCST] cmcConstants', function () {
17+
describe('[CCSA] CMC_APP_CODES', function () {
18+
it('[CCS01] PATIENT, COLLECTOR, BRIDGE_MIRA, BRIDGE_ATHENA match locked values', () => {
19+
assert.equal(CMC_APP_CODES.PATIENT, 'hds-patient');
20+
assert.equal(CMC_APP_CODES.COLLECTOR, 'hds-collector');
21+
assert.equal(CMC_APP_CODES.BRIDGE_MIRA, 'hds-bridge-mira');
22+
assert.equal(CMC_APP_CODES.BRIDGE_ATHENA, 'hds-bridge-athena');
23+
});
24+
});
25+
26+
describe('[CCSE] CMC_EVENT_TYPES', function () {
27+
it('[CCS10] INVITE_TRIGGER + ACCEPT match the CMC plugin convention', () => {
28+
assert.equal(CMC_EVENT_TYPES.INVITE_TRIGGER, 'consent/request-cmc');
29+
assert.equal(CMC_EVENT_TYPES.ACCEPT, 'consent/accept-cmc');
30+
});
31+
});
32+
33+
describe('[CCSB] appSubScope', function () {
34+
it('[CCS20] builds the canonical sub-scope id', () => {
35+
assert.equal(
36+
appSubScope(CMC_APP_CODES.COLLECTOR, 'abc123'),
37+
':_cmc:apps:hds-collector:abc123'
38+
);
39+
});
40+
it('[CCS21] composes deeper paths via colon-joined sub', () => {
41+
assert.equal(
42+
appSubScope(CMC_APP_CODES.PATIENT, 'chats:dr-smith'),
43+
':_cmc:apps:hds-patient:chats:dr-smith'
44+
);
45+
});
46+
});
47+
48+
describe('[CCSX] extractAppSubScopeSuffix', function () {
49+
it('[CCS30] strips the appScope prefix', () => {
50+
assert.equal(
51+
extractAppSubScopeSuffix(':_cmc:apps:hds-collector:abc123', CMC_APP_CODES.COLLECTOR),
52+
'abc123'
53+
);
54+
});
55+
it('[CCS31] tolerates leading prefix (matches original .*? regex behavior)', () => {
56+
assert.equal(
57+
extractAppSubScopeSuffix('peer:_cmc:apps:hds-collector:abc123', CMC_APP_CODES.COLLECTOR),
58+
'abc123'
59+
);
60+
});
61+
it('[CCS32] returns streamId unchanged when prefix absent', () => {
62+
assert.equal(
63+
extractAppSubScopeSuffix(':some-other-stream', CMC_APP_CODES.COLLECTOR),
64+
':some-other-stream'
65+
);
66+
});
67+
it('[CCS33] does not cross-match a different appCode', () => {
68+
assert.equal(
69+
extractAppSubScopeSuffix(':_cmc:apps:hds-collector:abc', CMC_APP_CODES.PATIENT),
70+
':_cmc:apps:hds-collector:abc'
71+
);
72+
});
73+
});
74+
});

ts/cmc/appScope.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* Idempotently provision streams under `:_cmc:apps` on a CMC-enabled account.
3+
*
4+
* The CMC plugin auto-creates the `:_cmc:apps` parent but does not
5+
* auto-create the per-app children — each `:_cmc:apps:<appCode>` and its
6+
* sub-scopes (e.g. `:_cmc:apps:hds-collector:<collectorId>`) need explicit
7+
* `streams.create` calls. The two error modes are tolerated:
8+
*
9+
* - `'item-already-exists'` — the stream is already there (idempotent re-run).
10+
* - `'forbidden'` — happens on the appScope when the caller's OAuth-scoped
11+
* access has `manage` on `:_cmc:apps:<appCode>` but not on the parent
12+
* `:_cmc:apps` (the plugin-managed namespace). In that case the appScope
13+
* is typically pre-existing anyway because the CMC plugin auto-provisions
14+
* app-scope roots on first invite.
15+
*
16+
* Hoisted in Plan 60 B1 from three independent copies that had drifted:
17+
* `doctor-dashboard/app/cmcDoctor.ts` (canonical, used here),
18+
* `bridge-mira/src/methods/cmcBridgeMira.ts`, and the archived
19+
* `_plans/_archives/59-…/scripts/cmc-migrate.mjs`.
20+
*/
21+
22+
import { cmc, pryv } from '../patchedPryv.ts';
23+
24+
/** Extract a Pryv API error-code id from a thrown error, regardless of nesting depth. */
25+
export function pryvErrorCode (e: unknown): string | undefined {
26+
const inner = (e as { innerObject?: { id?: string } })?.innerObject?.id;
27+
if (inner != null) return inner;
28+
return (e as { id?: string })?.id;
29+
}
30+
31+
/**
32+
* Idempotently provision `:_cmc:apps:<appCode>` (and optionally
33+
* `:_cmc:apps:<appCode>:<subPath>`) on the caller's account.
34+
*
35+
* @param connection caller's Pryv connection (doctor / patient / bridge).
36+
* @param appCode `hds-collector`, `hds-patient`, `hds-bridge-mira`, etc.
37+
* @param subPath optional leaf scope under the appScope (e.g. a collectorId).
38+
* @returns the streamId of the deepest scope provisioned.
39+
*/
40+
export async function ensureAppScope (
41+
connection: pryv.Connection,
42+
appCode: string,
43+
subPath?: string
44+
): Promise<string> {
45+
const appScope = cmc.appScope(appCode); // :_cmc:apps:<appCode>
46+
try {
47+
await connection.apiOne('streams.create', { id: appScope, parentId: ':_cmc:apps', name: appCode });
48+
} catch (e: unknown) {
49+
const code = pryvErrorCode(e);
50+
if (code !== 'item-already-exists' && code !== 'forbidden') throw e;
51+
}
52+
if (subPath == null) return appScope;
53+
const sub = appScope + ':' + subPath;
54+
try {
55+
await connection.apiOne('streams.create', { id: sub, parentId: appScope, name: subPath });
56+
} catch (e: unknown) {
57+
const code = pryvErrorCode(e);
58+
if (code !== 'item-already-exists') throw e;
59+
}
60+
return sub;
61+
}

ts/cmc/constants.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* Canonical constants for the HDS x CMC integration.
3+
*
4+
* Centralizes:
5+
* - **App codes** — the `:_cmc:apps:<code>` namespace identifiers for each
6+
* HDS surface (patient app, doctor's collector flow, per-bridge services).
7+
* - **CMC event types** — the well-known consent trigger/accept types the
8+
* CMC plugin emits.
9+
* - **Stream-ID helpers** — build / extract the `:_cmc:apps:<appCode>:<sub>`
10+
* sub-scope pattern used by collector forms and chat anchors.
11+
*
12+
* Hoisted in Plan 60 B2 from per-file literals + per-file constants.
13+
*/
14+
15+
/** Locked HDS app codes for the `:_cmc:apps:<appCode>` namespace. */
16+
export const CMC_APP_CODES = {
17+
/** Patient-side HDS-webapp scope (chats anchor, inbox routing). */
18+
PATIENT: 'hds-patient',
19+
/** Doctor-side collector flow scope (form-spec sub-scopes + invites). */
20+
COLLECTOR: 'hds-collector',
21+
/** bridge-mira service-account scope (Mira API ingest). */
22+
BRIDGE_MIRA: 'hds-bridge-mira',
23+
/** bridge-athena service-account scope (Athena/EHR ingest; CMC wiring pending). */
24+
BRIDGE_ATHENA: 'hds-bridge-athena'
25+
} as const;
26+
27+
export type CmcAppCode = typeof CMC_APP_CODES[keyof typeof CMC_APP_CODES];
28+
29+
/** Well-known CMC consent event types. */
30+
export const CMC_EVENT_TYPES = {
31+
/** Doctor's invite trigger event under their collector sub-scope. */
32+
INVITE_TRIGGER: 'consent/request-cmc',
33+
/** Patient's accept event (mirrored to the doctor's inbox via the plugin). */
34+
ACCEPT: 'consent/accept-cmc'
35+
} as const;
36+
37+
export type CmcEventType = typeof CMC_EVENT_TYPES[keyof typeof CMC_EVENT_TYPES];
38+
39+
/**
40+
* Build `:_cmc:apps:<appCode>:<sub>` — the per-sub-scope stream id used by
41+
* collector forms (`:<collectorId>`), chat anchors (`:chats:<peerSlug>`), etc.
42+
*
43+
* Mirrors the upstream `cmc.appScope(appCode)` builder which only handles the
44+
* top-level appScope; this helper handles the one-level-deeper case the HDS
45+
* integration uses pervasively.
46+
*/
47+
export function appSubScope (appCode: string, sub: string): string {
48+
return ':_cmc:apps:' + appCode + ':' + sub;
49+
}
50+
51+
/**
52+
* Extract the trailing `<sub>` from a stream id of the form
53+
* `:_cmc:apps:<appCode>:<sub>` (tolerant of any leading prefix — matches the
54+
* `^.*?:_cmc:apps:<appCode>:` pattern used in the original sites).
55+
* Returns the original streamId unchanged if the prefix is not found.
56+
*/
57+
export function extractAppSubScopeSuffix (streamId: string, appCode: string): string {
58+
const escaped = appCode.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
59+
return streamId.replace(new RegExp('^.*?:_cmc:apps:' + escaped + ':'), '');
60+
}

ts/cmc/formSpec.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
*/
2626

2727
import { pryv } from '../patchedPryv.ts';
28+
import { CMC_EVENT_TYPES } from './constants.ts';
2829
import type { Permission } from '../appTemplates/interfaces.ts';
2930
import type { localizableText } from '../localizeText.ts';
3031
import type {
@@ -182,7 +183,7 @@ export async function createInviteWithFormSpec (
182183
}
183184
const event = await connection.apiOne('events.create', {
184185
streamIds: [params.scopeStreamId],
185-
type: 'consent/request-cmc',
186+
type: CMC_EVENT_TYPES.INVITE_TRIGGER,
186187
content
187188
} as any, 'event') as any;
188189
return {
@@ -256,7 +257,7 @@ export async function readOfferWithFormSpec (
256257
const pryvMod = (opts && opts.pryv) || (pryv as any);
257258
const cap = new pryvMod.Connection(capabilityUrl);
258259
const events = await cap.apiOne('events.get', {
259-
types: ['consent/request-cmc'],
260+
types: [CMC_EVENT_TYPES.INVITE_TRIGGER],
260261
limit: 1
261262
}, 'events') as any[];
262263
if (!events || events.length === 0) {

ts/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import { EuclidianDistanceEngine } from './converters/EuclidianDistanceEngine.ts
2121
import { HDSLibError } from './errors.ts';
2222
import { extractOverloadAsDefinitions } from './HDSModel/overloadExtract.ts';
2323
import * as cmcFormSpec from './cmc/formSpec.ts';
24+
import * as cmcAppScope from './cmc/appScope.ts';
25+
import * as cmcConstants from './cmc/constants.ts';
2426
export type { MonitorScopeConfig, MonitorScopeCallbacks } from './MonitorScope.ts';
2527
export type { ReminderConfig } from './HDSModel/HDSItemDef.ts';
2628
export type { ReminderSource, ReminderStatus } from './HDSModel/reminders.ts';
@@ -34,7 +36,7 @@ export type { InitHDSModelOptions } from './HDSModel/HDSModelInitAndSingleton.ts
3436

3537
export const getHDSModel = HDSModelInitAndSingleton.getModel;
3638
export const initHDSModel = HDSModelInitAndSingleton.initHDSModel;
37-
export { pryv, cmc, settings, HDSService, HDSModel, appTemplates, localizeText, localizeText as l, toolkit, logger, durationToSeconds, durationToLabel, computeReminders, eventToShortText, formatEventDate, MonitorScope, HDSSettings, SETTING_TYPES, HDSProfile, PROFILE_FIELDS, HDSModelConversions, HDSModelConverters, HDSModelPreferred, getPreferredInput, getPreferredDisplay, HDSModelAppStreams, EuclidianDistanceEngine, HDSLibError, extractOverloadAsDefinitions, cmcFormSpec };
39+
export { pryv, cmc, settings, HDSService, HDSModel, appTemplates, localizeText, localizeText as l, toolkit, logger, durationToSeconds, durationToLabel, computeReminders, eventToShortText, formatEventDate, MonitorScope, HDSSettings, SETTING_TYPES, HDSProfile, PROFILE_FIELDS, HDSModelConversions, HDSModelConverters, HDSModelPreferred, getPreferredInput, getPreferredDisplay, HDSModelAppStreams, EuclidianDistanceEngine, HDSLibError, extractOverloadAsDefinitions, cmcFormSpec, cmcAppScope, cmcConstants };
3840
export type { FormSpec } from './cmc/formSpec.ts';
3941

4042
// Plan 45 — top-level type re-exports so consumers can `import type { CustomFieldDeclaration } from 'hds-lib'`.
@@ -93,6 +95,8 @@ const HDSLib = {
9395
HDSModelConverters,
9496
EuclidianDistanceEngine,
9597
extractOverloadAsDefinitions,
96-
cmcFormSpec
98+
cmcFormSpec,
99+
cmcAppScope,
100+
cmcConstants
97101
};
98102
export default HDSLib;

0 commit comments

Comments
 (0)