Skip to content

Commit 2e17631

Browse files
committed
fix(cmc): persist negotiated features into accept trigger + default-true on listAcceptedRelationships — features-negotiation contract drift
Lockstep patch release: pryv@3.4.1 + @pryv/cmc@1.1.1 + @pryv/monitor@3.4.1 + @pryv/socket.io@3.4.1. @pryv/cmc@1.1.1 — two fixes (one cause): - acceptInvite resolves the offer's features (default-true on omission per README contract; explicit false binding both ways) and persists them into the accept trigger's content.features. Previously offerFeatures was computed but never written, so the server-side plugin saw nothing and stamped clientData.cmc.features: null on the accepter's data-grant. - listAcceptedRelationships mapper defaults absent content.features to { chat: true, systemMessaging: true } (was { chat: false, system: false } — both wrong-default AND wrong-key; `systemMessaging` is the documented contract everywhere else). Legacy content.extra fallback removed. Coordinated with the server-side plugin fix on open-pryv.io master: handleAccept now reads triggerEvent.content.features (was .extra). Both sides need the bump to fully restore the negotiation; mismatched versions keep producing null features. Tests: 4 new [CMCL1OF] J6 contract tests pin each fix at unit-test time. pryv-cmc 60/60. Reported by HDS implementer 2026-05-21 against open-pryv.io@04bb2c1 + @pryv/cmc@1.1.0.
1 parent c68ba36 commit 2e17631

8 files changed

Lines changed: 197 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,50 @@
22

33
<!-- Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) -->
44

5+
## [3.4.1]
6+
7+
Lockstep patch of `pryv@3.4.1` + `@pryv/monitor@3.4.1` +
8+
`@pryv/socket.io@3.4.1` + `@pryv/cmc@1.1.1`. Bugfix release; carries
9+
the SDK side of a coordinated fix with the server-side `cmc` plugin
10+
on `open-pryv.io` master.
11+
12+
### `@pryv/cmc@1.1.1`
13+
14+
#### Fixed
15+
- **Features-negotiation contract drift on `acceptInvite` /
16+
`listAcceptedRelationships`.** Reported by a deploy-side implementer
17+
against `@pryv/cmc@1.1.0` + `open-pryv.io@04bb2c1`.
18+
- `acceptInvite` now persists the resolved offer features into
19+
`content.features` of the `consent/accept-cmc` trigger (both keys
20+
default to `true` when omitted by the offer, per the README contract;
21+
explicit `false` is binding both ways). The server-side plugin
22+
forwards them onto the data-grant access's `clientData.cmc.features`.
23+
Previously the SDK computed `offerFeatures` but never persisted it,
24+
so the data-grant access ended up with `clientData.cmc.features:
25+
null` on the accepter side even when the offer specified default-true.
26+
- `listAcceptedRelationships` mapper now defaults absent
27+
`content.features` to `{ chat: true, systemMessaging: true }` (was
28+
`{ chat: false, system: false }` — both wrong-default AND wrong-key
29+
name; `systemMessaging` is the documented contract everywhere else).
30+
The legacy `content.extra` fallback was removed (it was a
31+
workaround for the SDK-side write bug and is unreachable for
32+
events produced by `@pryv/cmc >= 1.1.1`).
33+
- **4 new contract tests** under `[CMCL1OF] J6 features-negotiation`
34+
pin each fix at unit-test time so a regression surfaces without
35+
needing a real backend round-trip.
36+
37+
#### Migration
38+
- **No SDK API changes.** Callers of `acceptInvite(conn, capabilityUrl,
39+
opts)` and `listAcceptedRelationships(conn)` see the same return
40+
shapes; the fix is in what gets persisted server-side + what the
41+
mapper exposes when `content.features` is absent.
42+
- **Server compatibility:** the fix is coordinated with the server-side
43+
`cmc` plugin shipped in `open-pryv.io` master (Plan 68 4th reopen,
44+
2026-05-21). Older `@pryv/cmc` clients writing to a fixed server will
45+
still produce `clientData.cmc.features: null` because the SDK isn't
46+
writing `content.features` — bump to `@pryv/cmc@1.1.1` to get the
47+
full negotiation persisted.
48+
549
## [3.4.0]
650

751
Lockstep release of `pryv@3.4.0` + `@pryv/monitor@3.4.0` +

