open-pryv.io

Changelog - API Changes

2.0.0-rc.24 — 2026-09-22

Ceilings on the access requests a core holds at once (security)

POST /reg/access needs no credentials, and each request it creates is held in the core’s memory for up to an hour, so a flood of calls could grow that process until it died. Two ceilings now bound it:

Both counts are per core, like the requests themselves, and expired or decided requests stop counting once their window passes. This is a last line of defence: configure a rate limit for /reg/access in the reverse proxy in front of the core as well.

Third-party sign-in keeps the app’s return context

GET /auth/sso/:provider/start accepts an optional ssoReturn query parameter: an opaque string of at most 2048 characters in the form-urlencoded alphabet that the auth app uses to remember where the user came from (its own returnURL, state, requestingAppId, next). The core stores it in the signed state cookie and, once that cookie has verified at the callback, hands it back unchanged as ssoReturn on the landing page’s URL fragment, on every outcome (login, mfa, error). The core never interprets it and still redirects only to sso.landingPageURL; a value that is too long or malformed is refused with 400.

A start without ssoReturn behaves exactly as before, and a landing page that does not know the key ignores it. Before this, an app that sent a user through third-party sign-in got no completion redirect back: the query it started from was dropped at the first hop, so the user landed on the account profile instead of wherever the app had sent them.

An OAuth client can be registered under an opaque client_id

client_id was always the app account’s username, and that id is a key in the replicated platform store (the client record, its revocation tombstone, the DPoP keys seen) as well as travelling in URLs and in the name of the access a grant mints. On a deployment that keeps usernames out of the platform store, the username went in anyway.

bin/oauth-client.js create <username> now takes --client-id <opaque-id> (4 to 64 characters of A-Z a-z 0-9 . ~ -; _ is excluded because the store scans keys with SQL LIKE, where it is a wildcard). An id already registered, or one that is an account name on the platform, is refused. The record still points at the account, by user id, and client_name then defaults to the id rather than the username.

Two things still name the account unless the operator acts: client_name (pass --name) and the capability URL of a cmc: offer, which is built from the publishing account’s API endpoint and is needed by every authorization-code client.

Nothing changes for existing clients: without the flag the client_id is the username as before, and create now says so on stdout. client_id cannot be changed on an existing record (update refuses the flag), so moving an app to an opaque id means revoking and re-creating it: live tokens and refresh chains die and users re-run the authorization flow, while their consent records survive (keyed by the offer, not by the client).

2.0.0-rc.23 — 2026-09-18

Managed shared accesses no longer outlive their managing app access (security)

The expiry chain (a shared access managed by an app access cannot expire later than that app access) treated a shared access without expiry as within the limit, so a shared token could outlive the app access that issued it.

Account delegation: granting an app access for a controlled account

A delegate (a parent, a caregiver) can now grant an app access on an account it controls, the way the account owner grants one: an auth page authenticated with the delegate token creates the app access on the controlled account and posts it back.

bin/oauth-client.js create runs on the app account’s home core

The OAuth client record now stores the app account’s user id instead of its username, so create <username> must run on the core that hosts the account; on a multi-core platform it refuses elsewhere and names the hosting core (it used to answer “user not found”). show prints the stored accountUserId plus the account username resolved on the core it runs on; update converts a record written by an earlier version when the account is local. /oauth2/token wire shapes are unchanged. The CLI also no longer claims that re-registering a revoked client id clears the revocation (it does not: tokens minted before the revoke stay dead).

Credential hand-off: one-time shared-secret delivery for /reg/access

An app can ask that its access token be delivered through a one-time shared secret instead of being returned in the authorization poll, so the token never lingers in the poll response (or in the logs of the core that answered the authorization request) and a theft becomes detectable (the legitimate retrieve fails loudly).

Service info: account and features.delegation

2.0.0-rc.22 — 2026-09-18

service/info.version now reports the release on native installs too

2.0.0-rc.21 fixed the reported version for the Docker image only: a native install that checks out a release tag still reported 2.0.0-pre.4 in service/info.version, the API-Version header and meta.apiVersion, because it reads the committed .api-version file. From this release on, the release commit carries the tag in that file (the tag build refuses to publish otherwise), so native installs report the release they run. Reported in #135.

Security: credentials no longer stored in the platform store replicated to every core

The platform store (rqlite) is replicated to every core of a platform, on disk. Until this release it held live credentials: the app token of every accepted /reg/access request (with the username, for up to one hour), and the OAuth2 authorization codes, refresh tokens (valid up to 90 days) and pre-minted access tokens. Anyone able to read one core’s platform data, or a backup of it, could use them against accounts hosted on any core. Operators of multi-core platforms should upgrade every core.

/reg/access: expireAfter, deviceName and token reach the auth page again

Native installs: use Node.js 24 below 24.19.0

