Cross-account Messaging & Consent (CMC)

This guide describes how to use CMC — Pryv.io’s built-in protocol for federated, cross-account consent, chat and system notifications. With CMC, two Pryv accounts (which may live on different platforms) can mutually issue and receive data-grants, exchange chat messages, and send system alerts — all on top of standard Pryv events / streams / accesses.

It complements the Consent request guide, which covers the classical single-account consent flow (one app obtaining an access token on one user’s account). CMC is what you reach for when consent flows BETWEEN two end-user accounts.

Table of contents

  1. When to use CMC
  2. Concepts
  3. Streams reserved by the plugin
  4. Event types
  5. The handshake — a worked example
  6. Sending chat messages
  7. Sending system notifications
  8. Revoking
  9. Lib-js helpers
  10. Further reading

When to use CMC

Use CMC when:

If your use-case is “one app authenticating to one user’s account”, stick with the standard access-request flow.

Concepts

A CMC interaction always involves two parties:

The handshake creates two paired accesses:

Together these two accesses form a CMC consent. Either party may revoke at any time.

Streams reserved by the plugin

The plugin auto-provisions a small reserved namespace on every account on first CMC use:

:_cmc:                      reserved root
  :_cmc:inbox               one-shot lifecycle delivery (consent/* events from peers)
  :_cmc:apps                parent of user-creatable app scopes
    :_cmc:apps:<app-code>   user-creatable, one per app
      <user-defined paths>  e.g. :study-A, :campaign-2026
        :chats              auto-created at acceptance time
          :chats:<peer>     one chat thread per peer
        :collectors         auto-created at acceptance time
          :collectors:<peer> one system channel per peer
  :_cmc:_internal           plugin-internal hidden region (capability mint, retry queue)

Apps must NEVER write to :_cmc:_internal:*. They write to their own :_cmc:apps:<app-code>:* streams; the plugin handles everything inside :_cmc:_internal:* and :_cmc:inbox.

Event types

CMC types follow the Pryv <class>/<format> convention. Implementation formats are suffixed with -cmc so the data-types directory groups CMC entries together within shared classes.

Type When you write it
consent/request-cmc Requester writes to start a request. The plugin mints a capability URL.
consent/accept-cmc Accepter writes to accept (carries the capability URL from the request).
consent/refuse-cmc Accepter writes to refuse.
consent/revoke-cmc Either party writes to revoke an established consent.
message/chat-cmc Either party writes a chat to their per-peer chat stream.
notification/alert-cmc Either party sends a system alert (level + title + body).
notification/ack-cmc Acknowledge a previously-received alert.
consent/scope-request-cmc Collector proposes a scope change.
consent/scope-update-cmc User-side accepts / applies a scope change.
consent/back-channel-cmc Plugin-internal handshake step. Apps don’t write these.

consent/back-channel-cmc is not app-facing — the plugin emits and consumes it transparently as part of the handshake.

The handshake — a worked example

Imagine Alice (a study participant) wants to grant Bob (a research collector) read access to her fertility stream, with chat enabled.

1. Alice creates an app-scope stream. Once per app:

await aliceConn.api([{ method: 'streams.create', params: {
  id: ':_cmc:apps:my-study', parentId: ':_cmc:apps', name: 'My Study'
}}]);
// Optionally a per-request sub-path for finer-grained scoping:
await aliceConn.api([{ method: 'streams.create', params: {
  id: ':_cmc:apps:my-study:cohort-2026', parentId: ':_cmc:apps:my-study', name: 'Cohort 2026'
}}]);

2. Alice writes the consent request. This triggers the capability mint:

const res = await aliceConn.api([{ method: 'events.create', params: {
  streamIds: [':_cmc:apps:my-study:cohort-2026'],
  type: 'consent/request-cmc',
  content: {
    to: null,                               // null = open invite via capability URL
    capabilityRequested: true,
    request: {
      title:       { en: 'Cohort 2026 — share fertility data' },
      description: { en: 'Sharing fertility data with the cohort 2026 research team.' },
      consent:     { en: 'I consent to share my fertility data for cohort 2026 research.' },
      permissions: [ { streamId: 'fertility', level: 'read' } ]
    },
    requesterMeta: { username: 'alice', appId: 'my-study' }
  }
}}]);
const triggerId = res[0].event.id;

The plugin stamps content.capabilityUrl on the trigger event within milliseconds. Alice’s app reads it back and shares it with Bob (via email, QR code, etc.).

3. Bob accepts via the capability URL:

await bobConn.api([{ method: 'events.create', params: {
  streamIds: [':_cmc:apps:my-study'],   // Bob's local app-scope stream
  type: 'consent/accept-cmc',
  content: { capabilityUrl, accessName: 'cmc-cohort-2026' }
}}]);

The plugin on Bob’s side:

4. Alice’s side automatically:

Alice’s app subscribes to :_cmc:inbox to be notified:

const aliceConn2 = new pryv.Connection(aliceApiEndpoint);
const monitor = aliceConn2.monitor({ streams: [':_cmc:inbox'] });
monitor.on('event', (event) => {
  if (event.type === 'consent/accept-cmc' && event.content?.from?.username === 'bob') {
    console.log('Bob accepted! Data-grant URL:', event.content.grantedAccess.apiEndpoint);
  }
});

After the handshake, both sides have:

Sending chat messages

To chat, write message/chat-cmc to your per-peer chat stream:

const cmc = require('@pryv/cmc');
const peerSlug = cmc.counterpartySlug({ username: 'bob', host: 'pryv.example' });
const myChatStream = cmc.chatStreamUnder(':_cmc:apps:my-study:cohort-2026', peerSlug);

await aliceConn.api([{ method: 'events.create', params: {
  streamIds: [myChatStream],
  type: 'message/chat-cmc',
  content: { content: 'Hello from Alice' }
}}]);

The plugin delivers the chat to Bob’s matching chat stream within ~100ms. Bob’s app subscribes to the same stream-id pattern (with Alice’s slug) to read incoming chats.

Features gating. If the original invite was issued with content.request.features.chat: false, both sides’ events.create rejects with cmc-chat-disabled and no delivery happens. The flag is binding on the relationship’s lifetime; default-permit on omission. Use cmc.sendChat() for the lifecycle-aware wrapper that surfaces the rejection as a CmcError({ id: cmc.errorIds.CHAT_DISABLED }).

Sending system notifications

System notifications carry richer structure than chats — a level (info / warning / critical), localised title + body, and optionally an ack-request:

const myCollectorStream = cmc.collectorStreamUnder(':_cmc:apps:my-study:cohort-2026', peerSlug);

await collectorConn.api([{ method: 'events.create', params: {
  streamIds: [myCollectorStream],
  type: 'notification/alert-cmc',
  content: {
    level: 'warning',
    title: { en: 'Daily survey reminder' },
    body:  { en: 'You haven\'t submitted today\'s survey yet.' },
    code:  'survey-reminder',
    ackRequired: true
  }
}}]);

If ackRequired is true, the recipient sends a notification/ack-cmc back referencing the alert event-id.

Features gating. Mirrors the chat behaviour: features.systemMessaging: false on the original invite blocks notification/alert-cmc + notification/ack-cmc sends with cmc-system-messaging-disabled. consent/scope-request-cmc and consent/scope-update-cmc are protocol-level and remain permitted regardless of the flag.

Revoking

Either party can revoke the consent at any time:

await aliceConn.api([{ method: 'events.create', params: {
  streamIds: [':_cmc:apps:my-study:cohort-2026'],
  type: 'consent/revoke-cmc',
  content: {
    accessId: backChannelAccessId,         // the local access being revoked
    reason: { en: 'study complete' }
  }
}}]);

The plugin tears down both sides of the access pair. The chat / collectors history is preserved (events are not deleted) but no further messages will be delivered.

Lib-js helpers

CMC client helpers live in the sibling package @pryv/cmc — install alongside pryv:

npm install pryv @pryv/cmc
const pryv = require('pryv');
const cmc = require('@pryv/cmc');

// Level-0 — pure helpers (no network):
cmc.NS;                                                                  // ':_cmc:'
cmc.appScope('my-app');                                                  // ':_cmc:apps:my-app'
cmc.counterpartySlug({ username: 'bob', host: 'pryv.example' });         // 'bob--pryv-example'
cmc.chatStreamUnder(':_cmc:apps:my-app:study-A', 'bob--pryv-example');
// → ':_cmc:apps:my-app:study-A:chats:bob--pryv-example'

// Level-1 — lifecycle wrappers (take a pryv.Connection):
const conn = new pryv.Connection(aliceApiEndpoint);

// Provider issues an invite (writes consent/request-cmc + waits for capabilityUrl).
const { inviteEventId, capabilityUrl } = await cmc.createInvite(conn, {
  appCode: 'my-study',
  scopeStreamId: ':_cmc:apps:my-study:cohort-2026',
  displayName: 'My study',
  requestedPermissions: [{ streamId: 'fertility', level: 'read' }],
  mode: 'single-use',
  // Optional per-invite TTL override — server bounds to [60s, 30d];
  // omit for the 7-day default. Out-of-range rejects with
  // cmc-capability-ttl-out-of-range.
  // expiresAt: Math.floor(Date.now() / 1000) + 3600,
  // Optional features negotiation — omitted defaults to true for both.
  // Setting either to false makes that channel binding-disabled for the
  // resulting relationship; sends will reject with cmc-chat-disabled /
  // cmc-system-messaging-disabled.
  features: { chat: true, systemMessaging: true },
});

// Accepter accepts (returns local data-grant access id + counterparty identity).
const { dataGrantAccessId } = await cmc.acceptInvite(bobConn, capabilityUrl, {
  scopeStreamId: ':_cmc:apps:my-study',
});

// Provider polls inbox for the accept arrival.
const { grantedAccessApiEndpoint } = await cmc.waitForAccept(conn, {
  fromUsername: 'bob', appCode: 'my-study', timeoutMs: 15000,
});

// Either side can chat / alert / revoke:
await cmc.sendChat(conn, { scopeStreamId, peerSlug, content: 'Hello' });
await cmc.sendSystemAlert(conn, { scopeStreamId, peerSlug, level: 'info',
  title: { en: 'Reminder' }, body: { en: 'Daily survey reminder' } });
await cmc.revokeRelationship(conn, { inviteEventId });
// or revoke from accepter side by data-grant access id:
await cmc.revokeAcceptance(bobConn, { scopeStreamId, accessId: dataGrantAccessId });

// Frozen catalogue mirroring the server-side error ids.
cmc.errorIds.CAPABILITY_TTL_OUT_OF_RANGE;     // 'cmc-capability-ttl-out-of-range'
cmc.errorIds.CHAT_DISABLED;                   // 'cmc-chat-disabled'
cmc.errorIds.SYSTEM_MESSAGING_DISABLED;       // 'cmc-system-messaging-disabled'
cmc.errorIds.CLIENTDATA_CMC_FORBIDDEN;        // 'cmc-clientdata-cmc-forbidden'
cmc.errorIds.RESERVED_STREAM_UNDELETABLE;     // 'cmc-reserved-stream-undeletable'
cmc.errorIds.COUNTERPARTY_IDENTITY_MISSING;   // 'cmc-counterparty-identity-missing'
// + the lifecycle / handler / chat-routing ids — see source for the full list.

Full surface + JSDoc: @pryv/cmc/src/index.js. Mirror of the server-side CmcErrorIds lives at components/cmc/src/errorIds.ts.

Further reading