components/pryv-cmc/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pryv/cmc",
3-
"version": "1.1.0",
3+
"version": "1.1.1",
44
"description": "Cross-account Messaging & Consent client helpers for Pryv.io",
55
"keywords": [
66
"Pryv",

components/pryv-cmc/src/index.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -675,7 +675,16 @@ async function acceptInvite (conn, capabilityUrl, opts) {
675675
} catch (_e) {
676676
// Best-effort; if offer read fails the accept can still proceed.
677677
}
678-
const content = { capabilityUrl };
678+
// Persist the negotiated features into the accept trigger so the
679+
// plugin's handleAccept can stamp them onto the data-grant access's
680+
// clientData.cmc.features. Both keys default to true when omitted on
681+
// the offer side (per README "Features negotiation"); explicit false
682+
// is binding both ways.
683+
const resolvedFeatures = {
684+
chat: offerFeatures?.chat !== false,
685+
systemMessaging: offerFeatures?.systemMessaging !== false
686+
};
687+
const content = { capabilityUrl, features: resolvedFeatures };
679688
if (opts.extra) content.extra = opts.extra;
680689
if (opts.accessName) content.accessName = opts.accessName;
681690
const event = await conn.apiOne('events.create', {
@@ -877,7 +886,12 @@ async function listAcceptedRelationships (conn, params) {
877886
appCode: c.appCode || null,
878887
scopeStreamId: (event.streamIds && event.streamIds[0]) || event.streamId,
879888
acceptedAt: c.acceptedAt || event.time || null,
880-
features: c.features || (c.extra ? { chat: !!c.extra.chat, system: !!c.extra.systemMessaging } : { chat: false, system: false })
889+
// Features default to true on both keys when absent (per README
890+
// "Features negotiation"). The legacy c.extra fallback predates
891+
// the contract fix and is no longer reachable for events written
892+
// by pryv-cmc >= 1.1.1; we leave the field-omission default at
893+
// true to honour the documented contract.
894+
features: c.features || { chat: true, systemMessaging: true }
881895
};
882896
});
883897
}

components/pryv-cmc/test/cmc.test.js

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -933,6 +933,138 @@ describe('[CMCL1] @pryv/cmc Level-1 protocol functions', function () {
933933
});
934934
});
935935