On Node.js 24.19.0 and later (confirmed on 24.21), the SQLite driver installed on that Node version aborts the whole process when a SQLite statement is garbage-collected (RemoveEnvironmentCleanupHook ... Assertion (env) != nullptr, nodejs/node#65446). SQLite is the default audit engine, so a native (non-Docker) install on such a Node version can crash at any time under normal use. engines.node is now >=24.0.0 <24.19.0, and INSTALL.md shows how to install and hold a suitable version.

Check your hosts: run node -v; if it reports 24.19.0 or later, downgrade to 24.18.x and reinstall the dependencies (npm install --ignore-scripts && npm rebuild) so the SQLite driver is rebuilt against the older headers. A NodeSource setup_24.x install or a routine apt upgrade lands on the latest 24.x. The Docker image is not affected (it pins Node 24.18.0).

Fixed

2.0.0-rc.21 — 2026-09-17

CMC: an invite reports its outcome, and a refusal reaches the requester

An open-link invite (capability.mode: 'open-link') may now be published with request.expiresAt: null: the capability access is minted with no expiry and the link keeps working until the requester ends it with consent/invalidate-link-cmc. The trigger reports capabilityExpiresAt: null. Until now every capability was capped at 30 days, which forced a public registration link to be re-minted and republished monthly. Reported in #137.

contact/facebook, audiogram/data and clinical/fhir events are accepted again

The schemas of these three event types in the event-types catalogue were malformed (a string where a boolean belongs, and misnested properties), so the validator could not compile them and every event of these types was refused with invalid-parameters-format, whatever its content. The published catalogue is repaired and both copies bundled with the server are refreshed from it: valid events of these types are now accepted, and their content is validated (contact/facebook requires id; audiogram/data requires sensitivityPoints, start and end, each point a frequency; clinical/fhir requires displayName and clinicalType, and its fhir object identifier and resourceType). A core running an earlier release that loads the published catalogue at startup is fixed for contact/facebook only: it merged the download into its bundled list deeply, which kept the misplaced keys of the other two types. A core now applies each downloaded type (and extras, classes and sets entry) whole instead, still keeping entries the download does not carry, so a schema repaired upstream reaches it. The catalogue’s numset/* schema was also rewritten; numset/... types remain unvalidated, as before.

HF series requests on raw deploys answer 504 when the HFS worker stalls

On deployments where the API process itself routes HF series traffic to the co-located HFS worker (no nginx in front), a worker that stays silent for 60 s on a request now gets that request answered with 504 and the JSON error unexpected-error (“HFS upstream timed out”), or the response cut if the worker stalls after it started answering. Before, the client waited until its own timeout. The bound is idle time on the worker connection, so long uploads and long query answers whose bytes keep flowing are never cut; a client that itself stops sending or reading for 60 s is cut the same way. It matches the 60 s the documented nginx front applies to the same traffic. nginx-fronted deployments are unaffected.

Event content validation no longer falls back to a 2023 type list

A core validates event content against its built-in event-type list until its startup download of the published catalogue succeeds, and for as long as it runs if that download fails. The built-in list dated from October 2023, so on a core that could not reach the catalogue, and on every core during its startup window:

Cores that reached the catalogue at startup already behaved this way after the download, so nothing changes for them past startup. The legacy density/g-dl, density/mmol-l and density/mg-dl types, renamed to concentration/* in the catalogue, remain accepted everywhere.

CMC: two legacy lookups read the wrong event

Two fallback paths looked an event up by id with a query that does not filter on id, so they read the account’s newest event instead:

CMC: approving a collector’s scope request now changes the grant

A user answering a collector’s consent/scope-request-cmc with consent/scope-update-cmc { scopeRequestEventId, accept: true } saw the trigger reach status: 'completed', and the approval page report success, while the data-grant kept its old permissions: only answers that restated accessId and newPermissions were applied, and completed reflected delivery to the collector, not a change. Reported in #136.

Now:

CMC: accepting no longer fails when the app scope stream does not exist

consent/accept-cmc written on a :_cmc:apps:<app>[:<path>] stream the accepter never created failed with unknown-referenced-resource, naming the stream rather than the cause, for every participant of a collector that had not arranged for it. When written with a personal token, consent/accept-cmc and consent/refuse-cmc now create the missing scope chain (marked clientData.cmc.autoProvisioned). App and shared tokens get no provisioning. Reported in pryv/app-web-user-account#2.

POST /reg/access accepts one new optional top-level object:

{
  "requestingAppId": "my-app",
  "requestedPermissions": [
    { "streamId": "diary",  "level": "read", "defaultName": "Journal" },
    { "streamId": "weight", "level": "read", "defaultName": "Weight" }
  ],
  "consent": { "allowUserChoice": true, "mandatory": ["diary"], "optIn": ["weight"] }
}

consent.mandatory and consent.optIn name permission ids (a stream permission’s streamId, a feature permission’s feature); consent.allowUserChoice means the same thing it means in an OAuth2 or CMC offer. The annotations travel BESIDE the entries rather than inside them so that requestedPermissions stays exactly what it has always been: the auth page forwards those entries verbatim to accesses.checkApp, whose schema rejects unknown per-entry fields, and an annotation written inside an entry would fail there against servers and auth pages already deployed.

The server resolves the pair into a consent form and echoes it, as consent, on the 201 and on the NEED_SIGNIN poll. That echo is also how an app can tell whether the server understood the annotations: an older core ignores the field and answers without it, and the flow degrades to all-or-nothing rather than failing.

POST /reg/access/:key with status: ACCEPTED now verifies the grant, but only for a request that carried a consent sidecar. The server reads the access behind the posted token (locally, or on the user’s own core when the platform hosts them elsewhere, never at the host named in the posted apiEndpoint) and checks it against the consent form with the same rule the OAuth2 and CMC accept paths use. Two new answers:

A request without consent is unaffected in every respect: no new validation on create, no consent key in either response body (absent, not null), and no check on accept, so the long-standing opaque-token contract of the accept endpoint still holds for existing integrator UIs.

A permission entry in a consent request or offer (consent/request-cmc, consent/scope-request-cmc, and the offer embedded in an OAuth2 authorization) may now carry optIn: true beside the existing mandatory: true. The two annotations give a requester three ways to present an entry to the user:

annotation meaning on the consent screen
mandatory: true required: the user cannot leave it out, the screen locks it
neither optional, shown pre-selected (unchanged: what every optional entry does today)
optIn: true optional, shown NOT pre-selected

optIn is display-only. It decides how the screen opens, never what may be granted, so the accept check returns the same verdict with or without it: an opt-in entry the user leaves unticked is simply an entry that is not in the grant. Setting both annotations on one entry is a contradiction and is rejected where the offer is read (400 invalid_scope on the OAuth2 path, cmc-offer-invalid-permissions on the CMC path).

Nothing changes for a request that carries no annotation, and neither annotation ever reaches a minted access: both are stripped before the access is created. Cherry-picking still requires the offer’s allowUserChoice; without it a consent remains all-or-nothing.

SECURITY — the PostgreSQL audit engine returned audit rows across accesses

Reading the audit trail applied no stream filter when storages.audit.engine is postgresql. Any access could therefore retrieve the account’s audit rows for other accesses: an app granted one narrow permission could see which API methods the account owner called, when, and with what query. Accounts on the default sqlite audit engine were never affected, and no data outside the audit trail was exposed.

Who is affected: deployments where storages.audit.engine is postgresql. The install wizard selects that engine whenever PostgreSQL is chosen, so a platform installed with PostgreSQL through the wizard is affected unless the setting was changed. Check storages.audit.engine in your configuration; if it is sqlite (the default), you were not affected.

The filter was read as a flat list while every store is handed the normalised nested form, so no condition was built — and the code treated “no condition” as “no filter” rather than as an error. Fixed by reading the normalised form, and by making an unreadable filter deny instead of returning everything: a filter that degrades to “return all rows” is the wrong failure mode for an authorization boundary.

The same change anchors stream-id matching between separators. Before it, a stream id that was a suffix of another could match it.

No action is required beyond upgrading; no stored data is altered.

Cross-core delegation no longer requires an explicit core.url

On a multi-core platform, every cross-core delegations.* call failed with 400 delegation-unknown-core (“Could not resolve the delegate account core endpoint”) unless the operator had configured an explicit core.url on each core. Resolving the delegate’s core read the peer’s registry entry directly, where a URL is recorded only when that peer was given an explicit core.url — which neither the configuration wizard nor the bootstrap bundle writes. So on a dns-active platform the lookup could not succeed, and the relationship could never be created. Same-core delegation was unaffected.

Resolution now goes through the same helper the rest of the API uses, which prefers a peer’s advertised URL and otherwise derives it from the core id and the platform DNS domain. Deployments that had set core.url as a workaround keep working unchanged and may now drop it. Where neither an advertised URL nor a domain is available the call is still refused with delegation-unknown-core, rather than being delivered to the calling core itself. Reported via #134.

Account email can no longer be written through the events API

accesses.update now validates account-stream permissions like accesses.create

Account-stream permissions: clearer error, corrected docs

Docs corrected

Correction to the “System streams refactor” notes further down: they listed :_system:email among unchanged system stream ids. That id does not exist. The email account field is platform-defined and its id is :system:email (customer prefix); only built-in fields such as language, appId, invitationToken, referer and storageUsed take :_system:. The original line is annotated in place. The same wrong spelling has been corrected in the CMC README, in the email constants module header, and in the account datastore’s field-name examples. Reported via #131.

service/info.version now reports the released build, not a frozen value

GET /service/info returned version: "2.0.0-pre.4" on every Docker release, and the same stale value went out as the API-Version response header and as meta.apiVersion on every response body. The value is there for capability negotiation (SDKs branch on >= 1.6.0), so a version that never advanced meant a client could not tell which build a core was running, and any future version gate would have compared against a frozen number.

A core that cannot load the event-types dictionary now refuses unknown types

If the boot-time fetch of the published event-types dictionary (service.eventTypes) failed, the core started up healthy and ran for the rest of its lifetime on the embedded fallback set. Because unknown types were accepted without content validation, this surfaced as a silent, permanent loss of validation rather than an error: every type present in the published dictionary but absent from the embedded set was written with no schema check.

2.0.0-rc.20 — 2026-09-15

CMC: a revocation now ends both halves of the relationship

Withdrawing consent was only half enforced. Each side deleted the access the PEER was using against its own account, but the access the withdrawing side itself held on the peer’s account survived, because the receiving server ran no teardown. So after a withdrawal both parties considered the relationship over while a live token still read the counterparty’s data, until that side’s app got around to deleting it. The implementers’ guide documented this and asked integrators to delete their own half; that is no longer necessary.

CMC: forwarded revocations carry ids the receiving side can match

content.accessId on a revoke arrival is the sender’s access id on the sender’s own account, so it matches nothing the receiver holds. The receiving server now adds the receiver’s own handles for the relationship, using the names each side’s app already knows: backChannelAccessId + inviteEventId on the requester side, dataGrantAccessId + offerEventId + acceptEventId on the accepter side, and on both scopeStreamId (derived from the receiver’s own state, not the peer’s claim) plus revokedAccessIds, the accesses the teardown destroyed.

Ids that cannot be resolved are absent rather than null, a value the peer supplied is never overwritten, and accessId keeps its meaning. The requester’s back-channel access is also stamped with offerEventId / inviteEventId at mint, so a peer running an older build still receives something matchable.

2.0.0-rc.18 — 2026-09-15

Account delegation — guardian/caregiver-controlled accounts (delegation:active, default on)

An account can now be controlled by one or more other accounts (“delegates”) — for example a parent managing a child’s account until majority, or a trusted adult managing a dependent person’s account. A delegate holds a personal-class token over the controlled account that is owner-equivalent for data and account management, with one reserved exception: it cannot remove a delegation. Removing a delegation (“detach”) requires a genuine login on the controlled account (a personal token obtained from that account’s own credentials), so the owner always keeps ultimate control.

This supersedes the 2.0.0-rc.17 entry “Email verification now ships OFF by default (beta)”: that entry stays as the record of what rc.17 did, but its guidance no longer applies. The “(beta)” qualifier on the rc.17 entry “Multiple emails per account (beta)” is likewise lifted: the feature, its templates, its verification page in the reference account app and its API surface are now general availability.

services.email.enabled.verifyEmail defaults to true. Upgrading does not stop a working configuration from booting:

“Mail is configured” is decided from config alone, with no SMTP probe: for method: in-process it means services.email.smtp.host is set; for microservice and mandrill it means services.email.url and services.email.key are set. The sender (services.email.from) is NOT part of that test — it matters for deliverability, but a deployment that was sending mail without one keeps sending mail. The same predicate now answers for the boot check, bin/check-config.js, the runtime send path and service.info, so those four can no longer disagree about whether a verification mail would go out.

GET /service/info now always carries features.emailVerification: { atRegistration, onAccount }. onAccount is true only when the verification-link flow is live on this platform (flag on, page URL set, mail configured); clients use it to show or hide “send verification link” actions. atRegistration is true when a verified address is required to create an account (see the registration gate entry).

Email verification at sign-up (optional)

Operators can now require a verified email address before an account is created. Off by default; a stock deployment is unchanged.

Mail templates now ship with the server

In-process mail (services.email.method: in-process) previously relied on an operator-provided Pug directory; the documented “bundled default set” did not exist, so a fresh deployment could not send any mail until templates were added by hand. The server now ships welcome-email, reset-password, verify-email and email-challenge templates in English and French and seeds them into PlatformDB on first boot when services.email.templatesRootDir is empty. Deployments that already hold templates are untouched (seeding only runs on an empty store). bin/mail.js templates seed defaults to the bundled set when --from is omitted.

2.0.0-rc.17 — 2026-09-11

Third-party sign-in (OIDC relying party) — OFF by default (beta)

Pryv.io can now act as an OpenID Connect client, letting an account holder sign in through an external identity provider (e.g. Google) that the operator configures. Inert unless enabled (sso.enabled: true) with at least one provider; a stock deployment is unaffected.

Fixes

2.0.0-rc.16 — 2026-09-04

(supersedes the 2.0.0-rc.15 tag, which was cut from a commit that failed CI and was never published.)

MFA: per-account failed-attempt limit (brute-force hardening)

The failed-second-factor limiter now also accrues PER ACCOUNT, not only per pending MFA session. Repeated wrong codes across repeated logins no longer reset the budget: once an account reaches services.mfa.attempts.perAccount failed verifications within perAccountWindowSeconds, the MFA step is locked for lockoutSeconds and mfa.verify, mfa.confirm and mfa.challenge return 429 too-many-attempts (with a Retry-After header). Password login itself is not locked, and already-issued access tokens keep working; only the second-factor step is throttled, so a password-holder cannot use it to lock a user out.

2.0.0-rc.14 — 2026-09-02

MFA: server-side TOTP (authenticator apps) enabled by default, over SMS

MFA is no longer SMS-only, and it is now on by default. A server-side TOTP factor (RFC 6238, authenticator apps such as Google Authenticator / 1Password) is built in and works out of the box with no configuration (in-process, no external service). It is the default method; SMS continues to work unchanged and stays off until an operator configures it.

Behavior change: MFA is active by default. services.mfa.active now ships true, so the mfa.* endpoints are live and any user can self-enrol TOTP. Nothing is forced: a user with no enrolled factor logs in exactly as before (login only challenges confirmed enrolments). To turn MFA off entirely, set services.mfa.active: false. Legacy deployments upgrade unchanged: a config with the legacy services.mfa.mode: single|challenge-verify takes precedence over the new default, so an SMS deployment keeps its SMS second factor (byte-identical) until it migrates off mode. auth.login now performs one extra profile read per login (to detect enrolment) that was previously skipped when MFA was off.

service-info advertises active MFA methods. service.info().features.mfa = { methods: [...] } lists the active methods, default-method first ([] when MFA is off; the field is absent only on older cores). Clients (e.g. the account UI) use it to offer only the methods the operator actually enabled rather than advertising SMS on a server with no SMS provider.

Migration note. A legacy services.mfa.mode: single|challenge-verify config keeps working unchanged: the normalizer gives mode precedence over the active-by-default, so SMS-enrolled users keep their SMS factor. To adopt the multi-method model (and gain TOTP), remove mode and use active/defaultMethod/methods. To keep MFA off after upgrading, set services.mfa.active: false — a bare mode: disabled no longer suffices (it is indistinguishable from the shipped default, which also carries mode: disabled). If a deployment ends up MFA-active with a user whose enrolled method is not active server-side, that login proceeds without a second factor and logs a warn (a config mistake to catch, not a silent state).

Known limitation. The failed-attempt limiter is per MFA session (5 tries), not a per-user rate limit; a caller who can re-authenticate gets a fresh budget. Front with login/rate-limiting at the edge for high-assurance deployments.

Concurrent streams.create of the same id returns item-already-exists, not a raw DB error

When two clients raced to create the same stream id, the loser could receive a 500 unexpected-error leaking the storage engine’s unique-constraint violation (e.g. duplicate key value violates unique constraint "streams_pkey") instead of the documented item-already-exists. The database constraint is now mapped to a 409 item-already-exists on the concurrent path too, identically to a sequential duplicate create, so clients no longer have to match on an unstable database message string. Fixes #126.

Attachment uploads are now bounded by uploads.maxSizeMb (413 on overflow)

uploads.maxSizeMb previously bounded only JSON request bodies; multipart attachment parts were unbounded at the application level, so a deployment without a size-limiting reverse proxy in front accepted attachments of arbitrary size. The configured limit now applies to the multipart path as well: both the uploaded file part and the non-file (JSON) part are capped at uploads.maxSizeMb (the latter previously fell under multer’s silent 1 MB default). Previously an oversized file part was accepted outright and an oversized multipart JSON part surfaced as an opaque 500; both now return a readable 413 { error: { id: 'payload-too-large' } } carrying data.limitMb.

Operator note: a deployment that was relying on unbounded attachment uploads (over 50 MB, no proxy limit) will now receive 413 until uploads.maxSizeMb is raised. Fixes #125.

When the data holder rejects the consent accept during the OAuth2 authorize flow (for example the shareable link already recorded this accepter, or a single-use link was already consumed), the /oauth2/accept endpoint now returns 400 { error: 'invalid_grant', error_description: 'consent accept rejected: <id>' } carrying the peer’s machine-readable reason (a cmc-capability-* id), instead of a bare 500 server_error. Delivery timeouts and other genuine server faults still return 500.

A subject who withdrew consent for a relationship established through an open (multi-use) shareable link was left recorded as an accepter of that link, so a later attempt to re-consent through the same link was refused. Withdrawal now clears that record, so re-consent through the same link works again. Consumed single-use links are unaffected (they stay spent by design).

Email verification now ships OFF by default (beta) — opt in explicitly

Read this if your configuration does not set services.email.enabled.verifyEmail. Enabling that sub-feature makes auth.emailVerificationPageURL a required configuration key, and the core refuses to boot when a required key is unset. Because the sub-feature shipped true by default, a deployment that had been valid for months could stop booting on upgrade without its operator changing anything. It is now false by default, so an upgrade can no longer invalidate a working configuration.

Opt in with both keys — neither works without the other:

services:
  email:
    enabled:
      verifyEmail: true
auth:
  emailVerificationPageURL: 'https://<your-auth-ui>/verify-email'

If you rely on email verification today, this flips it OFF for you. That includes deployments layering the shipped production configuration, where the sub-feature was previously on. Add the two keys above to keep the behaviour.

Behaviour while it is off: account.update still records an added address as pending, but no verification mail is sent, and the resend operation reports success without delivering anything. Addresses therefore stay unverifiable until the feature is turned on. This is unchanged logic — only the default moved — but it is easy to mistake for a mail-delivery fault.

Operators who worked around the boot failure by pinning services.email.enabled.verifyEmail: false in a host configuration can drop that pin; it now matches the default.

Observability rebuilt: no third-party agent, telemetry built from a fixed allow-list, any OTLP backend

If you enabled the optional APM integration in an earlier version, read this. The vendor agent’s scrubbing configuration was placed in a file the agent does not look for, so it was never loaded, and affected deployments ran on the agent’s built-in defaults: the vendor received request URLs, the Host header, route parameters (including the username as a first-class attribute), obfuscated SQL, and forwarded application log records including their message text. Assume that behaviour applied for as long as the integration was enabled, and check what your provider account holds; ingested telemetry usually cannot be deleted on demand.

That defect is fixed, but the response went further than a fix. Configuring an agent that instruments everything means enumerating what must not leave, and anything overlooked (or added by the next agent release) leaves by default. The integration is now built the other way round: no third-party agent runs in the process, nothing is auto-instrumented, and telemetry is constructed by the platform from a closed vocabulary. What can be emitted is the vocabulary, so the answer to “could a URL, a username or a message body reach the backend?” is structural rather than a matter of configuration.

This replaces the previous integration. Upgrading is enough to inherit it; there is nothing to re-scrub and no vendor-agent settings to review.

Multiple emails per account (beta)

An account can now hold more than one email address, each with its own verification state, while the singular email field stays authoritative as the primary. This feature is beta — the surface may still change.

Fixed — the OTLP endpoint guard blocked the collector layout it recommends

Reported as #119. Pointing telemetry at a self-hosted collector is the way to keep it out of a third party’s hands, but set-endpoint only accepted plain http:// for exactly localhost or 127.0.0.1. In a containerised deployment the collector is a separate container, so the core reaches it on the bridge gateway (for instance 172.17.0.1:4318), which the check treated as remote and refused. The recommended architecture therefore required a certificate for a hop that never leaves the machine.

Cleartext is now decided by whether the destination is reachable from off the network: loopback, RFC1918 private space and link-local (plus the IPv6 equivalents) are accepted, anything routable still requires https:.

The same report noted that the check lived only in the CLI, so an operator writing the value straight into PlatformDB, or exporting PRYV_OBS_ENDPOINT, got plaintext telemetry with nothing to stop it. The rule now belongs to the emitter: startup refuses a cleartext remote endpoint and reports why, leaving telemetry off rather than putting a credentialed payload on the wire. If you configured such an endpoint by one of those paths, it will stop activating.

Fixed — a method disabled by the operator no longer blames the licence

451 unavailable-method always answered “API method unavailable in current version. This method is only available in the commercial license.”, whatever the caller’s actual reason: the error builder accepted an explanatory message and discarded it. A method can be unavailable for reasons unrelated to licensing, such as an operator turning an optional feature off, and the licence text sent readers chasing the wrong thing. The caller’s message now reaches the response; the licence wording remains the default where no message is given. Calling a shared-secrets endpoint on a platform with sharedSecrets.enabled: false now answers “Shared secrets are disabled on this platform.”

Shared secrets: hand a secret to a third party by one-time key

Passing a secret to a third party — typically an apiEndpoint carrying an access token — has meant putting it in a URL, where it survives in browser history, referrer headers and server access logs. A shared secret stores the payload on the account and hands over a random key that can be redeemed exactly once.

OAuth2: DPoP — sender-constrained tokens (RFC 9449) (beta)

An OAuth2 client can now bind its tokens to a key pair it holds, so a stolen bearer token alone is useless: every API call must also carry a DPoP proof — a short-lived JWS over the request method and URI, signed with the bound key. Opt-in per session and fully backward compatible — a client that sends no DPoP header gets plain bearer tokens exactly as before.

Client support ships in the pryv JS library 3.10.0 (SignedConnection, OAuth2Client with dpop: true).

OAuth2: operator key-revocation (revoke-key) + key inventory (beta)

When a client’s key is compromised, the operator can now kill everything bound to it, cluster-wide, with one command: bin/oauth-client.js revoke-key <jkt> --yes (where <jkt> is the RFC 7638 key thumbprint) writes a platform-wide tombstone. Revocation is by key presence, not epoch: any token bound to the key dies — including refresh rotations attempted after the revoke — and the token endpoint refuses to mint or rotate for it. Each core re-reads the revoked set within oauth.dpop.keyRevokeCheckSeconds (default 30), so live tokens are cut within that window without any cross-core bus. Rejections use the same uniform DPoP 403 as a binding failure, so the endpoint leaks no revoked-vs-not signal. unrevoke-key restores; list-revoked-keys and list-keys [<clientId>] inspect — the latter joins an advisory per-client inventory of keys seen at token issuance, so an operator can tell what a revocation will hit before running it.

OAuth2: client revocation now reaches live tokens cluster-wide (beta)

bin/oauth-client.js revoke <clientId> used to stop new grants while already- issued access tokens lived out their TTL. Revoking a client now also writes a platform-wide tombstone carrying the revocation moment; every core rejects accesses minted before it at validation time, within oauth.clientRevokeCheckSeconds (default 30). Long-lived transports are covered too: open socket.io connections are re-validated on a sweep and dropped when their client is revoked, and the HF series token cache is capped so a revocation cannot outlive it. The revocation is a token epoch: re-registering the same client_id works and its freshly-minted tokens are honoured, but the tombstone stays, so sessions from before the revoke can never be resurrected.

OAuth2: private_key_jwt client authentication (RFC 7521/7523) (beta)

A confidential client can now authenticate at the token endpoint with a signed JWT instead of a shared secret — no client_secret to distribute, store, or rotate. Register the client’s public JWK Set (bin/oauth-client.js create|update … --jwks-file <path> or --jwks-json <json>; EC P-256 / ES256 keys only, and any key carrying private material is rejected outright), then send client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer

OAuth2: an abandoned authorization code no longer leaves its access alive

The access behind an OAuth2 grant is minted when the user accepts, and delivered at the /token exchange. If that exchange never succeeds — the code expires unexchanged, or the exchange fails after the code is consumed (wrong PKCE verifier, client-auth failure, DPoP binding failure) — the pre-minted access used to linger until its own TTL (≤1h). It is now revoked proactively: a failed exchange revokes it on the spot, and a platform sweep catches expired unexchanged codes. Best-effort by design — if a revoke attempt fails, the access still dies by its own TTL, so the previous behavior is the worst case. The durable consent record (the data-grant) is never touched: only the ephemeral session credential dies.

Two supporting API-visible changes:

Fixed — a CMC back-channel handshake could be dropped, silently and permanently

2.0.0-rc.10 began stamping the requester’s app-code on the data-grant minted at acceptance. That activated a guard in the accepter-side back-channel handler which rejected the delivery whenever the app-code on the grant differed from the one on the delivery — and the two sides derive that value independently, the sender falling back to the literal unknown when it cannot resolve its own request scope. A mismatch discarded the only candidate, so the handshake never completed, backChannelApiEndpoint stayed null, and every subsequent consent-revocation on that relationship was undeliverable, with nothing logged and no way to recover short of a fresh request/accept cycle.

The app-code is now a disambiguator and never a rejector: it selects between several candidate grants but can no longer eliminate the only one. Relationships established before this release are unaffected in their selection — the ordering for every previously-succeeding case is unchanged.

Affects 2.0.0-rc.10 and 2.0.0-rc.11. Relationships whose handshake was already dropped do not heal on upgrade: nothing re-drives the delivery, so each affected relationship needs a fresh request → accept.

Fixed — several relationships with one counterparty under one app

Two concurrent relationships with the same counterparty under the same app-code were not told apart. The app-code derives from the app scope rather than the per-request scope, and every resolution site keyed on it, so the newest relationship was the one they all agreed on: the second handshake’s back-channel overwrote the first relationship’s stream pointers, deliveries on the older relationship were routed to the newer one’s streams, and a consent-revocation on it could not reach the counterparty at all.

Relationships are now keyed on their per-request scope stream (e.g. :_cmc:apps:my-app:study-a) — the one identifier both accounts already share, since each side anchors its chat and collector streams under it. The inbound back-channel matcher and every outbound selector resolve through a single shared function, so which grant serves a relationship cannot be answered two different ways.

Accepting a relationship also used to name the requester-side back-channel access per (app, counterparty), so a second acceptance updated the first relationship’s access in place. Names are now qualified by scope, and an existing access is matched by scope rather than by name — so re-delivery still updates in place while a genuinely new relationship gets its own.

Backward compatible: grants minted before this release carry no scope field, but one is derived from the access’s own channel permissions; where even that is absent, resolution falls back to the previous app-code behaviour. No migration is required, and single-relationship deployments are unaffected.

Relationships whose back-channel was already lost still do not heal on upgrade — nothing re-drives a delivery that was dropped — so each needs a fresh request → accept. Deliveries on such a relationship now fail visibly instead of being silently misrouted.

2.0.0-rc.11 — 2026-07-21

OAuth2: refresh-token reuse detection (chain revoke)

Replaying an already-rotated refresh token — the signature of a stolen token chain — now revokes the whole chain rather than merely rejecting the call. Each rotation is shadowed by a short-lived consumed-marker (no credentials stored), which distinguishes genuine reuse from a token that simply expired or never existed. A benign double-submit inside a grace window (oauth:refreshReuseGraceSeconds, default 10s) is tolerated without revoking. Beyond it, the chain is revoked: the durable data-grant and all live OAuth session accesses for that (user, client) pair — plus their descendants — are soft-deleted, dependent webhooks are cascaded, the access cache is invalidated cluster-wide, and a consent/revoke-cmc is delivered to the counterparty on a best-effort basis. The error response is byte-identical whether reuse was detected or not, so the endpoint cannot be used as an oracle.

OAuth2: oauth.* events reach the audit trail

The nine oauth.* audit events are now emitted into the audit subsystem instead of a no-op stub, gated on audit:active. Five user-scoped events (including the new oauth.token.reuse_detected) persist to the user’s audit storage and are readable through the usual :_audit:* streams; four user-less events (consent.shown, consent.refused, code.reused, token.issued.client_credentials) go to syslog only, since they have no user to attribute. Audit emission never fails a token grant — a backend hiccup is logged, not propagated.

Fixed — audit input validation was silently dead

eventForUser’s validation could never reject anything: the validators return a diagnostic string on failure, which the guard treated as success. The guard now trips on any non-true result, user-less events are validated against the event (not the user id), and the real audit-log/* type family is accepted — the previous rule would have rejected every framework event the moment the guard started working.

Docs corrected — CMC revoke was never a “dual delete”

INTERNALS.md and the CMC implementers guide described revocation as a server-orchestrated dual delete in which the peer’s plugin deletes its half. That has never been implemented: a revoke tears down only the accesses on the account where the trigger was written, and the forwarded consent/revoke-cmc is classified as peer-delivered, so the receiving side runs no teardown handler and its access survives. The docs now state this plainly and instruct integrators to delete their own half when they observe a revoke arrival. Revocation is therefore advisory in the trigger-writer → peer direction; the enforcing direction is the local one (an accepter revoking destroys the data-grant on their own account, which is what cuts the requester’s read). Server-side teardown on the receiving side is planned.

Correction to the 2.0.0-rc.10 notes below: they state that the forwarded revoke’s offerEventId lets the counterparty “correlate the revocation with the originating invite”. That is wrong — offerEventId is the id of the plugin’s internal offer copy, not the inviteEventId a client holds, and the forwarded accessId is the sender’s own id, which the receiver never sees. Carrying a genuinely matchable identifier is still outstanding (#109).

2.0.0-rc.10 — 2026-07-21

CMC: revocation reaches the counterparty whatever path performs it

Deleting a CMC relationship access with a plain accesses.delete (e.g. from a generic “connected apps” screen) now delivers the same consent/revoke-cmc to the counterparty’s :_cmc:inbox as the CMC revoke helpers do — consent withdrawal is observable on the other side regardless of how it was performed (#109). The forwarded event always carries content.accessId (previously missing, which made the receiving side reject the delivery as schema-invalid), plus appCode / offerEventId / acceptEventId when resolvable, so the counterparty can correlate the revocation with the originating invite.

consent/revoke-cmc triggers now honour content.accessId as the authoritative selector of the relationship to revoke (the client helpers already send it): with several relationships to the same counterparty, the previous (username, host) matching could tear down the wrong one, and triggers written to a plain app-scope stream (the helpers’ default placement) could not resolve the counterparty at all. A revoke whose accessId no longer resolves fails cleanly (cmc-revoke-counterparty-access-not-found) instead of falling back to a different relationship — so a duplicate revoke after a raw delete never produces a second inbox event on the peer side.

Data-grant accesses minted at acceptance now carry clientData.cmc.appCode (the requester’s app-code), matching what the requester-side back-channel access has always stored.

The never-functional pre-acceptance revoke branch (content.capabilityUrl on a consent/revoke-cmc trigger) was removed: no client emits it — cancelling an open invite is consent/invalidate-link-cmc, declining one is consent/refuse-cmc — and the capability access could not have delivered the notification anyway. Such a trigger now simply fails with cmc-revoke-counterparty-access-not-found.

CMC: the :_cmc:* namespace now materialises on reads too

An account’s reserved CMC streams (:_cmc:, :_cmc:inbox, :_cmc:apps, …) are created lazily, on the account’s first CMC operation. That trigger covered writes only, so a consumer whose first CMC action was a read — typically an inbox watcher calling events.get {streams: [':_cmc:inbox']} — got unknown-referenced-resource on every poll and could never bootstrap: the read that needed the streams was also the thing that refused to create them (#111). Reads that reference a :_cmc:* stream (plain ids, {streamId} objects, or {any|all|not} logical queries) now provision the namespace like writes do, and so does minting an access carrying an :_cmc:apps:<app> permission (grant-first flows). No configuration is required — the namespace is never something a deployment has to register.

Because this puts provisioning on a polling path, repeat calls are guarded: a per-process memo of already-provisioned accounts short-circuits, and on a memo miss a single stream read decides whether anything needs creating.

CMC: scope edits made with plain accesses.update now reach the counterparty

The same any-path principle applies to scope changes: editing a CMC relationship access directly with accesses.update (no CMC trigger event) now delivers the consent/scope-update-cmc notification to the counterparty’s collectors stream, like the helper flow does. The post-hook previously targeted the counterparty’s :_cmc:inbox, which only admits lifecycle events — the delivery was silently rejected there, so peers never learned of raw scope edits. The peer endpoint resolution also gained the same backChannelApiEndpoint fallback as the revocation paths.

2.0.0-rc.9 — 2026-07-18

CMC: request a delegable (app) data-grant

A consent/request-cmc offer may now carry request.accessType: "app" (default "shared"). When set, the accepted data-grant is minted as a Pryv app access instead of shared, so the approved requester can accesses.create scoped, individually-named sub-accesses (permissions ⊆ the grant) — the least-privilege re-delegation pattern with per-actor audit attribution. shared grants (the default) cannot call accesses.*; nothing changes for existing offers or for the OAuth2 flow (whose data-grants stay shared). Any other accessType is rejected (cmc-offer-invalid-access-type).

2.0.0-rc.8 — 2026-07-17

Fixed — api-server no longer crash-loops on production (--omit=dev) builds

components/api-server/src/routes/oauth2.ts did a top-level require('cuid'), but cuid is a devDependency (the codebase uses @paralleldrive/cuid2). A production image built with npm install --omit=dev prunes cuid, so the require threw at module load and crash-looped every api worker (the core never served). Switched to the production @paralleldrive/cuid2. rc.7 is dead on arrival — operators must use rc.8. (#106)

2.0.0-rc.7 — 2026-07-17

OAuth2 authorization-code flow (server-side)

Pryv can now act as an OAuth2 authorization server (RFC 6749 + PKCE / RFC 7636). Third-party applications obtain access tokens through the standard authorization-code redirect flow instead of the Pryv-native access-request polling flow (both flows remain supported). New endpoints: GET /.well-known/oauth-authorization-server (RFC 8414 discovery), GET /oauth2/authorize, POST /oauth2/token (authorization_code, refresh_token, client_credentials grants). The token response carries a Pryv apiEndpoint extension so multi-core clients build a working connection; vanilla RFC 6749 clients that call the wrong core receive 421 with the correct coreUrl. Authorization: Bearer <token> is accepted alongside the bare-token and Basic forms. Application accounts are registered out-of-band by the operator (bin/oauth-client.js; curated registration only). Short-TTL access tokens plus rotating refresh tokens; nine oauth.* audit event types. Configured under the oauth: block (disabled by default). See docs/oauth2.md.

Granular consent-offer scopes. There are no coarse wildcard scopes: the scope parameter carries exactly one consent-offer reference (cmc:<offer-name>), resolved through the client registration to an open-link consent/request-cmc offer published by the app’s account. The offer’s permission set covers the full accesses.create grammar (per-stream levels AND feature permissions such as selfRevoke); the consent screen lets the user untick individual permissions and the minted session access carries exactly the kept subset. The durable consent record is a cross-account data-grant access on the user’s account: revoking it invalidates the refresh chain (invalid_grant), and narrowing it propagates to the next refreshed access — widening always requires a fresh authorization. client_credentials treats scope tokens as opaque and always serves the app’s own account. Consent event-type schemas (consent/*-cmc) accept the full permission grammar accordingly, and accept triggers support an optional grantedPermissions consent-downgrade subset.

2.0.0-rc.6 — 2026-07-11

Access aliases (randomAlias) — de-identifying endpoints

accesses.create accepts an optional randomAlias: true. When set, the new access is issued a platform-unique, routable alias (r- followed by 8 characters) that replaces the username everywhere the access is addressed: the returned apiEndpoint, and access-info (user.username reports the alias). The real username never appears for that access, so accesses handed to different parties cannot be cross-matched back to one account. The alias routes to the user exactly like the username (including across cores) and is released when the access is deleted. The resolved value is returned as the access’s alias property.

Changeable username (account.changeUsername)

A new personal-token endpoint POST /account/change-username lets a user choose a new username. Accesses already issued under the previous username keep working — the old name is kept as a routable alias — and access-info for those accesses reports the new (current) username. The number of changes is capped by the operator (default 2); GET /account/username-changes returns how many changes have been used, the limit, and how many remain.

CMC: accept no longer fails permanently on data-grant access-name collision (#105)

Accepting a CMC invite with an accessName already used by an existing access (typical for apps passing a fixed app name on every accept) used to fail permanently with the raw database duplicate-key message, and the internal retry loop kept re-attempting an accept that could never succeed. The handler now retries once with a deterministic per-accept suffix (<name> (<8 chars of the accept event id>)), so distinct accepts never fight over one name. A re-dispatch of the same accept (after a delivery failure) reuses its own prior data-grant instead of colliding with it. If the uniquified name still collides, the accept fails fast with the new typed, non-retryable error id cmc-handler-data-grant-name-conflict — no raw database text is echoed.

Mail-delivery failures no longer leak internal detail in 500 errors (#104)

When a transactional email (password reset, welcome) fails to send, the API previously returned a 500 whose message included the configured mail-service URL and the raw upstream HTTP status or transport error — visible to unauthenticated callers of account.requestPasswordReset. The client-facing message is now a generic “Sending email failed. Please try again later or contact support.”; the full diagnostic (URL, upstream status/error, SMTP transport failures) is logged server-side instead.

2.0.0-rc.5 — 2026-06-25

Optional encryption-at-rest image variant

A new published image variant pryvio/open-pryv.io-encrypted adds optional encryption at rest for the data directories (events, attachments, series, audit, platform DB). It layers the container-encrypted-volume facility onto the stock image and mounts an encrypted volume inside the container on boot. The base pryvio/open-pryv.io image is unchanged, and the variant is off by default (CEV_ENABLED=false) so it boots identically until opted in. Pluggable backends (LUKS / gocryptfs) and key providers (env / file / exec / clevis / aws-kms). See the “Encryption at rest” section of INSTALL.md.

/service/info can advertise adapters

/service/info may now carry an optional adapters array — a list of adapter base URLs. Adapters are transient converters between Pryv and an external standard (for example iCalendar). Each URL serves the adapter’s web UI and a manifest.json describing its name, type, version and capabilities; clients fetch <url>/manifest.json for the details. {username} templating is supported, as for the api field. Fully additive — the field is absent unless configured.

BREAKING — CMC trigger writes that mint or widen accesses now require a personal token; revoke is access-permission-gated

Writing consent/accept-cmc or consent/scope-update-cmc to a :_cmc:apps:* stream now requires the calling access to be personal. These two trigger types mint (accept) or widen (scope-update) data-grant accesses on the user’s account; requiring a personal token enforces user-presence at the moment the action is recorded — closing a scope-escalation surface where an app token with narrow :_cmc:apps:* write permission could trigger creation of a much broader shared data-grant access derived from a colluding requester’s offer.

Revoke uses the standard access-permission gate, not a token-class check. consent/revoke-cmc is a contraction (deletion), not an escalation — the access being deleted bounds the impact. The handleRevoke orchestrator now runs triggerAccess.canDeleteAccess(target) (the same primitive accesses.delete uses) before deleting each access in the counterparty pair. This honours the selfRevoke feature permission on the target accesses, so:

Operators who set selfRevoke: forbidden on a counterparty access at mint time block the self-revoke path explicitly — the existing feature-permission contract carries over unchanged.

Upgrade path for apps without a personal token — adopt the new @pryv/cmc.requestAccept / requestScopeUpdate helpers (lib-js ≥ next minor), which open app-web-auth3 (≥ next minor) so the user authenticates, the personal token writes the trigger, and the data-grant apiEndpoint is returned to the app via popup postMessage or returnUrl redirect. No requestRevoke is needed — apps holding the relationship access can self-revoke directly via cmc.revokeAcceptance(...) / cmc.revokeRelationship(...) without bouncing through the auth pages.

2.0.0-rc.4 — 2026-06-18

Multi-core: non-voter join by default

This release hardens multi-core operations: cores now join the cluster as non-voters by default, so adding a core can no longer take an existing core’s control plane offline (see CHANGELOG-v2-back.md for the full description and the new --bootstrap-as-voter / bin/bootstrap.js promote-core operator surface).

On-demand encrypted backups (bin/backup.js)

The full-platform backup tool can now encrypt its output on demand so that plaintext PHI/PII never touches the destination disk — the bytes written to the backup media are ciphertext only. Encryption is opt-in: without the flags below, backups behave exactly as before (plaintext JSONL, same filenames).

Two key models:

Format: each file is encrypted independently (streaming AES-256-GCM in authenticated chunks; a per-file subkey is HKDF-derived from a random salt), so chunking, --incremental, --no-compress and single---user restore all keep working. A small cleartext encryption.json at the backup root records the key model and the wrapped data key — crypto headers only, never user data; manifest.json and every per-user file (including the user manifest and attachments) are encrypted. Restore auto-detects an encrypted backup from that file.

Disaster-recovery note: a lost key (or passphrase) makes the backup unrecoverable — that is the point of the feature. For an --incremental run over an already-encrypted backup, supply the matching secret so the tool can read the previous manifest.

2.0.0-rc.3 — 2026-06-17

Scoped notifications — filter socket.io + webhook delivery by named scopes

Real-time change notifications can now be filtered to named scopes instead of the coarse “something changed” signal. Each scope is an events.get-shaped query of one resource kind (events — default, streams, or accesses). The delivery carries only the matched scope key names — never an id or content. Fully additive: existing socket clients and webhooks behave exactly as before.

BREAKING: HMAC pseudonymisation of PlatformDB rows is now the default (platform.piiMode: hashed)

Every PlatformDB identification + uniqueness row is now stored as a deterministic HMAC-SHA-256 token derived from a cluster pepper, instead of cleartext. In multi-region clusters, the rqlite Raft ring carries opaque tokens for usernames (user-core/, user-indexed/) + every isUnique system-stream field value (default email, in user-unique/) — cleartext never crosses jurisdictions as a side effect of the routing index. Persistent DNS-record subdomains (_acme-challenge, www, static infra records) are operator infrastructure names, NOT user PII, so they stay cleartext. Usernames used as <username>.<domain> DNS names resolve through the hashed user-core/ mapping, so that path is covered too. Lookups still work by-value (the writer derives the same HMAC the reader queries with); the inverse is infeasible without the cluster pepper.

BREAKING: removed the deprecated GET /audit/logs route (audit.getLogs)

The legacy audit-logs route has been removed. Audit logs are queried through the Events API exactly as documented in the Audit logs guide: call events.get with the audit streams in the streams parameter — :_audit: for everything, :_audit:access-<access-id> for a given access, :_audit:action-<method-id> for a given action. The same access-scoping rules apply (a personal token sees all audit logs; an app/shared token sees only the access it authorizes, plus any :_audit:access-* permissions explicitly granted to it).

2.0.0-rc.2 — 2026-06-12

PostgreSQL attachment storage (low file volume)

Client-selectable auth page on access requests (access.trustedAuthUrls)

POST /reg/access accepts an optional authUrl body field: apps can request that the sign-in popup open THEIR auth page instead of the platform-wide access.defaultAuthUrl. Honored only when the URL matches an operator-configured access.trustedAuthUrls entry (array of URL prefixes) — the endpoint is unauthenticated, so an open passthrough would be a phishing/redirect vector.

Structured JSON log output (logs.console.format.json / LOG_FORMAT=json)

Console logs can be emitted as one JSON object per line — {timestamp, level, name, pid, message, context} — for log collectors and log-based alerting (WHERE level = 'error' matches nothing against the human-readable lines). Enable per-config (logs.console.format.json: true) or per-run (LOG_FORMAT=json env, no config change). Sensitive-value masking (tokens, passwords) applies unchanged; the default human-readable format is untouched.

Fix: full-platform backup export (bin/backup.js) on both engines

Diskless deployment: PostgreSQL platform storage + S3 attachments

Single-core dnsLess deployments in full PostgreSQL mode can now run with no persistent filesystem on the app host — every durable byte lives in PostgreSQL and an S3-compatible object store. Verified end-to-end with the app container on a --read-only rootfs (tmpfs for caches only).

Content queries: filter events.get by content / clientData

Two new events.get parameters — content and clientData — each an array of conditions on dot-paths into the corresponding event field, e.g. [{"path":"drug.codes.atc","in":["G03DA04","B01AC06"]},{"path":"taken","eq":true}]. Conditions AND together and compose with all existing parameters (streams, types, time bounds, paging).

2.0.0-rc.1 — 2026-06-03

First Release Candidate of open-pryv.io v2. The runtime has been production-deployed since 2026-04-23 on pryv.me (two-core cluster, 14 real users, 28K events, 264 attachments). lib-js conformance against deployed infra: 168/169 (the missing one is the documented HF case on raw deploys without nginx ingress, see “Known gaps in v2.0.0” below).

New in 2.0.0-rc.1: install wizard

mkdir -p /opt/pryv && cd /opt/pryv
docker run -it --rm -v "$(pwd):/app/pryv" \
  pryvio/open-pryv.io:2.0.0-rc.1 init

Interactive single-core install wizard. Hardcodes the in-container mount target to /app/pryv (avoids the /app/config collision that masks the image’s bundled config plugins), auto-discovers the host path from /proc/self/mountinfo, and writes three artefacts into the operator’s chosen directory:

User-data lives under <install-dir>/data/ (sibling to the config); the wizard auto-derives this path so the operator answers fewer questions. Both directories ride the same single -v mount.

No-arg docker run pryvio/open-pryv.io continues to behave exactly as before (boots bin/master.js). Anything else passes through (node --version, bash, …).

What an implementer pinning to 2.0.0-rc.1 gets

BREAKING changes since 2.0.0-pre

Two breaking surface changes have landed since the rolling :2.0.0-pre line and the implementer should plan for them up-front. Both have full migration guides below:

Additional smaller breaking changes already landed in 2.0.0-pre: accesses.create managed-shared expiry now capped by parent, ID minting algorithm changed cuid v1 → cuid2, accesses.delete personal-access no longer cascades, /reg/hostings returns slash-terminated URLs. All documented in the per-feature entries.

Known gaps in 2.0.0-rc.1

Compliance posture

Compliance-matrix work (regulator-row coverage, primitive-citation lattice) is a parallel deliverable. The latest published matrix lives at https://pryv.github.io/compliance-matrix/.


BREAKING/reg/access polling endpoint response shapes trimmed

The access-request polling endpoints have been narrowed to expose only the fields each consumer audience actually needs. SDKs (lib-js + downstream apps) get the minimum needed to drive the flow; the auth UI (app-web-auth3 + equivalents) keeps a richer poll response.

What changed

POST /reg/access (SDK-facing — create access request):

The response now contains only { status, key, authUrl, poll, poll_rate_ms }. Removed fields: code, url (was a v1 alias of authUrl), returnUrl (camelCase duplicate of returnURL), requestingAppId, requestedPermissions, lang, returnURL, oauthState, clientData, serviceInfo. Echoed inputs and service metadata are reachable via the GET poll path or /service/info.

GET /reg/access/:key (auth-UI-facing on NEED_SIGNIN, SDK-facing on terminal states):

Refused/Error responses (POST + GET, all forms):

Migration

BREAKING — MongoDB removed as a user-data storage engine

The MongoDB engine has been dropped from open-pryv.io. Supported user-data engines are now PostgreSQL (default) and SQLite (alternative). InfluxDB remains optional for high-frequency seriesStorage; rqlited remains the only platformStorage.

What changed at the operator surface

Migration path for existing MongoDB deployments

Use the engine-agnostic backup tool that has been part of the V2 release line:

# On the MongoDB-backed deployment (this build's predecessor)
bin/backup.js --export --userid <userid>     # exports user data as a JSONL bundle

# On a fresh PostgreSQL-backed deployment of this build
bin/backup.js --restore --bundle <path>      # reads the bundle into PG

This is the same path used for the V1→V2 migration and for production MongoDB→PostgreSQL cutovers. Bundles include accounts, streams, events, accesses, profiles, webhooks, and attachments.

Code-level removals

components/storage/src/index.ts drops getDatabaseSync + _ensureMongoDatabase; test-helpers dependencies.ts no longer imports the MongoDB collection classes; databaseFixture.ts drops the legacy raw-DB branches; storages/index.ts drops the baseEngine === 'mongodb' connection bootstrap branch.

Platform.deleteUser hardening

Shipped alongside the engine removal: Platform.deleteUser now discovers PlatformDB entries by username prefix and deletes whatever is present, instead of iterating the mutable accountStreams.{uniqueFieldNames,indexedFieldNames} module-level lists at call time. Fixes a latent leak where a fixture user created under one systemStreams config couldn’t be fully removed after a config change (test-only impact, but the root cause was a production-side fragility).

SQLite baseStorage — now a complete V2 alternative engine

Counterpart to the MongoDB removal: the SQLite engine is now a real user-data option, not the “not yet implemented” stub that throws at init.

Engine-choice tradeoff: backup/deletion semantics, not volume

The PG and SQLite engines have different data-layout shapes:

This shape difference matters under GDPR Art.17 / right-to-be-forgotten + similar privacy-preserving deletion regimes. Operators with stricter deletion semantics, per-user backup orchestration, or per-user retention policies may prefer SQLite. Operators with high-volume cross-user analytics or who already have PG operational tooling stay on PG. Neither is a “low-volume only” choice.

What ships under storages/engines/sqlite/src/

Per-test SQLite matrix is clean across audit, business, cmc, hfs-server, mall, storages, etc (1225+ tests passing under STORAGE_ENGINE=sqlite). The api-server component shares a pre-existing test-helper crash with the now-removed Mongo matrix run (tracked separately) and is verified component-by-component until that is closed.

accesses.create — accepts :_cmc:* stream-ids in permissions

accesses.create was rejecting permissions referencing the CMC plugin’s reserved namespace (e.g. :_cmc:apps:<app-code>, :_cmc:inbox) with invalid-request-structure: “forbidden character(s) in streamId ‘:_cmc:…’“. The auto-create-stream side-effect of personal-access app authorization was hitting the local-store streamId regex (^[a-z0-9-]{1,100}), which rejects the leading colon.

The fix skips the auto-create step for :_cmc:* stream-ids — the CMC plugin owns provisioning of that namespace (reserved parents auto-provisioned at user creation; user-creatable scopes under :_cmc:apps:<app> lazy-provisioned by the plugin or by user-side streams.create). Same-shaped permissions on other namespaces (e.g. :_system:/:system:) are unchanged; truly invalid local stream-ids are still rejected with the same error.

This unblocks app onboarding flows whose accesses.create payload mixes local + CMC permissions (e.g. doctor-dashboard via app-web-auth-3, third-party bridges).

Also: the error message for that path is now spelled “forbidden character(s)” (was “forbidden chartacter(s)”). Clients matching on the message text need to update — matching on error.id === 'invalid-request-structure' was always the correct path.

CMC plugin — features-negotiation now correctly stamped on data-grant clientData.cmc.features

Coordinated fix with @pryv/cmc@1.1.1 (lib-js): the accept handshake now persists the offer-resolved features onto the accepter’s data-grant access in clientData.cmc.features. Previously the patient-side data-grant ended up with clientData.cmc.features: null even when the offer specified default-true values, because the plugin read the negotiated features from the wrong field of the accept trigger (content.extra, which is the SDK’s user-supplied free-form pass-through) instead of content.features.

CMC plugin — security hardening (forge-prevention + reserved-root immutability + internal-stream filtering)

Four route-level guards added to close enforcement gaps in the CMC plugin’s clientData.cmc.* namespace, reserved-stream lifecycle, peer-side content.from stamping, and :_cmc:_internal:* visibility. None of these change the wire shape for valid CMC traffic; they add 4xx rejections for misuse and prune internal events from read responses.

Boot-time REQUIRED_WHEN validation — refuse to start on misconfigured feature gates

The boiler’s config-validation plugin now refuses to boot when a feature-gated configuration key is missing or carries a sentinel value (REPLACE …, unresolved ${VAR}, empty string, null). Replaces the previous silent-degradation behaviour — e.g. password-reset emails rendered with a broken <a href="?resetToken=…"> when auth.passwordResetPageURL was absent at request time.

Upgrade check before 2.0.0-pre.4 — confirm your override-config.yml (or the platform-issued bootstrap bundle) sets:

Key Required when
auth.adminAccessKey Always
auth.filesReadTokenSecret Always (multi-core bootstrap bundles already set this; single-core deploys had no equivalent guard)
auth.passwordResetPageURL services.email.enabled is true OR services.email.enabled.resetPassword !== false
letsEncrypt.atRestKey letsEncrypt.enabled: true
letsEncrypt.email letsEncrypt.enabled: true

If any of these were unset or carried a REPLACE_WITH_… sentinel on 2.0.0-pre.3, the core will exit with a non-zero status on pre.4 boot. The error log names every missing key in a single pass so the fix is one config edit + one restart.

Pryv.me production (use1 + euc1) and HDS production deploys (api-ch1, demo-api-se1) have all five keys populated — no operator action expected. Dokku quickstart / INSTALL.md deploys that booted with default-config.yml placeholders left in place will need to fill them in before upgrading.

Follow-up to PR #71 (see “Password-reset email” entry below) — the request-time fallback shipped in pre.3 has been removed; boot-time REQUIRED_WHEN makes it structurally unreachable in valid deployments.

Password-reset email: robust against late-bound auth.passwordResetPageURL

Superseded as of 2.0.0-pre.4 — the request-time fallback documented here was removed and replaced by the boot-time REQUIRED_WHEN check above. The RESET_LINK Pug substitution is retained.

The account.requestPasswordReset mail-sending step now re-reads auth.passwordResetPageURL from the config store at request time instead of relying on the module-init auth slice capture. The captured slice can be missing values populated later by override-config or extraConfig plugins; when that happened, the Pug template rendered <a href="?resetToken=…"> — a relative URL with no scheme/host that Outlook/Apple Mail QuickLook silently dropped, leaving the user with an invisible link. Observed in HDS production.

Public-facing namespace addition. The api-server now reserves the :_cmc: stream-id namespace for the Cross-account Messaging & Consent plugin. Reserved roots auto-create on-demand at first use; per-app and per-counterparty sub-streams are auto-created by the plugin at acceptance time.

See components/cmc/README.md for the canonical design, IMPLEMENTERS-GUIDE.md for app integration, and INTERNALS.md for the orchestration flow diagrams.

Audit + socket.io for versioned accesses (Plan 66 Phase E)

accesses.getOne + composite-id wire format applied (Plan 66 Phase D)

accesses.update is back — versioned, chain-checked, composite-id (Plan 66 Phase C)

accesses.create — managed shared expiry now capped by parent (Plan 66 Phase B, BREAKING)

High-frequency series — in-process dispatch from the public port

accesses.delete — personal-access delete no longer cascades

audit.syslog.active defaults to false

POST /system/admin/certs/force-renew — admin route

bin/bootstrap.js init-ca-holder — new subcommand

Bootstrap bundle now propagates letsEncrypt.atRestKey

/reg/hostingsavailableCore URLs are now slash-terminated

ID minting algorithm — cuid v1/v2 → cuid2

2.0.0-pre — Publication as open-pryv.io

In-process mail delivery — optional replacement for the external service-mail process

Optional observability (APM) — New Relic as first provider

Multi-core registration + /service/info + /reg/access (dnsLess=false)

Schema migrations — engine-agnostic runner + CLI

Persistent DNS records — management endpoints and CLI

Auto-renewed public TLS certificates (Let’s Encrypt)

Multi-core bootstrap CLI + Raft mTLS

Docker image

Multi-core (DNSless variant)

Known gaps in v2.0.0

Multi-factor authentication (merged from former service-mfa)

Registration service merged into core (formerly service-register)

Registration & user management

Multi-core deployment

DNS server

Service info & apps

Legacy backward-compatible routes

Invitations

Removed

Consolidated master process (single Docker image)

System streams refactor

Removed: openSource:isActive flag

Removed deprecated features from v1

Stream ID prefix backward compatibility

Deprecated endpoint /register/create-user

streamId (singular) backward compatibility

Tags backward compatibility

Final cleanup

FollowedSlices