@pryv/cmc
v3.9.0
Published
Cross-account Messaging & Consent client helpers for Pryv.io
Readme
CMC client helpers for pryv
Cross-account Messaging & Consent (CMC) client-side helpers for the Pryv JavaScript library. CMC enables federated consent + chat + system notifications across two Pryv accounts (same or different platforms) via the open-pryv.io cmc plugin.
Usage
@pryv/cmc is a sibling package to pryv — imported separately, not attached to the pryv namespace. It uses the standard pryv.Connection to talk to the server.
Importing
NPM
npm install --save pryv @pryv/cmc, then in your code:
const pryv = require('pryv');
const cmc = require('@pryv/cmc');@pryv/cmc requires pryv@^3.3.0 as a peer dependency.
<script> tag
pryv-cmc.js must be loaded after pryv.js. The package exposes its API on the global pryvCmc:
<script src="https://api.pryv.com/lib-js/pryv.js"></script>
<script src="https://api.pryv.com/lib-js/pryv-cmc.js"></script>
<script>
const cmc = window.pryvCmc;
</script>Surface
@pryv/cmc ships two layers:
- Level 0 — pure helpers (constants, slug + stream-id builders, classifiers,
errorIds). Used to construct or parse identifiers without a network call. - Level 1 — protocol functions taking a
pryv.Connectionand a parameters object. Each maps to one server-side CMC operation.
Level 0 — constants + helpers
Namespace constants
cmc.NS=:_cmc:— reserved root for the CMC stream tree.cmc.NS_INBOX=:_cmc:inbox— one-shot lifecycle stream (delivered events from peers).cmc.NS_APPS=:_cmc:apps— per-app scope root.cmc.NS_INTERNAL=:_cmc:_internal— plugin-private root.cmc.NS_INTERNAL_RETRIES=:_cmc:_internal:retries— retry queue stream.
Event types
cmc.ET_REQUEST (consent/request-cmc), cmc.ET_ACCEPT (consent/accept-cmc), cmc.ET_REFUSE (consent/refuse-cmc), cmc.ET_REVOKE (consent/revoke-cmc), cmc.ET_INVALIDATE_LINK (consent/invalidate-link-cmc), cmc.ET_SCOPE_REQUEST (consent/scope-request-cmc), cmc.ET_SCOPE_UPDATE (consent/scope-update-cmc), cmc.ET_CHAT (message/chat-cmc), cmc.ET_SYSTEM_ALERT (notification/alert-cmc), cmc.ET_SYSTEM_ACK (notification/ack-cmc).
Grouped collections: cmc.EVENT_TYPES_LIFECYCLE, cmc.EVENT_TYPES_CHAT, cmc.EVENT_TYPES_SYSTEM.
Slug + stream-id builders
cmc.slugifyHost('pryv.me'); // → 'pryv-me'
cmc.counterpartySlug({ username: 'alice', host: 'pryv.me' }); // → 'alice--pryv-me'
cmc.parseCounterpartySlug('alice--pryv-me'); // → { username: 'alice', hostSlug: 'pryv-me' }
cmc.appScope('my-app'); // → ':_cmc:apps:my-app'
cmc.chatStreamUnder(':_cmc:apps:my-app', 'alice--pryv-me'); // → ':_cmc:apps:my-app:chats:alice--pryv-me'
cmc.collectorStreamUnder(':_cmc:apps:my-app', 'alice--pryv-me');// → ':_cmc:apps:my-app:collectors:alice--pryv-me'cmc.SEPARATOR is --.
Classifiers + parsers
cmc.isCmcStreamId(':_cmc:apps:my-app'); // → true
cmc.isAppNestedPluginStream(':_cmc:apps:my-app:chats:s'); // → true
cmc.getAppCode(':_cmc:apps:my-app:study-1'); // → 'my-app'
cmc.parseChatStreamId(':_cmc:apps:my-app:chats:a--p-me'); // → { appCode, scopeStreamId, counterpartySlug, counterparty }
cmc.parseCollectorStreamId(...); // → { ... } same shapeError id catalogue
cmc.errorIds mirrors the server-side CmcErrorIds catalogue (31 stable kebab-case strings as of @pryv/[email protected]). Match on these constants when observing trigger.content.failure.reason or error.data.id from a failed events.create / accesses.create instead of parsing English error.message.
Lifecycle / handler / chat-routing examples: cmc.errorIds.CAPABILITY_INVALID ('cmc-capability-invalid'), cmc.errorIds.CAPABILITY_CONSUMED, cmc.errorIds.CAPABILITY_INVALIDATED, cmc.errorIds.CAPABILITY_ALREADY_ACCEPTED_BY_YOU, cmc.errorIds.HANDLER_DELIVERY_FAILED, cmc.errorIds.CHAT_NO_REMOTE_APIENDPOINT, etc.
Added in @pryv/[email protected] (server-side open-pryv.io ≥ 2.0.0-pre.4):
| Const | String | When you'll see it |
|---|---|---|
| CAPABILITY_TTL_OUT_OF_RANGE | 'cmc-capability-ttl-out-of-range' | createInvite({ expiresAt }) resolves to a TTL outside [60s, 30d]. Omit expiresAt to use the 7-day default. |
| HANDLER_MISSING_CAPABILITY_ID | 'cmc-handler-missing-capability-id' | Plugin handler couldn't find content.capabilityId on the trigger event. |
| CHAT_DISABLED | 'cmc-chat-disabled' | sendChat against a relationship whose negotiated features.chat: false. Default-permit on omission. |
| SYSTEM_MESSAGING_DISABLED | 'cmc-system-messaging-disabled' | sendSystemAlert / ack against features.systemMessaging: false. Scope-request / scope-update remain permitted regardless. |
| CLIENTDATA_CMC_FORBIDDEN | 'cmc-clientdata-cmc-forbidden' | accesses.create / accesses.update rejected user-supplied clientData.cmc.* (the namespace is plugin-owned). |
| RESERVED_STREAM_UNDELETABLE | 'cmc-reserved-stream-undeletable' | streams.delete rejected on a plugin-managed :_cmc:* parent (incl. personal-token deletes). |
| COUNTERPARTY_IDENTITY_MISSING | 'cmc-counterparty-identity-missing' | Peer-side content.from stamping hook rejected — the counterparty access lacks {username,host} (ops-level, shouldn't happen under normal flow). |
The full list lives in src/index.js (and the typed mirror in src/index.d.ts).
cmc.CmcError is a typed Error subclass carrying .id (one of errorIds.*) and .failure (the server's raw failure object). It's thrown by acceptInvite on status: 'failed'.
Capability TTL bounds
The server bounds content.request.expiresAt (Unix seconds) to [60s, 30d] from now at mint time. Pass expiresAt to cmc.createInvite() to override the default 7-day lifetime; omit it for the default. Out-of-range values reject with cmc-capability-ttl-out-of-range before the capability access is minted.
Features negotiation
cmc.createInvite({ features: { chat, systemMessaging } }) opts the relationship in or out of each cross-account channel.
- Both default to
truewhen the key is omitted (matches the offer-side default). - Setting either to
falseis binding on both sides at send time — the recipient's plugin records the negotiation on the counterparty access'sclientData.cmc.features.*, and subsequentsendChat/sendSystemAlertagainst the access reject withcmc-chat-disabled/cmc-system-messaging-disableduntil a new invite negotiates it back on. cmc.scopes.{inbox, chats, collectors}andcmc.revokeRelationshipare unaffected — those are protocol surfaces, not message channels.
pryv.utils.decomposeAPIEndpoint
The companion pryv.utils.decomposeAPIEndpoint(apiEndpoint, serviceInfoApi) returns { token, username, host } with host being the canonical platform host (no <username>. subdomain prefix). It's the right way to derive the { username, host } arguments for cmc.counterpartySlug. It lives in pryv.utils (not cmc.*) because it's generally useful — see the pryv README.
Level 1 — protocol functions
Each function takes a pryv.Connection and a parameters object. All return Promises. The full TypeScript signatures live in src/index.d.ts.
Provider side (the requester)
const invite = await cmc.createInvite(conn, {
appCode: 'my-app',
scopeStreamId: ':_cmc:apps:my-app:study-1',
displayName: 'Dr. Alice',
requestedPermissions: [{ streamId: 'fertility', level: 'read' }],
mode: 'single-use', // or 'open-link'
consent: { en: 'I consent to share data for this study.' },
features: { chat: true, systemMessaging: true }
});
// invite = { inviteEventId, capabilityUrl, mode, expiresAt }
const arrived = await cmc.waitForAccept(conn, {
fromUsername: 'bob', // identify the expected accepter
appCode: 'my-app',
timeoutMs: 15000
});
// arrived = { acceptInboxEventId, grantedAccessApiEndpoint, counterparty, features }
// grantedAccessApiEndpoint is the data-grant access on bob's account —
// use it to query bob's data.
await cmc.revokeRelationship(conn, {
inviteEventId: invite.inviteEventId, // OR pass { accessId, scopeStreamId } directly
reason: { en: 'study completed' }
});
// Open-link mode only (Phase 2 lifecycle):
await cmc.invalidateCapability(conn, {
inviteEventId: invite.inviteEventId,
reason: { en: 'study cohort closed' }
});
const list = await cmc.listInvites(conn, { appCode: 'my-app', limit: 1000 });
// list = { items: InviteRecord[], truncated: boolean }
const one = await cmc.getInviteStatus(conn, invite.inviteEventId);
const update = await cmc.proposeScopeUpdate(conn, {
collectorStreamId: ':_cmc:apps:my-app:collectors:bob--pryv-me',
newPermissions: [{ streamId: 'fertility', level: 'read' }, { streamId: 'cycle', level: 'read' }],
message: { en: 'Adding cycle stream for sub-study.' }
});
// update = { scopeRequestEventId }
// (renamed in 3.9.0 from `requestScopeUpdate` — that name now refers to
// the user-side accept hand-off helper. Old name removed; importers
// must use `proposeScopeUpdate`.)Consumer side (the accepter)
Token class.
consent/accept-cmcandconsent/scope-update-cmcwrites require a personal access token server-side (Pryv.io rejects app/shared tokens with400 invalid-operation+error.data.id === 'cmc-accept-requires-personal-token'). If your app holds only an app/shared token, use the accept hand-off helpers instead of callingacceptInvite/acceptScopeUpdatedirectly.Revoke uses the standard access-permission gate, not the personal-token gate.
consent/revoke-cmcis checked againstAccessLogic.canDeleteAccess(which honours theselfRevokefeature permission on the target access). Apps holding a relationship's data-grant access can self-revoke directly viacmc.revokeAcceptance(...)/cmc.revokeRelationship(...)— no hand-off needed. The server rejects unauthorised revokes witherror.data.id === 'cmc-revoke-forbidden'.
const offer = await cmc.readOffer(capabilityUrl);
// offer = { requester: { username, host, displayName }, consent, requestedPermissions, mode, features }
const accepted = await cmc.acceptInvite(conn, capabilityUrl, {
scopeStreamId: ':_cmc:apps:my-app', // REQUIRED — own scope stream where the trigger lands.
extra: { chat: true, systemMessaging: true },
accessName: 'my-app-grant'
});
// accepted = { acceptEventId, dataGrantAccessId, counterparty, features }
// Waits for Phase-2 completion by default (~50-200ms).
// Pass `waitForCompletion: false` to return immediately as { ..., status: 'pending' }.
// Requires `conn` to be authenticated with a personal token (see hand-off helpers below).
await cmc.refuseInvite(conn, capabilityUrl, {
scopeStreamId: ':_cmc:apps:my-app',
reason: { en: 'not interested' }
});
await cmc.revokeAcceptance(conn, {
scopeStreamId: ':_cmc:apps:my-app',
accessId: accepted.dataGrantAccessId,
reason: { en: 'opting out' }
});
const relationships = await cmc.listAcceptedRelationships(conn, { appCode: 'my-app' });
// relationships = RelationshipRecord[]
// Respond to a scope-update request from the provider:
await cmc.acceptScopeUpdate(conn, scopeRequestEventId);
await cmc.refuseScopeUpdate(conn, scopeRequestEventId, { reason: { en: 'no thanks' } });Accept hand-off (app without a personal token)
acceptInvite and acceptScopeUpdate post triggers that the server gates to personal tokens only. If your app holds only an app/shared access, hand the user off to app-web-auth3's /cmc-accept or /cmc-scope-update page: the user authenticates with their own credentials, the page writes the trigger with the fresh personal token, and the result is returned to your app via popup postMessage or returnUrl redirect.
Revoke does NOT need a hand-off — cmc.revokeAcceptance / cmc.revokeRelationship go through the standard AccessLogic.canDeleteAccess rule (which honours the selfRevoke feature permission on the target). Apps holding the relationship's data-grant access can self-revoke from any token class.
Two helper pairs, both ship in @pryv/[email protected]:
// 1. URL only (caller drives navigation — custom popup, mobile deep-link, …).
const url = cmc.requestAcceptUrl({
authUrl: 'https://access.pryv.me/access/v3/cmc-accept', // /cmc-accept route on the deployed app-web-auth3
pryvApi: 'https://reg.pryv.me/', // recipient's Pryv API base
capabilityUrl, // from the requester's invite (out-of-band)
scopeStreamId: ':_cmc:apps:my-app', // recipient's own :_cmc:apps:* stream
accessName: 'my-app-grant' // optional
// returnUrl: 'https://app.example.com/accepted' // switches to redirect mode
});
// 2. Popup + postMessage (browser-only).
const result = await cmc.requestAccept({
authUrl,
pryvApi,
capabilityUrl,
scopeStreamId: ':_cmc:apps:my-app',
// mode: 'popup' is the default. timeoutMs default: 10 min.
});
// result = { ok: true, dataGrantApiEndpoint, acceptEventId }
// Rejects with CmcError (id: 'cmc-accept-popup-closed' | 'cmc-accept-popup-blocked'
// | 'cmc-accept-timeout' | the server's `failure.reason` on ok:false).
// Or redirect mode (full-page navigation; the page returns by re-navigating to returnUrl):
await cmc.requestAccept({
authUrl, pryvApi, capabilityUrl, scopeStreamId: ':_cmc:apps:my-app',
returnUrl: 'https://app.example.com/accepted' // → location.assign(returnUrl?cmcAcceptResult=<json>)
});Scope-update hand-off (same shape, different page):
// Browser flow — open the auth page and wait for the user to accept / refuse.
const result = await cmc.requestScopeUpdate({
authUrl: 'https://access.pryv.me/access/v3/cmc-scope-update', // /cmc-scope-update route
pryvApi: 'https://reg.pryv.me/',
scopeRequestEventId: 'evt-scope-req-abc123', // from the collector's proposal
// scopeStreamId is optional — defaults to the scope-request event's home stream
});
// result = { ok: true, updateEventId, action: 'accept' | 'refuse' }
// Rejects with CmcError (id: 'cmc-scope-update-popup-closed' | 'cmc-scope-update-popup-blocked'
// | 'cmc-scope-update-timeout' | the server's `failure.reason` on ok:false).
// URL-only (caller drives navigation):
const url = cmc.requestScopeUpdateUrl({
authUrl, pryvApi, scopeRequestEventId, returnUrl: 'https://app.example.com/done'
});The /cmc-accept page is part of app-web-auth3; operators serving Pryv.io's auth pages get it automatically. The result payload (popup or redirect) carries { type: 'cmc-accept-result', ok, dataGrantApiEndpoint, acceptEventId }.
Cross-direction (chat, system messages)
await cmc.sendChat(conn, {
scopeStreamId: ':_cmc:apps:my-app',
peerSlug: cmc.counterpartySlug({ username: 'bob', host: 'pryv.me' }),
content: 'Hello Bob.'
});
// Schema: { content: string } (1-10240 chars per data-types/message.json#chat-cmc).
await cmc.sendSystemAlert(conn, {
scopeStreamId: ':_cmc:apps:my-app',
peerSlug,
level: 'warning',
title: { en: 'Battery low' },
body: { en: 'Charge device before next sync.' },
ackRequired: true,
ackId: 'BAT-001'
});
await cmc.sendSystemAck(conn, {
scopeStreamId: ':_cmc:apps:my-app',
peerSlug,
alertEventId,
ackId: 'BAT-001'
});Observation scopes
Use these helpers to build the Monitor scope for the add-on @pryv/monitor:
const inboxScope = cmc.scopes.inbox();
// → { streams: [':_cmc:inbox'] }
const chatsScope = cmc.scopes.chats({ appCode: 'my-app' });
// → { streams: [':_cmc:apps:my-app:chats'] } (recursive)
const peerScope = cmc.scopes.chats({ appCode: 'my-app', peerSlug: 'bob--pryv-me' });
// → { streams: [':_cmc:apps:my-app:chats:bob--pryv-me'] } (narrowed)
const collectorsScope = cmc.scopes.collectors({ appCode: 'my-app', peerSlug });Plug them into a pryv.Monitor to receive live updates:
const monitor = new pryv.Monitor(conn, cmc.scopes.inbox())
.on('event', (event) => {
if (event.type === cmc.ET_ACCEPT) { /* a peer accepted */ }
if (event.type === cmc.ET_REVOKE) { /* a peer revoked */ }
});
await monitor.start();Examples
Full handshake (provider → consumer)
const pryv = require('pryv');
const cmc = require('@pryv/cmc');
const provider = new pryv.Connection('https://[email protected]/');
const consumer = new pryv.Connection('https://[email protected]/');
// Alice issues an invite.
const invite = await cmc.createInvite(provider, {
appCode: 'my-app',
scopeStreamId: ':_cmc:apps:my-app:study-1',
displayName: 'Dr. Alice',
requestedPermissions: [{ streamId: 'fertility', level: 'read' }],
consent: { en: 'I consent to share fertility data for this study.' },
features: { chat: true }
});
// (Share invite.capabilityUrl with Bob out-of-band — QR / link / email.)
// Bob accepts.
const accepted = await cmc.acceptInvite(consumer, invite.capabilityUrl, {
scopeStreamId: ':_cmc:apps:my-app',
extra: { chat: true }
});
// Alice waits for Bob's accept arrival + reads Bob's data.
const arrived = await cmc.waitForAccept(provider, {
fromUsername: accepted.counterparty.username,
appCode: 'my-app'
});
const peerConn = new pryv.Connection(arrived.grantedAccessApiEndpoint);
const data = await peerConn.api([{ method: 'events.get', params: { streams: ['fertility'], limit: 100 } }]);Known limitation — waitForAccept identification
The accept arrival on :_cmc:inbox does not carry the requester's inviteEventId (the server-side originalEventId field is the capability-internal offer event id, not the trigger). cmc.waitForAccept therefore matches by from.{username, host} + requesterAppCode instead. If you don't know the accepter's username ahead of time (e.g. open-link mode where anyone with the URL may accept), poll the inbox yourself using events.get with streams: [cmc.NS_INBOX] and filter on what you do know.
Contributing
See the Pryv JavaScript library README