936+
describe('[CMCL1OF] J6 features-negotiation contract', function () {
937+
// README contract: both `chat` + `systemMessaging` default to true
938+
// when omitted; explicit false on either is binding both sides.
939+
//
940+
// Bug history (2026-05-21, HDS implementer report on
941+
// open-pryv.io@04bb2c1 + @pryv/cmc@1.1.0):
942+
// - SDK acceptInvite never wrote content.features (offerFeatures
943+
// computed at line ~674 was unused).
944+
// - Plugin handleAccept read triggerEvent.content.extra instead of
945+
// content.features (extra is the user-supplied pass-through,
946+
// reserved for HDS-specific opts).
947+
// - listAcceptedRelationships mapper defaulted to
948+
// { chat: false, system: false } (contradicts README) and used
949+
// `system` instead of `systemMessaging` (inconsistent with the
950+
// contract everywhere else).
951+
// Together these defaulted the patient-side relationship to all-false
952+
// even when the offer specified default-true. These contract tests
953+
// pin each fix so regressions surface at unit-test time.
954+
955+
function fakePryvWithApiOne (apiOneFn) {
956+
const stub = {
957+
apiOne: apiOneFn,
958+
service: { info: async () => { throw new Error('not stubbed'); } },
959+
accessInfo: async () => { throw new Error('not stubbed'); }
960+
};
961+
return {
962+
Connection: function () { return stub; },
963+
utils: { decomposeAPIEndpoint: () => ({ username: null, host: '' }) }
964+
};
965+
}
966+
967+
it('[CMCL1OF1] acceptInvite defaults content.features to {chat:true, systemMessaging:true} when offer omits features', async function () {
968+
const fakePryv = fakePryvWithApiOne(async function (method, _params, expectedKey) {
969+
if (method === 'events.get') {
970+
// Offer event with NO features field — must default to true on both.
971+
return { events: [{ id: 'offer-1', content: { request: { permissions: [], consent: { en: 'ok' } } } }] }[expectedKey];
972+
}
973+
throw new Error('unexpected method: ' + method);
974+
});
975+
const conn = makeStubConnection({
976+
handlers: {
977+
'events.create': function (params) {
978+
return { event: { id: 'acc-default', streamIds: params.streamIds, content: { status: 'pending' } } };
979+
}
980+
}
981+
});
982+
await cmc.acceptInvite(conn, 'https://Tok@example.com/', {
983+
scopeStreamId: ':_cmc:apps:test',
984+
waitForCompletion: false,
985+
pryv: fakePryv
986+
});
987+
const createCall = conn.calls.find(c => c.method === 'events.create');
988+
expect(createCall, 'expected events.create on accepter conn').to.exist;
989+
expect(createCall.params.content).to.have.property('features');
990+
expect(createCall.params.content.features).to.deep.equal({
991+
chat: true,
992+
systemMessaging: true
993+
});
994+
});
995+
996+
it('[CMCL1OF2] acceptInvite preserves explicit false on each key from offer', async function () {
997+
const fakePryv = fakePryvWithApiOne(async function (method, _params, expectedKey) {
998+
if (method === 'events.get') {
999+
return { events: [{ id: 'offer-2', content: { request: { permissions: [], consent: { en: 'ok' }, features: { chat: false, systemMessaging: true } } } }] }[expectedKey];
1000+
}
1001+
throw new Error('unexpected method: ' + method);
1002+
});
1003+
const conn = makeStubConnection({
1004+
handlers: {
1005+
'events.create': function (params) {
1006+
return { event: { id: 'acc-explicit-false', streamIds: params.streamIds, content: { status: 'pending' } } };
1007+
}
1008+
}
1009+
});
1010+
await cmc.acceptInvite(conn, 'https://Tok@example.com/', {
1011+
scopeStreamId: ':_cmc:apps:test',
1012+
waitForCompletion: false,
1013+
pryv: fakePryv
1014+
});
1015+
const createCall = conn.calls.find(c => c.method === 'events.create');
1016+
expect(createCall.params.content.features).to.deep.equal({
1017+
chat: false,
1018+
systemMessaging: true
1019+
});
1020+
});
1021+
1022+
it('[CMCL1OF3] listAcceptedRelationships defaults features to {chat:true, systemMessaging:true} when content.features absent', async function () {
1023+
const conn = makeStubConnection({
1024+
handlers: {
1025+
'events.get': function () {
1026+
return {
1027+
events: [{
1028+
id: 'acc-no-feat',
1029+
streamIds: [':_cmc:apps:test'],
1030+
content: {
1031+
from: { username: 'alice', host: 'pryv.me' },
1032+
dataGrantAccessId: 'dg-no-feat'
1033+
// No `features` field — must default to true on both keys.
1034+
}
1035+
}]
1036+
};
1037+
}
1038+
}
1039+
});
1040+
const r = await cmc.listAcceptedRelationships(conn);
1041+
expect(r).to.have.length(1);
1042+
expect(r[0].features).to.deep.equal({ chat: true, systemMessaging: true });
1043+
});
1044+
1045+
it('[CMCL1OF4] listAcceptedRelationships passes through content.features verbatim — including explicit false', async function () {
1046+
const conn = makeStubConnection({
1047+
handlers: {
1048+
'events.get': function () {
1049+
return {
1050+
events: [{
1051+
id: 'acc-explicit',
1052+
streamIds: [':_cmc:apps:test'],
1053+
content: {
1054+
from: { username: 'alice', host: 'pryv.me' },
1055+
dataGrantAccessId: 'dg-explicit',
1056+
features: { chat: true, systemMessaging: false }
1057+
}
1058+
}]
1059+
};
1060+
}
1061+
}
1062+
});
1063+
const r = await cmc.listAcceptedRelationships(conn);
1064+
expect(r[0].features).to.deep.equal({ chat: true, systemMessaging: false });
1065+
});
1066+
});
1067+
9361068
describe('[CMCL1OH] J8 acceptInvite resolves dataGrantAccessId on waitForCompletion', function () {
9371069
it('[CMCL1OH1] returns dataGrantAccessId from the post-completion getOne', async function () {
9381070
// Two-phase: events.create returns status pending; pollTriggerCompletion

components/pryv-monitor/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pryv/monitor",
3-
"version": "3.4.0",
3+
"version": "3.4.1",
44
"description": "Extends `pryv` with event-driven notifications for changes on a Pryv.io account",
55
"keywords": [
66
"Pryv",

components/pryv-socket.io/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pryv/socket.io",
3-
"version": "3.4.0",
3+
"version": "3.4.1",
44
"description": "Extends `pryv` with Socket.IO transport",
55
"keywords": [
66
"Pryv",

components/pryv/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "pryv",
3-
"version": "3.4.0",
3+
"version": "3.4.1",
44
"description": "Pryv JavaScript library",
55
"keywords": [
66
"Pryv",

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "lib-js",
3-
"version": "3.4.0",
3+
"version": "3.4.1",
44
"private": false,
55
"description": "Pryv JavaScript library and add-ons",
66
"keywords": [

0 commit comments

Comments
 (0)