open-pryv.io

Changelog - Internal (no API impact)

test: backloop.dev 5.2.1, and the CA warm now finds the bundle in either layout

The dev-only HTTPS test proxy moves to backloop.dev 5.2.1, which keeps the proxy alive when a reply lands after the response was already answered — the failure mode that can take the proxy down mid-suite.

5.2.0 also began caching the public and private certificate material separately, which moved the bundle from certs/backloop.dev-bundle.crt to certs/private/backloop.dev-bundle.crt. scripts/backloop-ca-warm looked only at the old path, so on the new version it exited non-zero, test and test-sqlite silently degraded to “no extra CA”, and the lib-js integration suite was back to failing its first request with DEPTH_ZERO_SELF_SIGNED_CERT. The warm step now resolves the bundle from either location, newest layout first, so it works across the bump in both directions.

Note that the certificate itself does NOT come from the pinned tag: backloop fetches it in a postinstall step, so any install gets the current one regardless of the version pinned here.

Tests wait for writes the server makes after answering

The audit record of a successful call and the access usage counters (calls, lastUsed) are written after the response is sent, by design. Tests that read them back right after the call raced that write and failed on slower CI runners ([AINT], [AUDT], [SYER] [9C1A]). They now re-read with a bound through a new pollUntil test helper (test-helpers, 1500 ms default so a missing row reaches the assertion rather than mocha’s 2 s timeout); the same applies to [AUAB], [ASTE] and [AUDI]. [VBV0] and [0BK7] check the audit row of a specific call instead of whichever row came first, which let [VBV0] pass without checking anything. The [AUDT] filter tests await the audit writes they start instead of leaving hundreds running into the next hook (the [JBPZ] hook timeout), let earlier calls’ writes settle before resetting their spies (now restored afterwards), and assert “not called” as a zero count. [ASFL] waits for the rows of all three accesses before its leak checks. [WHBK] [WB07] captures the webhook’s state and arms the retry’s answer before the retry timer can fire, and its retry interval now outlasts the stored read. Verified by slowing both writes by 300 ms: the previous tests fail, these pass.

OAuth2 client rows store the app account’s user id, not its username

The oauth-client/<clientId> row in PlatformDB (replicated to every core) carried the app account’s username (accountUsername). It now carries accountUserId; the client_credentials grant resolves the canonical username on the account’s home core (the only core that can serve it) and answers 500 when the account is not hosted there. Each core’s master boot converts, once and before workers start, the rows of the accounts it hosts (migrateClientAccountIds); rows of other cores’ accounts are left to their home core, and the grant still reads the old shape meanwhile. Rolling back the account’s home core after the upgrade makes client_credentials answer 500 for converted clients until it is upgraded again or the client is re-created. Note: a CLI-created client_id IS the account username, so the username remains in the row key; only an opaque client_id would remove it. [OCU1]-[OCU7].

Test-server manager hygiene

DynamicInstanceManager installs one set of process exit / SIGINT / SIGTERM hooks for all instances instead of three listeners per instance (which piled up and kept every manager alive), removes its temp config file once its child has exited, and stop() now calls back when the kill is reported as an error event rather than thrown. Its 5 s force-kill fallback now actually fires for a child that ignores SIGTERM (it tested proc.killed, which is true as soon as SIGTERM is sent) and no longer holds the process open. The port probe resolves only after its socket has closed (a race too narrow to test deterministically). [DIM4]-[DIM7].

Test servers: a restart waits for the new server; unexpected exits are logged

DynamicInstanceManager reset its readiness flag only in stop(). After a spawned test server died unexpectedly or was force-killed, the next ensureStarted() trusted the stale flag and returned before the new server was listening, so the first request failed with ECONNREFUSED (the [SYRO] sighting). The flag is now reset at every spawn, and an unexpected exit after ready is logged at error level, which reaches the durable test log. Exit, error and readiness messages only act for the current child (a stopped child’s late message cannot mark its successor ready), and a child killed by a signal or exiting 0 before announcing readiness now fails the start instead of resolving it. The port allocator now probes the address the server binds (127.0.0.1; a probe on 0.0.0.0 succeeds on macOS over a port another process holds on 127.0.0.1) and stays within 10000-49151, below the ephemeral range, wrapping around instead of running into it. [DIM1]-[DIM3].

Auth-request credential hand-off: server conversion and a shared user-core resolver

POST /reg/access/:key converts an inline-token accept into a one-time shared secret when the request asked for credentialHandoff: 'shared-secret': it creates the secret on the user’s core authenticated AS the app token (in-process through a MethodContext on a local core, over HTTPS to the platform-resolved core otherwise), stores only the key, and never persists the token, which reaches the answering core in the POST handler’s memory only. A create that cannot be done (shared secrets disabled or forbidden, the core unreachable, an unparseable endpoint, or a delegation-derived token, which may not create the hand-off secret) falls back to inline delivery with one warn line, never the token. The state store drops the token whenever a handoff is written, so a hand-off state can never carry both. The “which core hosts the user, decided by the platform and never by the posted apiEndpoint” resolution is extracted from the consent check into routes/reg/userCore.ts (resolveUserCore) and shared by both callers. [RA95]-[RA106].

The externals suite no longer depends on the public assets site

The lib-js integration suite (components/externals) pointed service.assets.definitions at the public assets site on GitHub Pages, so the [ASTX] and [ACNX] lib-js tests failed with a connect timeout whenever that site was slow or unreachable. The assets they read (index.json, the default and sign-in-button CSS, the button HTML and messages) are now copied under components/externals/test/fixtures/assets/ and served by the suite’s HTTPS proxy at /test-assets/; config/libjs-test-config.yml points there.

The last bare-app supertest suites bind on 127.0.0.1; [SYRO] leaves nock off

[UPLD] (uploads middleware), [SDTP] (subdomainToPath), [SSOC] (SSO OIDC client flow) and [SSOE] (SSO end-to-end) still handed supertest a bare express app. supertest then listens on :: at an ephemeral port and connects to 127.0.0.1; on macOS another process already bound to 127.0.0.1 on that port receives the request (reproduced standalone: the specific bind wins). That is how [GY5H] and [SSOC11] got a foreign 404, and a plausible source of the [SSOE] socket hang up, in full runs next to another test matrix. All four now use listeningAgent(), like coreRequest. [SYRO] also stops calling useNock(): it mocks nothing, and it routed its requests to the real spawned server through nock’s mock socket.

OAuth2 code and refresh rows no longer store the username

Authorization-code, refresh-token and consumed-marker rows live in PlatformDB, which is replicated to every core, and carried the username in clear whatever the platform’s hashed-PII mode. They now carry the user id only: the core that serves /oauth2/token (always the user’s home core; another core’s code row is refused) resolves the canonical username from its local users index, so a renamed user’s chain also follows the current name. A user deleted since the grant gets invalid_grant (a consumed refresh token still leaves its reuse-detection marker), and the expired-code orphan sweep skips them. At boot, the master removes the username from this core’s live refresh rows and markers once, before workers serve /oauth2/token; code rows (10 min) simply expire. Rolling back to an earlier release after this upgrade makes refreshes of chains minted since then fail until the user re-consents. [OAC-OK2], [OPI1], [OPI3]-[OPI10], [OE08], [OE28].

Delegation: lineage marker on accesses a delegate grants

The delegation plugin gains an accesses.create hook that stamps clientData.delegation = { kind: 'delegated-child', relId, delegate, viaAccessId } when the authenticated access is a delegate token or itself such a child (wired after the forge-prevention hook, reads the authenticated access only), and an accesses.update hook that re-applies the stored marker whenever an update touches clientData. The delete and update lifecycle guards now protect only the markers the plugin owns (control, delegate token, invite capability, notify, and any unknown kind); a delegated-child access follows the normal access rules. The detach teardown deletes the relationship’s delegated-child accesses right after the delegate token (store.findMarkerAccesses) and logs the count. A new events.create hook (createDelegatedGrantGuardHook, fed the CMC gated trigger types) and a check in the OAuth2 resolveUser refuse delegation-derived tokens (isDelegationDerivedAccess) on the grant paths that do not stamp the marker. [DCHD], [DLN01-04], [DUP01-04], [DAD05-07], [DUG04-05], [DDG01-03], [DDCH1], [OE27].

The v1 docker-compose test harness is removed

build/test/ (compose file, start script, per-service config) described the v1 topology (InfluxDB 1.7.8, separate register and mail services, a config layout the v2 image no longer reads) and could not run against the single v2 image; 2.0.0-rc.22 had already flagged it as stale. It is removed along with its .gitignore, .licenser.yml and ESLint ignore entries. Running the release image locally is covered by the install wizard (bin/init.js, which writes run-pryv.sh) and the Dokku notes in INSTALL.md.

The audited query never carries a token

MethodContext drops token from originalQuery (what the audit trail records as content.query) next to the auth it already dropped. Callers that ask an access to carry a chosen token send it in the body, which is not recorded, so this is a guard against a caller putting it in the query string rather than a fix for an observed leak. [MCQT].

A port collision in a test now names the process holding the port

A suite that could not bind its port failed with a bare EADDRINUSE, which reads like a defect in the code under test; in practice another checkout on the same machine, or a server started by hand on the canonical port, was holding it. The new test-helpers/src/portHolder.ts reports the holding pid and command line and names the setting to move the suite’s port to. Wired into the pubsub broker-reconnect test, next to the existing check in the externals suite.

The legacy manual Docker build path is retired

build/build, build/Dockerfile and build/scripts/build_name built an image from the v1 base image and stamped .api-version themselves, a second mechanism that could disagree with the tag a release is built from. Releases are built by CI from the root Dockerfile, which stamps the version from the image tag, so the legacy path is removed (it also still installed with --omit=optional, which strips sharp’s native binaries). build/test/README.md now builds its test image from the root Dockerfile.

just install keeps optional dependencies

just install ran npm install --omit=optional, which removes sharp’s native binaries (sharp ships them as optional @img/sharp-<platform> packages), so the previews server failed to load (Could not load the "sharp" module) until sharp was reinstalled by hand. A full test run then crashed that component at load without printing a failing test. The recipe now installs optional dependencies, like CI, the Dockerfile and the native deploy path already did.

Credentials out of the replicated platform store

/reg/access state (api-server/src/routes/reg/accessState.ts) moved from PlatformDB access-state/<key> rows to the core-local cluster_kv store (namespace access-request/), the master-held map MFA sessions already use. The PlatformDB choice was made to share state across cluster.fork() workers (issue #67); the replication to every core was a side effect, and it put the ACCEPTED token and the username on every core’s disk. Nothing about a request is needed on another core: the poll URL is the entry core’s own. New markDelivered() shortens the life of a terminal state to access:terminalRetentionMs after the first poll that reads it. The cross-worker regression test ([XS12]) now runs the cluster_kv master handler in the test process for the forked children (clusterFixture option kvMaster), so the children no longer open rqlite.

OAuth2 storage (oauth2/src/storage.ts): codes and refresh tokens are keyed by the SHA-256 of their value (oauth-ac/, oauth-rt/<coreId>/, oauth-rt-used/<coreId>/), and the code row no longer carries accessToken / apiEndpoint, only accessId and the issuing coreId. The code grant reads the access back from local storage through a new resolveAccess dependency and refuses a row issued by another core; a failed exchange deletes the orphaned access through revokeAccessLocal (storage-direct), and the master’s expired-code pass (extracted to oauth2/src/orphanSweep.ts) deletes this core’s orphans locally. Migration: rekeyLegacyRefreshTokens runs in the master after migrations and moves this core’s oauth-refresh[-used]/ rows to hashed keys; consumeCode still reads a legacy oauth-code/<code> row once (to be removed in the following release), and legacy orphans keep the HTTP self-revoke. INTERNALS.md no longer describes a forwardIfCrossCore hop at /oauth2/token that never existed, and names the client-secret hash correctly (bcrypt).

event-types dictionary: load-state tracking, bounded fetch, boot await + background retry

The type repository (business/src/types.ts) now tracks whether the published dictionary ever loaded (everSucceeded / degraded, plus source, version, embeddedVersion, lastSuccessAt, lastAttemptAt, lastError), exposed via getEventTypesLoadState() and the instance isDegraded() / getLoadState(). The fetch is now bounded by an AbortController timeout so awaiting it cannot hang.

At boot, methods/events.ts awaits the first fetch (a healthy core validates against the published set before it serves, closing the previous startup window), and on failure schedules a background retry with exponential backoff (5s → 5min, unref’d) that self-heals the core once the endpoint is reachable, logging recovery. The previous fire-and-forget .catch(warn) is gone.

The HFS worker had the same dictionary fetch as a fire-and-forget with no .catch; with no global unhandledRejection handler, an unreachable endpoint at boot could crash-loop that worker. It now catches and logs, running on the embedded fallback (hfs is already fail-closed for unknown types, so it refuses rather than under-validates).

CMC capability accesses are read by id on the accept path

An accept or refuse arriving through a capability is written with the capability token, so its createdBy names the capability access: handleIncomingAccept, the new handleIncomingRefuse and setRequestEventIdOnAccess now read that access by id (mall.accesses.getOne, a primary-key read on both engines) and fall back to the full access scan only when the hint is not a capability access (a hint naming another invite’s capability resolves nothing). Invite state is written by one helper (cmc/src/inviteState.ts) with a transition table, so a late write cannot overturn a final state. recordAccepter / clearAccepter are removed; the three revoke paths mark the invite instead.

api-server tests: nock is off outside the suites that mock HTTP

nock 14 switches itself on when it is loaded and then sends every HTTP request of the process through a mock socket. Mocha loads every spec file before running any, and a suite filtered out by --grep never runs its own hooks, so a filtered run (the usual way to debug one test) ran socket-level tests against the mock instead of Node. Suites that mock HTTP now call useNock() (test-helpers/src/nockScope.ts). Root hooks switch nock off at startup and around every test outside such a suite. If a test leaves it on, the hooks switch it off and the run fails at the end, naming that test. Test-only change.

an event type with a malformed schema is now named in the log, and a download replaces types whole

The event-types catalogue is checked as a whole when it loads, which does not look inside each type, so a type whose own schema was malformed loaded silently and every event of that type was then refused with no visible cause (four such types shipped in the published catalogue before it was repaired). Each type schema of the bundled default list (once per process) and of the list after every download is now checked against the JSON Schema meta-schema, a few milliseconds for the whole list, and each malformed one is logged as a warning naming the type and the problem; a schema that still fails when compiled (an invalid regex, an unresolvable $ref) is named once, on first use. Behaviour is otherwise unchanged. The validator facade in utils gains schemaShapeError(schema), which never throws.

A downloaded catalogue is now applied entry by entry (each type, extras, classes and sets entry replaces the current one whole; entries not in the download are kept) instead of deep-merged, the same rule scripts/update-event-types uses for the bundled list. The HFS server now logs a failed catalogue download instead of leaving an unhandled rejection.

the in-process HFS ingress proxy tears its worker request down when the client goes away

On raw deploys the API process forwards HF series traffic to the co-located HFS worker by piping the client request into the worker request and the worker answer back into the client response, and .pipe() forwards destroy in neither direction. A client aborting mid-upload left the worker request half-open until the worker’s request timeout (300 s). A client leaving before the worker answered, or stopping mid-answer, left the worker socket stalled with the unread answer, for good when the answer (a series query) exceeded the socket buffers. Each hop now propagates the close of the stream it forwards: a client request that closes before its body completed destroys the worker request, the response hop runs under pipeline(), and a client already gone when the worker answers gets nothing piped. A teardown the proxy caused itself is logged at debug, not as an upstream failure.

A worker answering before the client’s body was complete (an access refused on a large batch) also stalled the rest of the client’s upload until a request timeout; the proxy now discards the unread body, as Node’s own server does, and releases the worker request. nginx-fronted deploys never ran this code.

the runtime event-types seed is current again, and the catalogue gate covers it

components/business/src/types/event-types.default.json is what a core validates event content against until its startup download of the catalogue lands, and for as long as it runs when that download fails (the failure only logs a warning). It was last refreshed in October 2023: 23 published types were missing (among them the consent/*-cmc, concentration/* and encrypted/aes-256-gcm types) and 5 descriptions were behind. A core that could not reach the catalogue at boot served on that list, and every core did so during its startup window.

The seed is now the published catalogue applied onto the previous seed entry by entry: every published type (and extras, classes and sets entry) replaces the seed’s, and entries no longer published are kept. A running core merges its download into the seed additively, so it still accepts types removed upstream; keeping them makes a core that cannot reach the catalogue validate exactly like one that can. That keeps the three legacy density/g-dl, density/mmol-l and density/mg-dl types, which the catalogue renamed to concentration/*. Dropping them would refuse new series:density/* events and is left as a separate decision.

just update-event-types now re-vendors both the test fixture and the runtime seed (it used to overwrite the seed with the download) and re-checks them; just update-event-types-fixture is kept as an alias. The catalogue guard in just lint and CI fails when a published entry is missing from the seed or differs there, and it checks the seed against the test fixture when the published catalogue cannot be fetched, so that part no longer depends on the network.

a failure after a response started streaming no longer crashes the worker

When a response failed after its headers were sent (for example an attachment whose file read failed mid-transfer), the error handler still tried to write an error status. That threw inside the async handler, an unhandled rejection that takes a worker down under Node’s default, and the client waited forever for the bytes its Content-Length announced. The handler now audits and logs the error as before, then cuts the connection, so the client sees a broken transfer at once.

A failure while writing the ERROR audit record had the same effect on the error path of every request: the handler rejected before answering, so the request hung and the rejection was unhandled. The audit failure is now logged and the error answered.

On the download path, a failure while writing the success audit record after the file was served was also left to reject unhandled; it is now logged. A file that cannot be opened (a missing file) or whose read fails before the first byte answers with a JSON error status and an error audit record, and no longer carries the attachment’s Content-Type, Content-Disposition and Digest headers, so a browser does not save the error as the file.

rqlite tests use the configured rqlite, and the conformance suite no longer skips silently

The rqlite PlatformDB conformance suite and the ACME integration test defaulted to localhost:4001 and ignored config/test-config.yml. A checkout running rqlite on another port therefore either skipped 89 conformance tests while the run still reported green, or ran them (writes and deletes included) against whichever rqlite answered on 4001, possibly another checkout’s. Both now resolve the URL from RQLITE_URL, then the test config (including its storages__engines__rqlite__url env mirror), then the default. Both fail, naming the URL, when that rqlite is unreachable, instead of skipping. The helper that boots a child core for multi-core tests falls back to the configured rqlite too.

an aborted attachment download no longer leaks the attachment’s file descriptor

An attachment download pipes the file read stream into the HTTP response, and .pipe() does not forward destroy upstream. When the client went away mid-transfer (or before the file was opened), the read stream stayed paused with its file descriptor open for the life of the process, so enough aborted downloads would exhaust the process file-descriptor limit. The response’s close now releases the source, and a client already gone when the file is opened gets nothing piped. .pipe() is kept on purpose rather than pipeline(): a source error before the first byte must still leave the response usable, so the client gets a proper error status and the error is audited. An aborted download writes no audit record, as before.

the test event-type catalogue is re-vendored, and a gate keeps it in sync

test/event-types-flat.json is a hand-copied snapshot of the published event-type catalogue. test/service-info.json serves it to the test platform and the business unit tests read it directly, so it is what every content-validating suite validates against. Nothing tied the copy to its source: it was refreshed only when somebody remembered, and it had fallen two months behind: two published types missing (encrypted/aes-256-gcm, encrypted/ecies-aes-256-gcm) and one definition changed.

The drift was silent in both directions. A type added upstream was exercised by no test until the copy was refreshed by hand, and a suite could pass against a catalogue no deployed client is served. The version field does not detect it, because the catalogue is republished under the same version.

The fixture is now re-vendored to the current catalogue, and just lint runs a guard (scripts/event-types-fixture-guard, also a CI step) that compares it with the published file and fails with the re-vendor command when they differ. The comparison is on canonicalised JSON, so a reformat upstream is not reported as drift, and it names the types added, removed or changed. The guard SKIPS when the catalogue cannot be reached (raised as a workflow warning in CI): an upstream outage must not redden every build, while an unnoticed two-month drift is worth failing on. A 404 fails, since it means the catalogue moved. just update-event-types-fixture refreshes the copy and re-checks it.

backup export streams end to end, so its memory is a batch rather than the account

bin/backup materialised every collection before writing it, and walked the events array a second time to find attachments. Peak memory was therefore the size of the largest collection, which is the wrong shape for exactly the accounts a backup matters most for.

Events and audit now flow through a lazy pipeline: the export source, the snapshot/incremental timestamp filter and sanitize run one item at a time on the way to the writer, and attachment references are collected during that same single pass instead of a second full iteration. Both stores gained a streaming producer next to the array one (events.exportAllStreamed, and exportAllEventsStreamed on the audit interface) on PostgreSQL through a server-side cursor and on SQLite through a statement iterator.

The streaming producer is optional and feature-detected, so this is additive: the audit interface’s required-method set is unchanged, an engine that does not implement it keeps the array path, and the import/restore contracts stay array-based. The backup file format does not change.

On PostgreSQL the audit export runs on the streamed-read pool (auditReadPoolSize), not the audit write pool, because exporting one account’s audit set holds a cursor for the whole collection. bin/backup is a separate process today, so nothing can be starved by it; keeping it on the read pool means an in-process backup trigger added later cannot regress that.

audit reads on PostgreSQL stream for real, and an aborted response releases the pool client

UserAuditDatabasePG.getEventsStreamed and getEventDeletionsStreamed looked like streams and were not: on the first read() they ran one SELECT, held the whole result set in memory and handed rows out one at a time. Memory was the size of the matching audit set, not of a batch. They now read through DatabasePG.queryIterable, a server-side cursor that yields fixed-size batches, so an audit query over a large trail costs a batch rather than the trail.

Making the read a real stream is only safe once “the response went away” reaches whatever holds the resource. A cursor-backed stream owns a checked-out pool client for as long as it lives, and .pipe() does not propagate destroy upstream: the client going away destroyed the response and the outermost Transform while every boundary below swallowed it, leaving the source suspended and its client checked out forever. A handful of aborted requests was enough to starve the pool, which is why an earlier attempt at this had to be reverted.

Every wrap site on the response path therefore goes through one helper, utils/streams.ts pipeThrough, which builds the chain with stream.pipeline() instead of .pipe() so destroy is forwarded end to end. Result.writeStreams destroys the chain when the response closes early. Keeping the call in a single helper is what makes “no .pipe() on the response path” a checkable property rather than a convention.

Proven at the pool, not at the stream: [AUAB] aborts an audit query mid-flight and asserts the pool’s checked-out count returns to its baseline, and [PGQI]/[RSAB] cover the cursor’s own release paths and the abort hook. pg-cursor is a new dependency of the PostgreSQL engine.

Streamed audit reads run on their own connection pool, separate from the one audit writes use. New PostgreSQL engine setting storages.engines.postgresql.auditReadPoolSize (default 5); auditPoolSize keeps its name, its default and its meaning, and now serves writes and short reads only. This is not tuning, it is the condition that makes the change above safe to run: a streamed read holds its connection for as long as the client takes to drain the response, there is no server-side response timeout, and every access can read its own audit trail by default. On a single pool, enough slow or deliberately stalled readers would queue the audit write of every request behind them, and a queued write is dropped once it times out, so a handful of readers could silence a core’s audit trail. With two pools the worst a reader can do is deny audit READS to other readers for as long as it holds them, which costs visibility rather than the record itself. Operators who raised auditPoolSize for read throughput should raise auditReadPoolSize instead. [AUAB4] holds the read pool at its ceiling and asserts an audit write still lands.

Holding a client for the length of a response rather than a few milliseconds also made two connection-loss paths matter that did not before. A checked-out client has no 'error' listener of its own, because the pool removes its idle one while the client is out, so a backend that went away mid-read (restart, failover, an administrator terminating the session) raised an unhandled 'error' event and ended the process; the cursor holder now listens for the length of the hold. And cursor.close() waits for a readyForQuery that a dead backend never sends, so closing the portal on that path parked the release forever and cost the pool slot permanently; the portal is now closed only on a healthy connection, which is the only case where there is one to close. [PGQI7] covers both.

Two smaller release gaps close with them. A method that failed after registering a stream but before the response was written left that stream flowing and holding its resource, as did the elements queued behind one that failed while being collected; both are now released. And on SQLite, the wrapper around a statement iterator gained the return() that Readable.from needs in order to close it, so destroying a streamed read now reaches the iterator instead of stopping one hop short. Writes were not affected by that leak, because user databases run in better-sqlite3’s unsafe mode, which disables its open-iterator guard; closing the database is checked whatever the mode, so a leaked iterator made that user’s handle unclosable, which is what account deletion and the handle cache’s eviction both need, and the un-reset statement held back WAL checkpointing for as long as it lived.

handleIncomingRevoke now deletes the access named by the arrival’s createdBy, so that access is gone by the time the handler returns. The loop-avoidance test resolves createdBy to a counterparty access, which then no longer resolves: a re-dispatch of the same inbox event (retry loop, operator re-processing) would fall through to the outbound handleRevoke with the peer’s foreign content.accessId, fail cmc-revoke-counterparty-access-not-found and rewrite the arrival’s status to failed – which an app reads as “the withdrawal did not work”. A consent/revoke-cmc sitting on :_cmc:inbox therefore always takes the incoming path now, whether or not createdBy still resolves. Sound because inboxWriteHook refuses any write to that stream from an access that is not counterparty-marked, so an inbox arrival is peer-delivered by construction.

Dispatch also keeps its in-memory copy of the event content in step with the status: 'delivered' stamp it writes, so a handler that rewrites content afterwards carries the status forward instead of dropping it.

delegation: account-delegation plugin + internal read/namespace hardening

New components/delegation/ plugin owning the :_delegation:* namespace: forge-prevention on clientData.delegation (create + update), lifecycle protection (delegation-marker accesses and :_delegation:* events are undeletable/unupdatable via the generic APIs by any token, including personal), reserved-namespace write protection, and internal-subtree read guards. The delegate token is a session-backed personal access minted like the login flow, discriminated by a forge-protected clientData.delegation marker whose ABSENCE is exactly what the genuine-login detach gate checks. Post-invite handshake delivery is modeled as marker-authenticated controlled-side method calls (not stream writes) so the namespace write-guard stays blanket; activation is synchronous with idempotent re-accept, holding an at-most-one-control-access-per-relationship invariant.

Hardening shipped alongside: the hidden plugin-internal namespaces (:_delegation:_internal and :_cmc:_internal) are now excluded from wildcard * event reads via the same seam that hides shared-secrets and emails — closing a leak where a * read could return the delegation A-side mirror event (which carries a control-channel bearer onto another account) and the equivalent pre-existing CMC internal state. The CMC internal-read guard was also hardened for single-value and logical-query forms. Fixed a latent production crash: relationship-id generation required cuid, a devDependency pruned from production builds — switched to the standard @paralleldrive/cuid2.

emails: registration challenge module, shared token helpers, mail-capability predicates

cache: invalidate after the write commits, not before (closes a stale re-cache race)

The streams and access caches were invalidated BEFORE their backing DB write committed, in the engine stream mutators (insertOne/updateOne/delete), the accesses.delete method, and the local user-index rename/alias operations. That left a window where a concurrent read of pre-commit data could re-populate the cache with a stale entry that no later invalidation removed (the epoch fences close the invalidation-during-read half of the race but not this one). Each invalidation now fires AFTER its write completes. For accesses.delete the cache unset is also now built from the authoritative access rows and issued even when the entry is not cached on the current worker, so a deleted token can no longer keep validating on a sibling worker until eviction (it is dropped cluster-wide via the existing broadcast). Behavioural fix only; no API surface change.

cache: fence the same set-after-unset race in the streams cache

The streams cache had the same race as the access-logic cache: a stream tree is loaded from storage on a cache miss and inserted with setStreams, and a concurrent invalidation (a local stream mutation, or a cross-process cache-invalidation broadcast) landing during the read could have its continuation re-insert the pre-mutation tree, served until the next bust. The per-(user, store) monotonic unset epoch now fences the insert the same way: the producer captures the epoch before the storage read and the cache skips the insert if any invalidation moved it meanwhile.

cache: fence a set-after-unset race that could re-cache a stale access

When an access is loaded from storage and inserted into the per-user access-logic cache, a concurrent invalidation (a local access update/delete, or a cross-process cache-invalidation broadcast) landing during the storage read could have its continuation re-insert the now-stale entry, which then served stale authorization state until the next eviction. A per-user monotonic “unset epoch” now fences the insert: the caller captures the epoch before the read and the cache skips the insert if any invalidation moved it meanwhile. The request’s own freshly-read access is unaffected; only the shared cache is guarded.

cache: document that cross-process cache invalidation is always on

The cross-process cache-invalidation channel is a correctness requirement whenever API workers are forked (a cache bust in one process must propagate), so it has no configuration gate. Removed a dead always-true conditional and documented the always-on intent.

deps: bump multer / nodemailer / sharp / morgan off high + moderate advisories

Runtime-dependency security bumps, all within the existing semver ranges: multer 2.2.0 → 2.3.0 (three high DoS advisories: crafted multipart field names, file-descriptor leak on aborted uploads, oversized array index in field names), nodemailer 9.0.3 → 9.1.1 (high addressparser O(n²) DoS plus the moderate recipient-domain / punycode validation bypasses), sharp 0.35.3 → 0.35.4 (high libheif advisories), morgan 1.8.x → 1.12.1 (moderate log-forging via unescaped Unicode line separators). npm audit --omit=dev is now clean (0 findings) and the security-audit CI gate passes.

cmc: route the back-channel to the newest grant when several serve one relationship

When an accepter holds several data-grants for the same peer and scope (a new grant is minted on every accept, e.g. after a revoke-and-re-invite), the relationship selector returned the first name-sorted match, so every inbound back-channel stamped the OLDEST grant: newer grants never received a peer endpoint and their revoke could not notify the peer (peerNotified: false, cmc-revoke-no-peer-endpoint), while outbound traffic routed through a grant whose token had since been deleted (403). When more than one grant has the exact scope, the selector now picks by created (newest first): an inbound back-channel stamps the newest grant still awaiting one (never clobbering a completed relationship), and outbound delivery goes through the newest grant that already knows the peer. Single-grant relationships are unchanged.

cmc: the accesses.delete post-hook gets a mall with accesses (clears acceptedBy)

The accesses.delete post-hook clears a withdrawn subject from an open-link capability’s acceptedBy when a stamped relationship access is removed by a plain accesses.delete. It was wired with the raw Mall, which exposes streams and events but not accesses, so that local clear was a silent no-op: after a requester removed a back-channel access directly, the subject stayed in acceptedBy and re-consent through the same link was refused. The hook now receives the composed CMC mall (an adapter over the accesses storage plus token-auth cache invalidation), extracted into a shared helper so every CMC wiring site uses it. A real-core integration test now covers the raw-delete path the fake-mall unit tests could not.

The OAuth2 authorization accept drives a CMC consent handshake and polls for the resulting data-grant. The handshake creates the data-grant BEFORE delivering the accept to the peer and rolls it back if the peer refuses (an invalidated or consumed link). The poll trusted the data-grant the instant it appeared, so under CPU contention it could observe the transient grant during the create-to-rollback window and mint a short-TTL OAuth access against a consent that was about to be refused: a 200 with a valid code where a 400 invalid_grant was due, leaving an orphan access minted through an invalidated link. The poll now keys on the trigger’s terminal status (the dispatch stamps completed / failed) and resolves the data-grant only once the accept is completed, so the transient window is never observed. Extracted as a pure awaitConsentOutcome helper with a deterministic, load-independent regression test. A peer-refused accept still returns 400 invalid_grant carrying the peer’s specific reason; on a transient (retryable) delivery failure the accept now returns a server error until a background retry completes the consent, rather than optimistically returning 200 off a data-grant whose delivery had not yet been confirmed.

test: trust backloop.dev’s self-signed cert for the lib-js integration suite

The lib-js integration suite proxies lib-js over HTTPS on l.backloop.dev, with the proxy’s certificate provided by backloop.dev. When no backloop secret is configured (as in CI, and on a fresh clone), backloop serves its public self-signed leaf, which Node’s built-in fetch rejects (DEPTH_ZERO_SELF_SIGNED_CERT) so every lib-js suite failed at its first request. The test and test-sqlite recipes now materialize backloop’s certificate bundle (scripts/backloop-ca-warm) and trust it via NODE_EXTRA_CA_CERTS before mocha starts, so the suite passes with or without a secret. NODE_EXTRA_CA_CERTS only adds to the default trust store, so a secret-based cert keeps working unchanged; the step degrades to no extra CA when backloop cannot provision, leaving unrelated component runs unaffected.

storage: one shared update-path contract, so the engines cannot disagree

An update key addresses at most ONE level inside a JSON field, and both engines now enforce that through a single shared helper (storages/interfaces/_shared/updatePath.ts, carrying the contract as its doc comment). Previously a key with two dots meant different things per engine and said nothing about it: PostgreSQL split off the first segment and merged the remainder as a LITERAL top-level key (data gained an entry actually named a.b), while SQLite walked every segment and nested properly. The same write therefore produced different documents, and a read could not agree across engines. Such a key is now refused on both, through the normal callback error and leaving the row untouched. The object form ({ data: { k: v } }) is unchanged and still merges one level with LITERAL sub-keys, so an application key that legitimately contains a dot keeps working; PostgreSQL no longer re-encodes it into a dotted string on the way through, which would have made the new check reject it. PostgreSQL also gains one-level $min/$max support, which it silently lacked where SQLite had it. To change something nested deeper, read the entry, modify it, and set it back, or give it its own entry. [PDOT] covers the semantics on every engine the suite runs.

test: bind one server per app instead of one per request

Handed a bare express app, supertest binds a fresh ephemeral port for every request and tears it down after. Across a full suite that was thousands of bind/close cycles, and the resulting port churn was a genuine source of cross-talk: a request could reach a port that had just been recycled and read a response belonging to another listener on the machine, or a desynchronised one. It presented as unrelated suites failing at random under load (spurious 404s, socket Parse Error, hook timeouts) while every one of them passed in isolation, which is why it survived so long as an apparently unfixable flake. The global agent, the CMC/OAuth2 fetch shim and the per-suite agents now all run against an already-listening server, via listeningAgent() which caches one server per app instance so a suite that builds its own application still gets its own. Full matrix went from 3 failing (PostgreSQL) and 5 failing (SQLite) to 0 on both.

api-server: bind loopback when http.ip is unset

startListen passed config.get('http:ip') straight to listen, so an unset or nulled value fell through to Node default of every interface, silently publishing the API to the network. It now falls back to 127.0.0.1, matching the guard the HFS host already had.

mfa: recovery codes are hashed at rest

A recovery code bypasses the second factor, but the codes were stored in the clear on the user profile while the TOTP secret next to them was encrypted. Codes are now stored as SHA-256 digests and the plaintext exists only in the response that shows them once; a profile read back from storage cannot hand any code out. A plain digest rather than a password KDF is deliberate: these are 122-bit random values, so there is no guessable keyspace for a slow hash to defend and a KDF would only add latency to every verification. Verification is constant-time over all entries with no short-circuit, and accepts both shapes, so enrolments made before this change keep working; those legacy cleartext codes disappear as soon as the user re-enrols or recovers, both of which remove them. [MRC] covers it.

mfa: recover stays exempt from the attempt limiter, and its code compare is constant-time

mfa.recover is deliberately NOT subject to the per-account limiter, in either of its steps, and this is now pinned by test ([MA12H], [MA12I]) rather than left as an accident of where the gate was placed. It is the last-resort path: the recovery codes are 122-bit random values so a ceiling buys no security against guessing them, while the password check is the same one auth.login performs unthrottled, so limiting it here would remove no capability from an attacker and would instead let anyone, with no credentials at all, lock a known user out of their own recovery by submitting wrong passwords. The endpoint therefore never reads or writes the throttle on failure and never returns too-many-attempts; a SUCCESSFUL recovery still clears it, since the enrolment it guarded is removed. Separately, the recovery-code comparison now runs in constant time over every stored code (timingSafeEqual, no short-circuit) instead of Array.includes, so neither the response time nor the position of a match is observable ([MA7E]). [MA7D] pins that a wrong password and a wrong recovery code stay uniform for an existing user. No new config and no new error id.

mfa: per-account attempt throttle on the profile

normalizeMfaConfig gained an attempts block (perSession/perAccount/ perAccountWindowSeconds/lockoutSeconds, defaults 5/20/900/900, applied in both the new-model and legacy-mode branches; an absent or unusable field falls back to its default rather than coercing to 0, which would silently disable the limit it governs). The former MAX_MFA_ATTEMPTS constant is gone; the per-session ceiling reads attempts.perSession. A new per-account counter lives on the user’s private profile at data.mfaThrottle = { count, windowStartedAt, lockedUntil }, a SIBLING of data.mfa rather than a field inside it: the profile store expands only one level of dot-notation, and a deeper path is not portable across storage engines, so a one-level sibling key is the shape that behaves identically on PostgreSQL and SQLite. Keeping it outside the enrolment blob also means a routine data.mfa rewrite cannot reset an accrued count as a side effect, so every clear is explicit: on a successful verify/confirm, and on all three recovery paths (mfa.deactivate, mfa.recover, and the admin system.deactivateMfa, whose $unset now covers both keys). The counter is per-core, which is complete rather than a compromise: a user is pinned to one home core, so that core’s profile sees all of their failed second-factor attempts and no cross-core state is introduced. The verify/confirm paths gate on the lock BEFORE verifying (no write while locked, no code-correctness leak) and mfa.challenge honours the lock without accruing. A breach logs one warn (on the transition only, never per guess) and returns the new too-many-attempts (429) error, which also enters the anonymous telemetry error vocabulary and the existing per-user audit row. [MA12A-F] cover the reported reproduction (fresh logins no longer buy a fresh budget, and a correct code is refused while locked), auto-recovery, the perAccount: 0 escape, per-session accrual, recovery-code unlock, and enrolment integrity across throttle writes; [MNORM10-13] pin the config normalization.

fix(rqlite): configurable boot readiness timeout (storages.engines.rqlite.readyTimeoutMs) + a slow rqlited start is now visible in the log

bin/master.js waited for rqlited’s /readyz with a hardcoded 30 s budget, on both the managed path (rqliteProcess.start()) and the external: true path. A node where rqlited legitimately needs longer (large platform dataset, slow disk) failed boot with rqlited did not become ready within 30000ms, was restarted by the supervisor and crash-looped until one attempt happened to land under the limit; there was no config key to raise it. The budget is now storages.engines.rqlite.readyTimeoutMs (default 30000, so existing deployments are unchanged; it must be a positive number of milliseconds, otherwise master refuses to start with a message naming the key). While waiting, master logs a warning at 50% and 80% of the budget, and on success it logs the elapsed time (rqlited HTTP API ready in 27.8s), so an operator can see a node running close to the limit before a boot fails. The timeout error now names the probed URL and the key to raise. The key is declared in the engine manifest, documented in config/default-config.yml, offered as a commented knob by bin/init.js, and shown in INSTALL.md. [RQREADY] unit tests drive the poll loop against a local HTTP stub (success with elapsed time, 50%/80% progress warnings, timeout, connection refused, external path) and pin the value resolution. (open-pryv.io#127)

mfa: TOTP enabled by default + service-info advertises active methods

config/default-config.yml now ships services.mfa.active: true (TOTP default, SMS off) so authenticator-app MFA works out of the box. To make the flip upgrade-safe, normalizeMfaConfig (business/src/mfa/index.ts) precedence was reordered: N0 explicit active:false wins, then a legacy mode shim (N2, now ABOVE N1) so a mode: single|challenge-verify config keeps its SMS-only behavior byte-identically instead of silently dropping to a TOTP-only model where its SMS users would have no active method (a silent MFA bypass the flip would otherwise have caused on upgrade), then the new active:true model (N1). service.info().features.mfa = { methods: [...] } (api-server/src/methods/service.ts, default-method-first, [] when off, absent on older cores) lets clients render only active methods. Note: with MFA active, login.ts mfaCheckIfActive now does one profile read per login that was previously skipped when MFA was off. Tests: normalizer precedence [MNORM8/9], disabled-vs-default [MA1]/[MA15], features.mfa [SN05-07].

mfa: multi-method model + in-process TOTP (registry, config normalizer, at-rest encryption)

The MFA subsystem grew a small MfaMethod interface + a per-method registry in components/business/src/mfa/index.ts, alongside a normalizeMfaConfig that maps both the new active/defaultMethod/methods shape and the legacy mode onto one normalized form (rules N1/N2/N3; a one-time deprecation WARN on legacy mode). The existing HTTP-provider SMS services are exposed through the interface via a thin adapter; the HTTP Service base class is untouched. A new in-process TotpService implements RFC 6238 over a dependency-free primitive (totp.ts: Base32 codec + HOTP + constant-time verify with a drift window and a per-user lastUsedStep replay guard), pinned by the RFC 4226/6238/4648 golden vectors. TOTP secrets are encrypted at rest by reusing the shipped AES-256-GCM AtRestEncryption envelope, under a key resolved from services.mfa.methods.totp.secretsKey or derived from auth.adminAccessKey. The MFA profile and session store now carry the method/totp state and a per-session attempt counter; the API layer resolves the method per user, sets confirmedAt at confirm, persists lastUsedStep at verify before releasing the token, and enforces a 5-attempt session limiter (all methods).

events: harden SQLite duplicate-id detection + add concurrent duplicate-creation coverage

The SQLite events store recognised a duplicate id insert by an exact match on the driver’s error message string. That is brittle: if the message shape ever changes, the branch would silently fall through to unexpected-error (a 500) instead of item-already-exists (409). It now matches on the stable SQLite extended result-code (SQLITE_CONSTRAINT_UNIQUE) scoped to the id constraint by substring, in both create and update. No API-facing behaviour change: a duplicate event id still returns 409 item-already-exists, on the concurrent path as on the sequential one. Added Pattern-C coverage ([EDUP1..3]: concurrent HTTP smoke, engine-contract, mall pass-through) exercising both engines, since the events chain has no existence pre-check and relies entirely on the store constraint mapping.

An open-link capability records each accepter in clientData.cmc.capability.acceptedBy to dedup re-clicks. Nothing cleared that record when the relationship was later revoked, so a withdrawn subject stayed listed and could not re-consent through the same link. The revoke paths now clear the matching accepter entry (only that one — co-accepters are preserved, and the capability state is never touched): the requester’s local revoke (handleRevoke), a raw accesses.delete of the relationship (the delete post-hook, which gains an optional mall dep), and a new handleIncomingRevoke wired into the dispatch middleware for the peer-delivered case (local-only, no outbound, with a legacy bridge that recovers the capability id from the revoke’s offerEventId for relationships minted before the id was stamped). The back-channel access now carries capabilityId so these paths can correlate the relationship to its capability.

fix(backup): integrity verification no longer reports [OK] when it verified nothing

--verify-integrity (and the standalone bin/integrity-check.js) derived its verdict solely from error counts, so a run that checked nothing — integrity inactive for a store, or the store unavailable — was reported identically to one that verified everything clean ([OK] <user> — events=0 accesses=0, exit 0). The integrity report now carries a per-store status (checked / inactive / unavailable) and a top-level verified flag, and both CLIs distinguish [OK] (verified clean) from [NOT VERIFIED] (nothing to compare against); ok keeps its error-free meaning. New exit codes: 1 an integrity failure was found (backup restore rolls it back), 2 restored/checked but could not be verified, 0 fully verified clean. A restore whose verification is not fully green now keeps its backup source (never runs --delete/move-on-success), and a bin/backup.js restore that rolled a user back now exits non-zero instead of 0.

fix(storage): access integrity-preserving update/delete are now atomic

The integrity-preserving updateOne/delete on the accesses store wrote the row hash-less in one statement and restored the hash in a second, as two separate autocommitted round-trips. A concurrent full-store integrity scan on another connection that landed between the statements observed the row without its integrity hash and reported “access has no integrity property” — a load-dependent false alarm (e.g. a back-channel data-grant update racing a scan). Both statements now run inside a single transaction per engine (PostgreSQL and SQLite), so no other connection can observe the intermediate hash-less state, and a failure rolls back instead of leaving the row hash-less. The accesses store also now requires its integrity reference at construction (no silent inert fallback).

fix(reconcile-user-cores): report names that cannot be healed due to a cross-core conflict

reconcileUserCoreMap now returns a conflicts list naming each local user or alias whose routing row points at a DIFFERENT core (owned locally but claimed elsewhere). Such names are never overwritten — they need manual operator resolution — and were previously invisible: the tool silently declined to heal them, so a dry-run counted them as would-be heals while apply healed nothing, making the two disagree. The classification is now consistent across dry-run and apply, and bin/reconcile-user-cores.js prints each conflicting name and the core it is routed to.

fix(multi-core): username checks and reservations must be platform-wide, not per-core

On a multi-core platform every username check read only the core-local user index, so a username hosted on a DIFFERENT core reported as available: each core answered GET /reg/:username/check_username with reserved: false for names it did not host, and the registry hostname round-robins across cores, so a client got a wrong answer roughly half the time. Registration, the /system/users reservation endpoint, and account.changeUsername shared the same blind spot, so a name already hosted elsewhere could be registered or renamed into on a second core, silently repointing the original user’s routing. See https://github.com/pryv/open-pryv.io/issues/122.

A new platform-wide existence check consults the shared name-to-core map on multi-core (single-core is unchanged by construction), and all the availability call sites now use it. Registration reserves the username atomically for the creating core (a redirecting core no longer writes the mapping, so a forwarded registration is never pre-rejected), and changeUsername claims the new name atomically as its cross-core collision gate. The name-to-core map is now kept truthful: deleting a user (and each of its aliases) frees the routing row on every core, and a failed registration releases its claim. A new operator tool, bin/reconcile-user-cores.js, heals historical drift per core (removes self-pointing rows with no local user, recreates missing rows for local users; never touches rows owned by other cores). The engine layer gains an atomic setUserCoreIfNotExists (claim-or-confirm) on both PostgreSQL and rqlite.

While here, two adjacent defects were fixed: registration now rolls back a unique-field reservation if a later field in the same request conflicts (an earlier value no longer stays reserved with no user behind it), and the DELETE /system/users/:username?onlyReg=true admin route now deletes platform fields through the mode-aware layer (the previous raw path read a unique field’s value from an indexed row and bypassed hashing, so it left the unique row behind and broke outright in hashed piiMode).

fix(backup): stop O(n^2) re-gzip that hung backups on large compressible collections

writeChunkedJsonlFiles sized chunks in compressed mode by probing gzip(whole accumulated buffer) on every item once the raw size passed maxChunkSize, and flushed only when that probe exceeded the limit. For highly compressible data (audit logs, repetitive events) the gzip of the whole buffer stayed under the limit, so the flush never fired: the buffer grew without bound and the entire growing buffer was re-gzipped on every item, i.e. O(n^2) synchronous compression that never completed. A full backup hung at ~100% CPU on the first user with a large, compressible collection and never wrote a manifest (issue #121). Chunks are now sized by raw (pre-gzip) bytes and compressed once at flush, so total gzip work is linear and the in-memory buffer is bounded to one chunk. maxChunkSize (and the --max-chunk-size CLI flag) now caps raw bytes per chunk; gzip output stays at or below that, so on-disk files remain within the target while compressible data yields smaller, more numerous chunks. See https://github.com/pryv/open-pryv.io/issues/121.

fix(platform): make Platform.init() idempotent

Platform.init() guarded re-entry on the private #initialized field, but the initialiser set the public initialized field instead, so the guard never fired and init() re-ran its whole body (self-registration, core-URL cache refresh, invitation-token seeding) on every getPlatform() call. That let invitation-token seeding re-run mid-request whenever the platform config listed tokens, seeding and then consuming a token into the platform database where it persisted, which could make a later registration that reused the same token fail with “Invalid invitation token”. init() now sets the guarded field so its body runs exactly once.

chore(docker): migrate base image to node:24-slim

Switch the Dockerfile base from node:24-bookworm to the digest-pinned node:24-slim (Debian bookworm-slim). The build steps are unchanged (python3 + build-essential + curl are apt-installed explicitly and then purged), so the native modules (better-sqlite3, sharp) and the baked-in rqlite binary are unaffected: a fresh image build passes the runtime smoke checks (rqlited -version, and require of both better-sqlite3 and sharp). Slim carries far fewer OS-package CVEs: a same-database Grype scan of the base images drops Critical 60 -> 8 and High 248 -> 21. The built image’s residual findings are now dominated by the application’s JS dependencies rather than the OS layer.

chore(supply-chain): dependency-audit gate, SBOM, image signing + provenance, hardened base image

The build pipeline now detects, gates, and attests the software supply chain.

Added.

Changed. nodemailer bumped to 9, clearing the standing runtime advisories.

Known bound. The pinned node:24-bookworm base image still carries OS-level CVEs that image scanners surface; a slimmer-base migration is tracked separately. The claim here is detection, gating, and attestation, not a CVE-free image.

fix(socket-io): the periodic client-revoke sweep had never run

revalidateConnections() is implemented on NamespaceContext, but the 30-second timer in socket-io/index.ts calls it on the Manager. The call was undefined, so every tick threw manager.revalidateConnections is not a function. The throw was caught and logged at warn, which is why it went unnoticed on deployed cores while emitting two warnings a minute per worker.

A socket authenticates once at handshake and never re-authenticates, so two things can drop a revoked connection: the pubsub notification on an access change, which works, and this timer, which is the backstop for when that notification never arrives. Since a broker loss is exactly the case where it does not arrive (see the pubsub-reconnect fix in the same series), the belt was working and the braces had never been fastened.

Manager now implements revalidateConnections() as a fan-out over its open namespace contexts, which is what the call site already assumed. [SNRS1-3] pin the manager-level shape, the fan-out, and the empty case; without the fix they reproduce the production TypeError exactly.

feat(breach-scope): operator tooling to scope a breach from a compromised accessId

An incident responder who holds only a compromised accessId and a time window can now produce the technical inputs for a breach notification (subjects affected, records affected, categories of data by stream scope) without an O(N) walk over every user or a fragile re-run of the historical query.

Added.

Fixed. Socket.io API calls produced no audit row at all; they are now audited like HTTP calls (best-effort, never breaking the socket response). Batch calls drain the result before auditing so streamed reads finish counting.

refactor(observability): replace the vendor agent with an allow-list emitter

The optional APM integration is rebuilt around a single choke point instead of an in-process vendor agent. Rationale, since it is the whole point: configuring an agent that auto-instruments everything makes the data-protection posture a property of that agent’s evolving defaults, provable only by enumerating what must not escape. Constructing telemetry ourselves makes it a property of two readable files.

Removed. The newrelic optional dependency; the provider boot shim (bin/_observability-boot.js) and its require from all five entrypoints; the provider adapter/config directory; the dead log-forwarder module; the setTransactionName / recordCustomEvent / startBackgroundTransaction façade surface and its call sites, including the named-transaction table in API.ts and the background-transaction wrapper in CertRenewer. Because no library is being patched, the “must be the first require in the process” constraint is gone with it.

Added, all under components/business/src/observability/:

Wiring. API.call is the single instrumentation site: method id, outcome and duration are all known there, and it serves HTTP and socket calls alike. Application.initiate starts the emitter after the routes are registered, because the method registry is the emitter’s vocabulary. Platform’s observability config swaps the vendor key for otlp-endpoint plus an encrypted otlp-headers map, which is what makes the backend interchangeable.

Two defects that only a deployment surfaced, both fixed, and both worth recording because they are the failure grammar this layer was rebuilt to retire: telemetry that reports success while emitting nothing.

The reporting interval is now a real control. It was hard-coded while the documentation already told operators they could widen it to reduce the low-traffic correlation residual. observability set-interval <seconds> stores it in PlatformDB, master resolves it with the rest of the posture, workers receive it, and the emitter clamps it to 60-3600 so a misconfiguration cannot make individual activity finely observable. Default raised 60s to 300s.

Tests. [OBS1]-[OBSQ] (business unit suite) are the filter proof: they ask the validator what it decided for accepted and refused inputs, including a fuzz pass over identifier-shaped keys and values, with a legitimate datapoint pinned in each block so the suite cannot pass by dropping everything. [OB01]- [OB10] cover config resolution, encrypted round-trip, worker env propagation and startup refusal; [OC01]-[OC08] cover the CLI.

fix(config): invalid-config problems now mirror to stderr so a fresh-deploy exit is diagnosable

On a fresh deploy the boiler logger’s only sink is a log file that may not exist yet; an invalid config then produced exit 1 with no output at all, forcing operators to wrap process.exit to find the caller. config-validation.js now factors the reporting into reportProblems(), which writes the header + every problem to stderr (tagged [config-validation]) in addition to the logger, before load() exits — stderr always reaches the operator (terminal, systemd journal, container stdout). Unit test [CVSE-01] asserts the mirror. Also: tools/performance/README.md now names rqlite instead of the removed “platform” SQLite engine.

fix(filesystem): remove the dead attachmentsDirPath config knob

storages.engines.filesystem.attachmentsDirPath was declared required in the engine manifest and set in every config file, but read by no code path: event attachments co-locate with per-user data under the user local directory (storages.engines.sqlite.path) via UserLocalDirectory.getPathForUser(userId, 'attachments'). The knob was a leftover from before that refactor and actively misleading — paths-config and production-config pointed it at a separate directory that stayed empty. Removed from the manifest, default/production config, paths-config and bin/init.js (kept mirrored), documented the co-location in userLocalDirectory + the manifest previews description, and scrubbed the stale INSTALL.md examples (including the encrypted-volume one that recreated the empty-directory trap). No behaviour change — getPathForUser is untouched, so attachments land exactly where they already did; manifest.configuration.fields is declarative (not validated), so removing the field is inert.

fix(observability): the agent never loaded its config file, the high-security opt-in was dead code, and the posture is now test-pinned

Three internal gaps behind the operator-visible scrubbing change.

The agent never loaded the config file. It discovers configuration by scanning NEW_RELIC_HOME for exactly newrelic.js, newrelic.cjs, newrelic.mjs, or whatever NEW_RELIC_CONFIG_FILENAME names, and otherwise falls back silently to environment variables and built-in defaults. The config lived in newrelic.ts, which is invisible to that scan, and the provider boot module only calls require('newrelic') without referencing it. Verified against the pinned agent’s own loader: with the directory as it was, the agent resolved attributes.exclude: [], record_sql: 'obfuscated' and application_logging.forwarding.enabled: true. The configuration is now in newrelic.cjs (.cjs because the package is ESM and the agent uses require), with newrelic.ts re-exporting it so callers and tests read the same object. [OBSC5] asks the agent’s loader what it resolved, so this class of gap fails a test rather than passing silently: every other assertion in the suite inspected our own exported object and was happy throughout.

The high-security opt-in never worked. The worker-env builder read obs.newrelic.highSecurity, but the platform config resolver never returned that field and no CLI command could set it, so the corresponding environment variable was always false regardless of operator intent. The resolver now reads a newrelic-high-security platform row (stored in the clear: it is not a secret, and operators compare it across cores), a local YAML override wins over it exactly as it does for enabled, and observability newrelic set-high-security writes it. Covered by [OBHS]-[OBHS4], which assert the row reaches the resolved config and the worker environment rather than just the database.

Agent config constants are now test-pinned. The exclusion list, URL obfuscation pattern, record_sql: 'off' and the log-forwarding default are quoted verbatim in customer-facing data-flow documentation, so [OBSC]-[OBSC4] assert each of them. A future edit that loosens the posture now fails a test instead of silently contradicting published documentation. [OBSC4] loads the config in a child process on purpose: the log-forwarding default is evaluated at module load and these modules load through ESM interop, so clearing the module cache in-process does not re-evaluate them.

Diagnostics. observability show now prints the high-security state and a summary of what is and is not sent to the provider, and reports the log-forwarding line conditionally since it cannot observe the service process’s environment from another shell. When storage engine start-up fails, the command now explains that engine start-up opens the user-data connection before it reaches the platform database, instead of surfacing a raw driver authentication error that sends operators looking in the wrong place.

fix(messages): pubsub workers now reconnect after the broker is lost

The internal TCP pub/sub bus (used, among other things, to broadcast cache invalidations across workers) had no reconnect path. A worker that lost its connection to the broker — because the broker process crashed, was redeployed or recycled, or because the worker lost the startup race and its first connect failed — stayed disconnected for the rest of its lifetime, logging only a warn. Since access-cache invalidations travel over this bus, a disconnected worker never dropped its cached copy and could keep serving a revoked access indefinitely; on a clustered deployment with requests spread across workers, whether a deleted token still worked became a coin flip until a full restart.

Clients now reconnect with capped exponential backoff, re-run broker election on each attempt (so a survivor takes over the freed port when the elected broker dies), and re-send their subscriptions once reconnected. A failed initial connect no longer strands the module in permanent local-only mode, and a worker that has genuinely lost the bus now logs at error rather than warn. No API or behavioural change on the healthy path. Covered by regression tests that kill the broker mid-run and assert delivery resumes.

fix(api): an API method that fails asynchronously no longer hangs the request

The method-chain runner ran each function inside a synchronous try/catch without observing the promise it returned. A function that threw after an await therefore rejected without anyone noticing: next was never called, the callback never fired, and the HTTP request hung forever with no response and a leaked socket — reproducible with a single request from any valid token via GET /streams?parentId=:<unknown-store>:foo, where the store lookup throws past an await. Such failures now propagate normally and return an error response. A late rejection arriving after the chain has already advanced is logged instead of advancing it a second time. No change on the healthy path; failures that previously hung now behave like any other error.

When integrity is active for accesses, AccessesPG / AccessesSQLite now assert, at write time in applyDefaults, that the access being persisted carries a computed integrity value. If the integrity step is silently skipped (for example because the storage layer was constructed without a real integrity reference and fell back to the inert stub), the write now fails immediately with a diagnostic error naming the access, the process id and whether a real integrity reference was injected — instead of the gap surfacing one operation later as an “access has no integrity property” whole-store scan failure that is hard to trace back to its origin. No API or behavioural change on the healthy path; this only makes an otherwise silent inconsistency loud at its source. Covered by unit tests on both engines.

chore(init): default auth UI is now app-web-user-account

The bin/init.js config wizard and bin/check-config.js now default access.defaultAuthUrl / auth.passwordResetPageURL to the app-web-user-account React app (routes /auth, /reset-password) instead of the deprecated app-web-auth3 (/access/access.html, …). Fresh installs point at the current reference auth+account web app out of the box. No runtime API change; existing deployments are unaffected (their configured values are untouched).

feat(cmc): access-permission gates on all access-mutating lifecycle triggers + chain checks in handlers

CMC orchestration previously treated consent/{accept,scope-update,revoke}-cmc as authoritative user consent regardless of which access wrote them: an app token with :_cmc:apps:<app>:*, contribute could write a consent/accept-cmc event whose capabilityUrl pointed at a colluding requester offering permissions: [{streamId: '*', level: 'manage'}], and the recipient’s plugin would dutifully mint a shared data-grant access on the user with */manage — no consent UI shown, no user-presence guarantee. The fix layers an events.create gate for the access-mint/widen triggers, an access-permission gate inside the revoke handler, and defense-in-depth chain checks in all three orchestrators that mutate access state.

Token-class gate (mint + widen — accept + scope-update)

cmcAcceptAccessGateHook (components/cmc/src/cmcAcceptAccessGate.ts) is a new events.create middleware. It rejects consent/accept-cmc and consent/scope-update-cmc writes when context.access.isPersonal() returns false, with cmc-accept-requires-personal-token (400 invalid-operation, CMC id at error.data.id). Plugin-managed accesses (clientData.cmc.kind === 'capability' or clientData.cmc.role === 'counterparty') are explicitly exempted so the cross-platform handshake — bob’s capability POST to alice’s :_cmc:_internal:responses:<capId> and counterparty deliveries via the shared data-grant pair — continues to work. Reuses AccessLogic.isPersonal(); no parallel “is personal” implementation.

Access-permission gate (revoke)

Revoke is a contraction, not an escalation — the access being deleted bounds the impact. consent/revoke-cmc is NOT in the events.create gate; instead handleRevoke calls triggerAccess.canDeleteAccess(target) before each mall.accesses.delete (data-grant + counterparty). This is the same primitive accesses.delete uses, so the existing selfRevoke feature permission carries over:

Peer-delivered revokes never reach handleRevoke (dispatch’s isPeerDeliveredEvent short-circuit on OUTBOUND_LOOPABLE_TYPES returns 'skipped' first), so the check doesn’t interfere with cross-platform delivery.

Defense-in-depth chain checks inside the access-mutating handlers

The api-server’s accesses.{create,update,delete} routes enforce access.can{Create,Update,Delete}Access(...) in applyPrerequisitesFor{...}, but the CMC plugin used to call mall.accesses.* directly — bypassing those checks. All three handlers now run the same primitive before the storage-layer call, no parallel implementation:

The trigger-writer’s AccessLogic is plumbed through the dispatch’s per-request deps (triggerAccess: context?.access) so the handlers can reach it. handleIncomingAccept’s back-channel mint stays on direct mall.accesses.create — its permissions are bounded by the original request, which is chain-checked requester-side at publish time.

Test coverage

User-facing impact: see CHANGELOG-v2.md “BREAKING — CMC trigger writes that mint or widen accesses now require a personal token; revoke is access-permission-gated” for the wire-level error contracts + the @pryv/cmc.requestAccept / requestScopeUpdate hand-off helpers. No requestRevoke helper is needed — apps holding the relationship access self-revoke directly via the existing cmc.revokeAcceptance / cmc.revokeRelationship calls.

feat(oauth2): foundation component — discovery doc, scope/error/client registries, PlatformDB keyspaces, app-account CLI

New top-level component components/oauth2/ lays the groundwork for the OAuth 2.0 authorization-server surface. No public auth flow yet — /oauth2/authorize + /oauth2/token arrive in a follow-up — but the substrate is fully wired:

Covered by [OAUTH-SCOPE] [OAUTH-ERR] [OAUTH-CLIENT] [OAUTH-WK] (component unit tests) and [PLKV] [OAUTH-STORE] (PlatformDB conformance — both engines).

feat(multi-core): cores join as non-voters by default, so adding a core can’t take an existing core offline

Adding a core could take a previously-healthy core’s control plane offline. A new core joined the Raft cluster as a voter immediately, so a two-core cluster ran at 2-of-2 quorum: if the new core (or any core) then became unreachable — crash, restart, redeploy, transient network — the survivor lost majority, stepped down, and platform reads/writes stalled. Under container orchestrators this was easy to trip, because a zero-downtime health check could start a new core, let it ack and join as a voter, then stop the container — stranding an unreachable voter. Two further snags made the documented join fail or surprise operators: the bootstrap ack pinned the cluster CA (so it failed against a core whose API is fronted by a public/ACME cert), and there was no guidance on the quorum math.

A joining core is now a non-voter by default: it replicates the platform DB and transparently forwards writes to the leader, but does not count toward quorum or vote in elections, so an unreachable joiner can never stall the cluster. The first/single core stays a voter (a lone non-voter cannot elect a leader). Concretely:

Covered by [RQARGS], [APPLYBUNDLE] and [BOOTSTRAPCLI] unit tests. (open-pryv.io#99)

fix(rqlite): embedded rqlited no longer leaves the Raft cluster on shutdown

rqliteProcess.buildArgs() passed -raft-cluster-remove-shutdown unconditionally, so every node removed itself from the cluster membership whenever its process stopped. A process stop is not a decommission: crashes, upgrades, and container reschedules all trip it, so multi-core clusters shrank on every restart and were fragile under orchestrators that recycle containers routinely (the single-core case re-bootstrapped its lone node, which masked the bug in the common deployment). The flag is removed — a restarting node keeps its membership and rejoins via its existing node id and Raft log; permanently removing a node is now strictly a deliberate operator action, not a side effect of the process exiting. The per-worker parallel-test launcher carried the same flag and is cleaned up too. [RQARGS] argv tests updated, with a regression assertion that the flag is never emitted (single- or multi-core); the two-node mTLS cluster integration test confirms cluster formation + replication are unaffected. (open-pryv.io#98)

fix(backup): audit logs are now actually included in bin/backup.js output

BackupOrchestrator was calling auditStorage.forUser(userId).exportAllEvents() without awaiting forUser — which returns Promise<UserAuditDatabase> per the interface contract. The unawaited Promise has no .exportAllEvents method, so every per-user iteration threw userAudit.exportAllEvents is not a function. The surrounding try/catch downgraded the failure to a warn, and the backup completed EXIT=0 with zero audit data for every user. This had been live since 464ce266 (the events-export fix that first allowed the audit step to be reached). Restore from such backups silently dropped the audit trail — a problem for operators relying on it as part of their retained record.

The fix adds the missing await and replaces the orchestrator’s inline AuditStorageLike typedef with an import of the real AuditStorage interface, so a future drift between the orchestrator and the storage contract becomes a typecheck failure instead of a silent runtime warn. The catch is removed: when auditStorage is configured and the export throws, the backup now fails loudly (non-zero exit) — the silent warn was exactly how this lasted from 464ce266 to today. Operators who run without audit (engine declares no auditStorage) are unaffected — the if (this.auditStorage) guard already returned early there.

Audit data exported this way is intentionally raw engine rows (snake_case on PG, per the UserAuditDatabase.exportAllEvents() contract used for migration round-trip). The orchestrator’s _filterByTimestamp does not see camelCase timestamps on those rows and falls to “no timestamp — always include”, which means audit data is currently exported in full on every backup (not incremental). Promoting audit to incremental needs a parallel camelCase export surface and is out of scope for this fix. Regression coverage at [BKP-SHAPE-AUDIT-01..03] pins the missing-await shape so it cannot return silently.

chore(audit): drop the legacy /audit/logs route plumbing

Removed the route handler, the audit.getLogs method + its parameter schema, and every registration site (Paths.Audit, application.ts route wiring, server.ts method wiring, the audit.getLogs entry in the audit ApiMethods list, and the two test-helper registrations). The audit datastore is untouched: recording, the :_audit: store, auditUserStreams/auditUserEvents, syslog, and the storage interfaces all stay — events.get over :_audit: is the supported (and now sole) read path. Audit acceptance tests were repointed to events.get (the deleted legacy-route.test.js is fully covered by the existing [ASTE] events.get suite); Audit.test.js reads audit logs through a dedicated personal “audit reader” access and filters out its own self-audited reads. Dead isAuditActive field in server.ts and dead auditLogs field in Result.ts removed.

fix(backup/test-infra): engine events stores + parallel-checkout test hygiene

chore(deps): dev-tooling refresh — zero npm-audit vulnerabilities, zero install deprecation noise

Dev-dependency refresh; no production dependency changed. mocha 10→11, nyc 15→18, sinon 14→22, superagent 8→10, supertest 6→7. The temp package is replaced by a native os.tmpdir() path in the test-helpers instance manager. The vendored boiler component drops its unused semistandard lint setup (linting is covered by the repo-wide neostandard config). New diff: ^8.0.3 override clears the jsdiff DoS advisory that mocha 11 still pins. npm audit reports 0 vulnerabilities; a fresh npm ci now prints a single deprecation line (glob@10, pinned by latest mocha) instead of fifteen.

fix(storage): SQLite literal escaping + cross-process PG schema-init serialization

Storage-layer and registration robustness fixes, no API impact:

Companion test-infra fixes: the lib-js integration suite now boots HFS correctly (its CommonJS launcher crashed as ESM on every spawn), forwards WebSocket upgrades through the HTTPS test proxy, supports port overrides for parallel local servers, and just clean-test-data fully resets rqlite raft state (stale raft replays previously resurrected ghost platform entries and degraded the local api-server matrix).

chore(lint): one type name, one meaning — canonical-noun guard

A type name now has exactly one meaning across the tree, enforced by lint. just lint fails when a TS source declares a local type/interface whose name collides with a canonical type (the AGENTS.md “Canonical type homes” table) — the error message points at the import instead. Implemented as no-restricted-syntax entries in eslint.ts-any.config.js; only the canonical-home files themselves are exempt. Naming rules: local structural views are XxxLike, engine rows XxxRow, domain-distinct concepts get their own name; when several shapes compete for a bare noun, the API-facing (wire) shape owns it.

A companion ratchet (scripts/open-type-ratchet, chained into just lint) pins the count of open index signatures ([k: string]: unknown) in production sources at 228 — it may only go down. Open index signatures disable typo detection on property reads; new shapes must enumerate their fields unless the openness is load-bearing (dynamic key access, passthrough, the open event data model).

Shipped in two passes, all type-only (no runtime change): first the guard plus three drift fixes (PG/SQLite engine entrypoints import UserOrId instead of carrying identical copies; the mail helper’s Callback renamed MailCallback; five dead type declarations dropped from Result.ts), then a 40-site sweep renaming every remaining bare-noun local (EventEventLike, StreamStreamLike or direct StoredStream use, AccessAccessLike/AccessRef, Permission locals replaced by canonical StreamPermission/FeaturePermission imports where identical, StreamQueryStreamQueryLike/StreamGetOptions, MallMallStreamsOnly/MallLike, LogFnLogLine or boiler imports, ConfigLike→boiler imports, AuditEventAuditEventLike, QuerySeriesQuery, plus singles) and a dead duplicate access view removed from the webhooks methods.

Diskless support — internal changes

refactor(types): typed inheritance seam on the storage engine classes

The per-store engine classes (Accesses/Streams/Webhooks/Profile × PG/SQLite) used to extend their bases through an untyped require(), which made the parent type any and silenced all override-compatibility checking — the blind spot the findDeletions shadowing shipped through. The bases are now generic over the stored item shape (BaseStoragePG<T> / BaseStorageSQLite<TItem>), declare implements UserStorage<T>, and every subclass extends through a typed require handle binding its item type (new StoredWebhook in interfaces/_shared/domain.ts). One cross-engine payload fix shipped with it (latent — no caller reads it): AccessesPG.delete in the integrity path now delivers { modifiedCount, integrityRecomputed } like the SQLite twin, instead of the raw recompute result. Type-coverage floor unchanged at 81 (actual 81.97%).

refactor(types): strongly-typed storage and data-access contracts

The storage interfaces, their engine implementations and the main callers now exchange real domain types instead of structural bags. All type-level — no runtime behavior changed (two deliberate exceptions below); both engine matrices and CI stayed green throughout.

Contracts (storages/interfaces/**):

Data-access layer: new components/mall/src/types.ts exporting interface Mall / MallEvents / MallStreams (classes declare implements); MethodContext.mall uses it; DataStore.supports strongly typed with StoreSupports.

Callers:

Runtime-visible fixes shipped with the typing (each found by the new types):

Guardrails: just type-coverage floor raised 80 → 81 (baseline 81.77%); explicit : any down to 8 lines in 4 documented clusters (heterogeneous middleware registry, multer file bag, boiler logging internals, migration authoring contract).

refactor(types): typing-consistency pass + lint/coverage guardrails

Closes the long-running TypeScript type-tightening effort. The earlier drain removed : any file by file with minimal-diff local types; this pass optimizes for consistency across files and locks the result in. All type-level — no runtime change intended; both engine matrices and CI stayed green throughout.

Consistency / dedup (each family one commit):

Guardrails:

Deliberately out of scope (next effort: strongly-typed interface I/O): nominal domain types — the remaining same-name local types (MethodContext, MallLike, AccessLike, Event, SqliteDb, AccessRow, …) are real wire-vs-stored-vs-engine-row divergences to be modelled once, then threaded through interfaces → engines → callers.

fix(init): wizard preflight reads the NS delegation from the parent zone — no more false warning on first boot

The dns-active preflight’s delegation check did a recursive NS lookup, which needs the zone’s own authoritative server to answer. At wizard time that server is the not-yet-booted embedded DNS, so the check could only fail and warned “No NS delegation found” on every correctly-delegated first boot — training operators to ignore the one check that catches real delegation mistakes.

The check now walks up the label chain to the nearest ancestor zone a public recursor can resolve, then queries one of that zone’s nameservers directly for NS <domain> (via dns2, now an explicit root dependency; 3s timeout race since the bare UDP client has none). Parents serve delegation referrals from their own zone data, so the answer is independent of the child zone’s server being up. Three outcomes: delegation found (✓ + the NS→A-vs-publicIp cross-check as before), parent explicitly serves no delegation (warning — now a true config-error signal), lookup itself failed (informational note, best-effort tier). Verified live against a real parent zone in all three states: correct delegation pre-boot (no warning), undelegated name (warning), delegation pointing at a different IP than declared (wrong-server warning).

fix(platform,dns): reserved service subdomains resolve in dns-active deploys — registerSelf falls back to dns.publicIp

In dns-active (subdomain-per-user) mode the embedded DNS answers the distribution-reserved service names (reg.<domain> / access.<domain> / mfa.<domain>) and <coreId>.<domain> from the ip field of each core’s PlatformDB self-registration. Platform.registerSelf() only read core.ip — a key that neither the config wizard nor the bootstrap bundle sets (both carry dns.publicIp) — so those rows landed IP-less and the names returned empty answers, while /reg/service/info kept advertising register: https://reg.<domain>/: standard onboarding against the advertised register URL could not resolve. Verified live on a wizard-generated single-core deploy (LE staging, real public resolvers).

Fix: registerSelf() now falls back to dns.publicIp for CoreInfo.ip when dns.active is on and core.ip is unset; an explicit core.ip still wins, and non-dns-active deployments are unchanged. 3 new [MCIP] tests pin the fallback, the precedence, and the dns-inactive no-op.

fix(rqlite): single-core rqlited binds its HTTP API on loopback, not 0.0.0.0

rqliteProcess.buildArgs() passed -http-addr 0.0.0.0:<port> unconditionally, while only the raft listener honored the single-core-stays-on-loopback rule the surrounding comment promised. The rqlite HTTP API is plaintext and unauthenticated with full read/write access to PlatformDB (DNS records, core registry, invitation tokens, access-state, encrypted TLS-cert blobs); docker deployments were shielded by the port mapping, but raw single-core hosts exposed it to anything the host firewall let through. Single-core (no core.ip) now binds 127.0.0.1:<port> — all in-process and on-host clients connect via localhost, so nothing changes for them. Multi-core keeps 0.0.0.0 + -http-adv-addr <core.ip> (NAT-aware, as before). The dev/test launcher storages/engines/rqlite/scripts/start carried the same 0.0.0.0 literal and is fixed to loopback too (observed live: a long-running dev rqlited answering unauthenticated on a dev machine’s public interface). [RQARGS] argv tests updated + a dedicated loopback assertion added.

feat(dns,init,acme): dns-active first-boot DNS chain + wizard preflight

Closes the out-of-the-box gap for non-dnsLess (subdomain-per-user) single-core installs. Previously the embedded DNS server shipped empty, so even though ACME published the DNS-01 TXT in-memory, public recursors saw no authoritative answer for the zone and acme-client errored at preflight (No TXT records found for name: _acme-challenge.<domain>). Operators had to hand-run bin/dns-records.js load with a SOA/NS/A YAML before first boot — undocumented and easy to miss.

Validated end-to-end on a real host with NS delegation: real LE production wildcard issued on first boot (after the 60s retry), cert restored from disk on restart, both confirmed externally via openssl s_client.

2.0.0-rc.1 — 2026-06-03

First Release Candidate divider. All entries below this line up to the next ## 2.0.0-* divider were landed during the 2.0.0-pre rolling line and are now sealed under the RC tag. Internal changes are no-API-impact; see CHANGELOG-v2.md for implementer-facing changes.

Notable internal work since 2.0.0-pre open: SQLite full-matrix parity (Plan 76 close, both engines at 2351/0/7), TypeScript + ESM migration foundation (Plan 57 + Plan 64 strict-mode + Plan 65 noImplicitAny + ongoing Plan 80 type tightening), test-isolation hardening ([P4OM] customAuthStepFn race fixed), docker publish gated on git tags only.

chore(init): self-referential image tag in generated launchers

bin/init.js’s defaultImageRef() reads process.env.PRYV_IMAGE_TAG (baked at image build time via ARG IMAGE_TAGENV PRYV_IMAGE_TAG in the Dockerfile) to compute the default pryvio/open-pryv.io:<tag> literal in the generated run-pryv.sh + check-config.sh launchers + the “Start the server” / “Verify the config” fallback hints. CI’s docker/build-push-action@v7 step passes --build-arg IMAGE_TAG=$ on tag pushes, so the published image always carries the same tag it was published under.

Local docker build . without --build-arg IMAGE_TAG=... falls back to dev — a tag that doesn’t exist on Docker Hub, so a wizard run from a local build surfaces the misconfiguration cleanly when the operator runs ./run-pryv.sh and docker can’t pull. No more stale :2.0.0-rc.1 literals across RC bumps.

fix(acme): TLS hot-swap actually works on first-boot + container restart

Two adjacent bugs in the embedded ACME flow that produced the same operator-visible symptom (workers serving a 1-day self-signed cert even after Let’s Encrypt successfully issued the real one):

  1. First-boot hot-swap silently re-loaded the placeholder. The onRotate IPC fanout in bin/master.js sent {type:'acme:rotate', certPath, keyPath} with the FileMaterializer’s paths (<tlsDir>/<hostname>/{fullchain,privkey}.pem), but workers’ Server.reloadTls() ignores the message paths and re-reads from http.ssl.{certFile,keyFile} (the configured boot-time paths) — which still held the placeholder. Fix: master copies the rotated cert over http.ssl.{certFile,keyFile} BEFORE the IPC fanout. New log line [acme] copied rotated cert <materializer-path> -> <ssl-file> precedes the worker reload.

  2. Container restart re-overwrote with a stale placeholder. selfSignedPlaceholder.ensure() checked whether the configured ssl files existed and short-circuited “cert-files-already-exist” — leaving the stale placeholder in place. Even though FileMaterializer had a real LE cert at <tlsDir>/<hostname>/, the placeholder code never looked there. Fix: reorder the function to check the materializer’s per-hostname dir FIRST. If a real cert is present, copy it over http.ssl.{certFile,keyFile} before workers fork. New return field { restored: true, source: <materializer-path> } + log line [acme] restored materialized LE cert for <host> from <materializer-path> -> <ssl-file> on the restart path.

The wizard now pins letsEncrypt.tlsDir: <install-dir>/data/tls so the materializer’s per-hostname dir lives on the same operator-mounted persistent volume as http.ssl.* — both paths converge cleanly on container restart. Operators with custom tlsDir paths get the same behaviour as long as the directory is persisted across container removal.

3 new unit tests pin the behaviour: [SS20] (dnsLess + http-01 restore), [SS21] (dns-01 wildcard restore — wildcard.<host> dir name), [SS22] (half-materialized state — only fullchain.pem present — falls through to placeholder rather than serving an incomplete cert).

feat(init): wizard UX overhaul — no-arg, /app/pryv mount, sectioned YAML

bin/init.js reshaped for the docker run -v "$(pwd):/app/pryv" pryvio init UX. Concrete changes:

Maintenance directive: the optional-sections appendix in buildOptionalAppendix() must stay in sync with config/default-config.yml + config/production-config.yml. New top-level sections an operator commonly tunes need a mirror edit there.


ci: publish docker image on git tags only

The docker job in .github/workflows/ci.yml now publishes pryvio/open-pryv.io to Docker Hub only on git tag pushes, not on every push to master. Master receives many no-behaviour-change commits (TS tightening, lint, comment edits) and publishing an image per commit churned the registry without any release-meaningful artefact.

The release flow becomes:

git tag -a 2.0.0-pre.5 -m "..."
git push --tags

Published tags:

The per-sha tag pryvio/open-pryv.io:2.0.0-pre-<sha> has been retired. Pin hosts to a named tag instead.

storages/sqlite + test-helpers: SQLite full-matrix parity

just test-sqlite all now matches just test all (PG) at exit=0, 0 fail, same baseline pass count. Six fixes, each gated narrowly so the PG matrix is untouched:

  1. test-helpers/src/helpers-base.ts module-load engine override. STORAGE_ENGINE=sqlite now propagates to every test component that loads helpers-base.ts directly (audit, cache, mall, webhooks, …), not just to api-server which loads helpers-c.ts. Without this, non-api-server components booted on the PG default and saw Pattern A child cores’ cross-engine writes in their first checkIndexAndPlatformIntegrity hook. storages:audit:engine is DELIBERATELY left at the default — PG audit storage (UserAuditDatabasePG.createEvent) has a pre-existing eventid NOT NULL violation that fires on [ASTO] if audit is routed through PG. Override gated to STORAGE_ENGINE === 'sqlite': under PG the memory-scope set blocked later injectTestConfig resets the mall suite relies on and timed [MS04]/[MS08] out.

  2. test-helpers/src/helpers-base.ts matrix-hygiene wipe in mochaHooks.beforeAll — calls platform.deleteAll() + usersLocalIndex.deleteAll() once per non-api-server component process. Clears leftover users that api-server’s versioning.test.js [VE07] (POST /users with no cleanup) writes to the persistent rqlite + per-user-file SQLite index. Without this, mall [2Z7L] and audit [U2PV] saw 4-vs-1 repo-vs-platform drift in their per-test integrity hook. Gated to SQLite + non-api-server (api-server runs first and racing with helpers-c.ts dependencies.init() regresses [ACUP07]/[EVNT]).

  3. TestServerContext.spawn engine pass-through (SQLite only) — copies parent’s effective storages.{base,series,file}.engine into the spawned child’s injectSettings. Fixes the 3-failure webhooks [WH01] block: the test parent spawns an api-server child via context.spawn(...), and that child was booting with the default engine. The test wrote a user to the parent’s SQLite store; the child looked it up in PG → 404. Settings are passed via injectSettings (test scope) rather than env (memory scope) so non-SQLite consumers can still pin their child to PG.

  4. SQLite engine: wire the cache internal end-to-end. The engine manifest gains cache in requiredInternals; _internals.ts exposes the getter; StreamsSQLite.{insertOne, updateOne, delete} call _internals.cache.unsetUserData(userId) (or unsetStreams for non-structural updates) to invalidate the per-user permission-level cache on writes — mirroring PG StreamsPG. localUserStreamsSQLite._getAllFromAccountAndCache reads / sets the streams cache so subsequent mall.streams.get is a cache hit. Closes cache [XDP6]: under SQLite, stale AccessLogic permissions after streams.update reparenting kept granting access to a parentId: null move-out.

  5. cache/test/acceptance/cache.test.js [FELT] SQLite skip on the 15%-speedup timing-gain assertion. Local SQLite’s per-user-file fetch is fast enough that the cache memoization adds more overhead than it saves; the isFull() cache-hit invariant earlier in the same test still verifies the integration. Same skip already existed for CI for the same reason. (A future cache-evaluation pass can revisit whether local memoization is worth keeping when storage round-trips are sub-millisecond.)

  6. api-server/test/helpers/validation.js checkObjectEquality — always exclude integrity from comparison when the expected object doesn’t carry one. Previously this only skipped under “approximate match” (i.e. when actual.modified !== expected.modified); under PG the times always drifted from the test’s post-response timestamp.now() so integrity got skipped, but under SQLite the modified timestamp could match exactly, integrity got compared, and the test failed with a phantom + integrity: EVENT:0:sha256-... diff. Removes the engine-shape coupling from [4QRU] (events PUT), [AC01]/[AC18] (accesses), and the cluster of [EVNT]/[CMCNS] intermittent fails the SessionState had tracked as “matrix-only test isolation”.

test-helpers: TestServerContext (lazy fork) replaces SpawnContext

SpawnContext prespawned a pool of child processes at module-load time, capturing process.env then. Under MOCHA_PARALLEL=1 the per-worker DB names + rqlite URL injected by setupParallelWorker in the parent’s mochaHooks.beforeAll arrived AFTER prespawn — so the prespawned children booted against the default pryv-node-test DB and the wrong rqlite endpoint. Tests that grabbed a prespawned child then failed on stale connections (the [SDHF] Pattern A cold-start failures in hfs-server/test/acceptance/store_data.test.js).

TestServerContext (new file components/test-helpers/src/TestServerContext.ts) is a drop-in replacement with the same external API: spawn(customSettings?), shutdown(), Server exposing request() / baseUrl / url(path) / stop() / process.sendToChild(cmd, ...args). The two material changes:

  1. Lazy fork — children are forked at the spawn() call, never ahead of time. No prespawn pool to drain or refill.
  2. Per-fork env captureprocess.env is snapshotted at fork time, so per-worker config injected after module load reaches the child.

IPC protocol with the child is unchanged (msgpack [msgId, cmd, ...args] → child ChildProcess handler → ['ok'|'err', msgId, cmd, ret|errJson]), so the existing launcher scripts api-server/test/helpers/child_process.js and hfs-server/test/support/child_process.js are untouched and server.process.sendToChild('mockAuthentication', false) style mock injection still works.

Consumers migrated:

The legacy spawner.ts (367 LOC) and InstanceManager.ts (180 LOC) are deleted. DynamicInstanceManager (used by api-server + previews-server dependencies.instanceManager for the modern in-process startup path) is unaffected.

Verification: just test hfs-server 60/0, just test webhooks 8/0 sequential PG. just test-parallel hfs-server 60/0 — closes the 4 [SDHF] [SD01]/[SD02] parallel-mode failures. Full just test-parallel all: ~5 failing (down from ~10), all remaining failures are pre-existing parallel-mode flakes in audit (log-count race) + api-server ([PFRC], [PCRO]) — neither uses the spawn surface.

Code-meta-cleanup pass on tests, comments, and component READMEs. No production behavior change.

Test matrix unchanged: just test api-server 1073 passing / 0 failing / 6 pending; just test business 384 passing / 0 failing.

fix(webhooks): cascade webhook deletion on accesses.delete (Plan 72 B)

accesses.delete did not remove webhooks attached to the deleted access (or its descendant shared accesses for an app-access deletion). Webhook rows survived with a dangling accessId and kept firing notifications until manually cleaned. The data exposure is bounded by the signal-only design (the receiver’s GET back authenticates with the now-deleted access and gets 401), but the outbound channel itself was not torn down.

Fix is three small additions:

  1. Repository.deleteByAccess(user, accessId) in components/business/src/webhooks/repository.ts mirrors the existing deleteOne / deleteForUser shape.
  2. deleteAccesses middleware in components/api-server/src/methods/accesses.ts walks the same idsToDelete list (app access + descendants) and calls webhooksRepository.deleteByAccess for each — before the access row is deleted, so a partial failure leaves a retryable state.
  3. Webhook.send() fire-time access-validity check: on cache miss for the parent access, the repository’s new accessExists(user, accessId) is consulted and the webhook self-deactivates (state = 'inactive') when the access is missing or tombstoned. Self-heals orphan webhooks from before this fix shipped, and any future code path that creates a dangling webhook.

WebhooksRepository constructor gained an optional third parameter accessesStorage; existing two-arg callers still work (defensive default accessExists returns true, so no-op for legacy wiring). Three known instantiation sites updated: api-server/src/methods/webhooks.ts, api-server/src/methods/accesses.ts (new), and webhooks/src/service.ts (the dispatcher that actually fires webhooks in-process per Plan 14).

Tests:

Closes the bug chips on hipaa-security.164.308(a)(3)(ii)(C), iso-27001.A.5.16, iso-27001.A.5.18 in compliance-matrix/. GitHub: pryv/open-pryv.io#82.

feat(auth.delete): operator setting audit.onUserDelete (Plan 72 A.2)

Layered on top of A.1 (which made the erasure engine-consistent), A.2 surfaces the policy choice operators need for retention regimes that conflict with the GDPR Art.17 default:

Wiring:

Tests:

Known caveat — SQLite audit + keep mode: the parent deleteAuditData step (filesystem wipe of userLocalDirectory) still runs after deleteAuditDataStorage returns from keep. For SQLite-audit deployments, the per-user .sqlite file lives inside that directory and gets wiped regardless. PG-audit deployments work as designed (rows survive). Operators wanting keep semantics on SQLite need either a PG audit migration, or a follow-up that teaches deleteAuditData to skip when onUserDelete === 'keep'. Logged for follow-up; not blocking the chip discharge.

Closes 3 companion feature chips on the compliance-matrix (gdpr.Art.17 feature, ccpa.1798.105 feature, hipaa-security.164.316(b)(2)(i) feature). GitHub: pryv/open-pryv.io#75 (same issue as A.1; both phases discharge together on merge).

fix(auth.delete): engine-agnostic audit-log erasure (Plan 72 A.1)

auth.delete (the GDPR Art.17 erasure primitive) silently left PG audit_events rows referencing the deleted subject behind. The existing deleteAuditData step in business/src/auth/deletion.ts only wiped the per-user filesystem directory — sufficient for SQLite (whose audit DB is a file inside that tree), insufficient for PG (whose audit_events is a shared table keyed by user_id). AuditStoragePG.deleteUser existed but had only one in-tree caller (the backup-restore preflight); the auth.delete pipeline did not invoke it.

Fix:

  1. New deleteAuditDataStorage middleware in components/business/src/auth/deletion.ts calls require('storages').auditStorage?.deleteUser(context.user.id) (null-guarded — defensive for any future engine that doesn’t declare auditStorage in its manifest; today PG and SQLite both do).
  2. Wired into the auth.delete pipeline in components/api-server/src/methods/auth/delete.ts BEFORE the existing deleteAuditData filesystem wipe — so the SQLite path closes the per-user DB file cleanly before the directory rm fires (avoiding forUser re-creating the dir during the close).
  3. Existing [USAD][9] test in components/api-server/test/deletion-seq.test.js (“should delete user audit events”) gains an engine-agnostic assertion using auditStorage.forUser(userId).countEvents() === 0. The old fs.existsSync regression-guard for the SQLite path stays.

Closes the bug chips on gdpr.Art.17, ccpa.1798.105, iso-27701.A.7.4.5 in compliance-matrix/. GitHub: pryv/open-pryv.io#75. Three companion feature chips on the same rows (operator setting audit.onUserDelete: erase|keep|pseudonymise) stay until Plan 72 Phase A.2 ships.

fix(accesses): align update permission schema with create — accept the same defaultName/name extras

Closes a long-standing wire-format asymmetry between accesses.create, accesses.checkApp (returns checkedPermissions shaped per CREATE), and accesses.update: the first two accepted/produced permission objects with optional defaultName + name fields used during app-authorization UI; the third strictly rejected those fields with OBJECT_ADDITIONAL_PROPERTIES. Naive callers piping checkApp.checkedPermissions straight into accesses.update hit invalid-parameters-format; HDS worked around it by stripping extras client-side in bridgeAccess.ts / Authorization.tsx (see workspace BUGS B-2026-05-14-4).

components/api-server/src/schema/access.ts permissions(action) now extends the streamPermission shape with defaultName + name for Action.UPDATE as well as Action.CREATE — wire-format-symmetric. A new cleanupUpdatePermissions middleware (mirror of the existing cleanupPermissions used on CREATE) strips those fields in accesses.update before snapshotAndApplyUpdate persists, so on-disk shape is unchanged. 2 new [ACUP-SYM] tests: [SYM01] PUT with defaultName+name returns 200 and stored permission has neither field; [SYM02] full checkAppaccesses.update round-trip with checkedPermissions sent verbatim returns 200. The pre-existing [PA03] CMC-handshake test comment that documented the workaround is updated to point at the fix.

Behaviour on the wire is strictly additive — pre-fix callers (who sent bare {streamId, level}) keep working unchanged; the change just stops rejecting the wider shape.

fix(backup): diagnostic shape-guard on every per-user export — replaces cryptic items.filter is not a function

BackupOrchestrator._filterByTimestamp previously called items.filter(...) directly; if any of the 6 upstream exportAll/exportAllEvents calls (streams / accesses / profile / webhooks / events / audit) ever returned a non-array (shape drift in a storage-layer wrapper, missing collection for a partially-provisioned user, etc.), the backup crashed at the first such call with TypeError: items.filter is not a function and no hint about which collection produced bad shape — see workspace BUGS B-2026-05-20-1 (HDS hit this in prod on the very first user). Without prod data to repro locally, the root-cause shape mismatch can’t be triaged from this side; the value here is making the next occurrence diagnose itself.

_filterByTimestamp now takes a source label (streams/accesses/webhooks/events/audit) and asserts Array.isArray(items) before filtering — non-arrays throw a clear error: Backup export shape mismatch: expected array from "streams" (user <id>), got object keys=[rows]. Likely a storage-layer return-shape drift; .... The single profile export (no timestamp filtering) gets the same guard via a sibling _assertArray. 7 new [BKP-SHAPE-01..07] unit tests in components/business/test/unit/backup/orchestrator-shape-guard.test.js. Smoke: just test business 381/0 (was 374 + 7 new tests).

fix(system): make DELETE /system/users/:username error message self-documenting

The admin endpoint refuses calls without ?onlyReg=true because it only deletes the user’s platform-side fields (uniqueFields like email + indexedFields) — it does NOT cascade through base storage (events, streams, attachments) or the audit log. Previous error text “This method needs onlyReg=true for now (query)” said nothing about why — operators ran into it, assumed the endpoint was broken, then either gave up (orphan test users on shared hosts) or filed bug reports. New error text spells out the partial-delete semantic + points at ?dryRun=true for preview. Closes B-2026-05-14-5 (workspace BUGS.md). Endpoint behaviour unchanged. Existing [GF30][GF33] tests still green (6/0 in npx mocha --grep GF3).

fix(storages): drop platformStorage from postgresql/mongodb manifests + fail fast on engine/storageType misdeclaration

Plan 25 made rqlite the only platform engine in production: default-config.yml ships storages.platform.engine: rqlite, and the PG / Mongo PlatformDB implementations are intentionally incomplete (missing the Plan-27 DNS-records methods, the Plan-55 access-state methods, the Plan-35 LE TLS-cert + ACME-account methods, the Plan-38 observability-secrets methods — see workspace BUGS B-2026-05-21-1). Their manifests nevertheless declared "platformStorage", which made pluginLoader.getEngineFor('platformStorage') happily return postgresql when a test config quirk set it that way — only to fail much later via the validatePlatformDB interface validator with a cryptic PlatformDB implementation missing method: <X> and no hint that the root cause was the engine selection.

Two-part fix:

tools/coverage/pg-early-init.js had platform: { engine: 'postgresql' } left over from before Plan 25 / before the PlatformDB scope grew — corrected to rqlite.

New test [PLUG-RESOLVE-MISDECLARE] in storages/test/pluginLoader.test.js (48 passing). Component smoke tests just test business (374/0), just test mall (22/0), just test storages (48/0) all green.

fix(justfile): clean-test-data falls back to system dropdb/createdb/mongosh when local var-pryv/<engine>-bin/ is absent

The clean-test-data + clean-test-data-parallel recipes hardcoded ./var-pryv/postgresql-bin/bin/dropdb etc. — if that local install was absent (e.g. on a Darwin dev box where the operator uses Homebrew / Postgres.app instead of running storages/engines/postgresql/scripts/setup), the recipes swallowed the binary-not-found failure as ... not reachable (skipping ...) and silently left the prior run’s PG/Mongo state in place. Tests then tripped on stale residue (e.g. webhook-lifecycle [WH01] flakes documented in workspace memory).

Both recipes now resolve DROPDB/CREATEDB/MONGOSH by preferring the local Plan-41 install if present, otherwise falling back to whatever is on PATH (command -v dropdb etc.). The skip-message path is preserved but now distinguishes “binary not found” from “server not reachable” so operators see the real cause.

Verified on Darwin: local bin present → unchanged behaviour; local bin hidden + system bin on PATH → fallback invoked correctly; both missing → clear “not found” message instead of misleading “not reachable”.

fix(cmc): auto-provision per-app appScope roots on accesses.create / accesses.update

The 5 reserved parents under :_cmc:* are pre-provisioned at user creation by components/cmc/src/provisioning.ts. Per-app sub-trees under :_cmc:apps:<app-code> were historically created on-demand at CMC-acceptance time (provisioning.ts:21-26) — but the OAuth-grant flow used by doctor-dashboard (via app-web-auth-3) never reaches an acceptance event before the first invite, leaving the per-app root :_cmc:apps:<app-code> missing. Downstream streams.create for a child of the leaf then failed with unknown-referenced-resource (“Unknown referenced unknown Stream”). Bridge-onboarded doctors escape this because their onboarding flow uses a personal token to pre-create the stream — OAuth-onboarded ones cannot.

New createAccessProvisionAppScopeHook in components/cmc/src/hooks.ts, exported via index.ts, wired post-createAccess in accesses.create and post-snapshotAndApplyUpdate in accesses.update (components/api-server/src/methods/accesses.ts). Scans result.access.permissions for any streamId resolving via C.getAppCode() to a valid app-code (matches /^[a-z0-9-]+$/, excludes reserved chats/collectors segments), and lazy-creates the leaf :_cmc:apps:<app-code> as a child of :_cmc:apps via mall.streams.create — same bypass pattern as provisionUserStreams so the reserved-root hook doesn’t reject our own provisioning. Provisioning failures are logged but don’t fail the access response (the access is already stored; surfacing here would confuse the caller — if the stream truly can’t be created, the user’s first child streams.create will surface the same downstream error). Deep app sub-trees (:_cmc:apps:<app>:chats:* / :_cmc:apps:<app>:<...>:collectors:*) keep their on-demand-at-acceptance-time behaviour — this hook only provisions the leaf root.

NEW [CMCHS-AP-PER-APP] describe in components/api-server/test/cmc-handshake.test.js (4 tests, positioned after the existing [CMCHS-AP] block from cad7627):

just test cmc 396/0; just test api-server 1060/2 (the 2 failures are pre-existing [WH01] webhook lifecycle flakes from stale DB residue, documented in workspace memory, unrelated to this change).

test(parallel): stabilize the parallel-mode test matrix on high-core dev boxes

Closes a long-running internal effort to make just test-parallel all (the local-dev productivity tool) survive 14-worker concurrency on a 15-core dev box. Matrix went from a broken 1654/79 baseline (with ~480 tests silently hidden) to 2248/68/3 in ~2:06 wall — and the remaining 3 failures are Pattern A cold-start flakes that don’t reproduce at CI’s 2-worker scale. CI continues to run the sequential PG matrix (just clean-test-data && just test all) as the matrix-of-record because parallel mode disables integrity checks, the caching layer, and cluster_kv IPC fallback semantics — three production-relevant verifications that the sequential mode exercises.

Key fixes that landed:

Remaining flakes (3 tests in api-server: [ACCO]×2, [SYRO]) + 4 deferred follow-ups (hfs-server SpawnContext migration, port-collision proper fix shifting RQLITE_HTTP_BASE 4001→4011, harness cleanup, two non-api-server -seq files) tracked outside this repo for a future pass. A new test-parallel-all.sh wrapper (in the orchestration workspace) runs pre-checks (stops host rqlited, ensures PG + InfluxDB up) then clean-test-data-parallel + test-parallel all.

fix(api-server/accesses): skip auto-create for :_cmc:* permissions

Implementation detail for the user-visible behaviour documented in CHANGELOG-v2.md. accesses.ts::createDataStructureFromPermissions::ensureStream now early-returns when permission.streamId.startsWith(':_cmc:') — the local-store streamId-validity regex (^[a-z0-9-]{1,100}) was rejecting valid CMC-plugin stream-ids like :_cmc:inbox and :_cmc:apps:<app>. The skip is intentionally narrow: the existing :_system: / :system: path is untouched (parses to account store, not local), and any non-CMC local streamId with forbidden characters is still rejected with the same invalid-request-structure error.

Also fixes the chartactercharacter typo at the three sites that share the error wording (accesses.ts, helpers/commonFunctions.ts, helpers/streamsQueryUtils.ts).

NEW [CMCHS-AP] describe in components/api-server/test/cmc-handshake.test.js (3 tests):

Positioned LAST in the file for the same ordering reason CN14 is — extra alice-side :_cmc:* accesses confuse the (username, host, appCode)-keyed back-channel matcher used by CMCHS-IDEMP / CMCHS-EXT / CMCHS-SU.

Matrix at close: just test api-server (PG) 1058/0/7.

build(influxdb): self-contained engine setup, drop apt-based install

storages/engines/influxdb/scripts/setup rewritten to mirror the PG / rqlite pattern — self-contained, no system packages, no sudo. Pins InfluxDB 1.8.10 (matches the influx 1.x npm client; 2.x is API-incompatible per influx_connection.ts). OS/arch detection covers Linux amd64 + Darwin amd64 (via Rosetta on Apple-Silicon since upstream has no darwin-arm64 1.x release). Binary in bin-ext/influxdb/, data + logs + config in var-pryv/, idempotent, generates influxdb.conf with paths pinned. Rosetta-presence check on Apple Silicon catches Bad CPU type in executable with a clear install instruction. New companion storages/engines/influxdb/scripts/start (mirrors rqlite start: pidfile, background-default, foreground via DEVELOPMENT=true). Unblocks fresh-clone dev setup on macOS arm64 + on any non-Debian Linux.

fix(storage/pg): parse InfluxQL time literals as UTC in series query

storages/engines/postgresql/src/pg_connection.ts::parseInfluxSelect appends 'Z' to the captured time literal before new Date(), so JS interprets it as UTC (matching InfluxDB’s own semantics and series.ts:timestampToDateString’s intent).

Latent bug: series.ts:timestampToDateString emits InfluxQL literals like '1970-01-01 00:00:01.000000000' (no TZ marker — InfluxDB treats these as UTC). JS new Date() without TZ parses as LOCAL time. On a non-UTC dev machine (e.g. CEST/UTC+2) the literal became -3,599,000 ms instead of 1000 ms; the resulting nanos range matched zero rows in PG series_data, so any HFS read with a deltaTime offset returned []. Linux CI + Dokku production are UTC, so users were unaffected.

Verified: just test hfs-server 60/0 (was 59/1, [SDHF] [KC15] now passing); full PG matrix just test all 2312/0/8.

fix(cmc): handleAccept reads content.features (was content.extra) — features-negotiation contract drift

One-line fix in components/cmc/src/handleAccept.ts:94 paired with the @pryv/cmc@1.1.1 lib-js patch. The user-visible behaviour change (data-grant access now carries non-null clientData.cmc.features reflecting the negotiated offer features) is documented in CHANGELOG-v2.md. This entry covers the unit-test additions that pin the fix:

feat(cmc): Phase 4 security hardening + Phase 1.1/2.1/2.2/3.1/3.2 fixes (Plan 68 Phase 2)

Wire-up + tests for the API-facing changes documented in CHANGELOG-v2.md (CMC security hardening). Adds:

Earlier Phase 2 phases (1.1, 2.1, 2.2, 3.1, 3.2) shipped on the same feat/cmc-phase-2-hds-readiness branch:

Test deltas (Plan 68 Phase 2 cumulative):

Layer Pre-Phase-2 Post-Phase-2 Delta
open-pryv.io cmc 340 394 +54
open-pryv.io api-server 1050 1055 +5 (CN12/13/15/16/17)
lib-js pryv-cmc 44 55 +11

Plus 4 new deployed-infra validation scripts in _plans/68-cmc-datastore-atwork/tests/ (04-extended-messaging, 05-scope-update, 07-recapture, 08-sdk-handshake) — release-blocking gates before npm publish per Plan 68 Phase 6.

docs: storage-isolation keys for parallel tests

New docs/storage-isolation-for-parallel-tests.md enumerates every config key that a parallel-test fixture must override per mocha worker to avoid cross-worker collisions on shared PG databases, SQLite paths, ports, and rqlite endpoints. Audit confirmed every relevant key is reachable via config.set() and respected by its consumer — no code change needed; the doc is the canonical input for the per-worker test-helper that Plan 61 will ship.

Hardcoded fallbacks in bin/master.js (rqlite URL http://localhost:4001, raftPort 4002, dataDir var-pryv/rqlite-data) and components/api-server/src/server.ts (http:hfsPort default 4000) and components/messages/src/tcp_pubsub.ts (tcpBroker:port default 4222) are intentional for single-core production deploys; the per-worker fixture overrides them explicitly before ready() resolves. The doc pins the convention: code touching these keys must read through config.get() without an in-code literal fallback that would mask a missing config — let REQUIRED_WHEN catch it at boot.

feat(config): lazy-getter sweep across factories + helpers (plan 70 §2C)

Replace const x = config.get('slice') factory captures with lazy getters across components/api-server/src/methods/*.ts + the helpers that consumed captured slices (commonFunctions.getTrustedAppCheck, commonFunctions.catchForbiddenUpdate, eventsGetUtils.findEventsFromStore). After this commit, config.set() / injectTestConfig() / a future async config source reach every per-request callsite without a restart, and the PR-71-class bug shape (factory slice frozen at module init, missing a value populated later by override / plugin / extraConfig) is structurally impossible.

feat(boiler): config.ready() accessor + factory sweep (plan 70 §2B)

New ready() export on @pryv/boiler — the stronger-contract sibling of getConfig(). Documents the “config is ready to trust” contract at the call site: by the time it resolves, sync + async init has completed AND any registered boot-time validators (today: the config-validation plugin’s REQUIRED_WHEN + REPLACE-sentinel walk, which process.exit(1)s on problems) have run. Future Wave 2 work (PlatformDB-backed config, remote-file refresh) will extend the gate without touching every consumer.

feat(config): boot-time REQUIRED_WHEN validation

config/plugins/config-validation.js now refuses to boot when a feature-gated config key is missing or unset. Previously, a missing key silently degraded a downstream consumer at request time — the trigger for this work was PR #71, where auth.passwordResetPageURL could be absent at runtime when a deployment had the password-reset email feature enabled. The Pug template then rendered a broken href that some mail clients silently dropped.

Side-note on the services.email.enabled config shape: today it’s an object ({ welcome: true, resetPassword: true }), inconsistent with the rest of v2’s flat boolean feature-gate convention. The REQUIRED_WHEN predicate for auth:passwordResetPageURL mirrors the existing runtime gating in methods/account.ts:174 to stay consistent during this change. Flattening the schema is a focused follow-up — see _plans/XXX-Backlog/SERVICES-EMAIL-FLATTEN.md in the macroPryv workspace.

docs(cmc): fix wrong Monitor API in IMPLEMENTERS-GUIDE + README

The CMC docs (since Plan 68 first published IMPLEMENTERS-GUIDE.md in commit 02b6d94) used a monitor.subscribe(streamId, callback) API that doesn’t exist on @pryv/monitor. The actual Monitor API is:

const monitor = new pryv.Monitor(connection, { streams: [...] });
monitor.on('event', (event) => { ... });
await monitor.start();

Key semantic differences callers must understand (and the prior docs hid):

This commit sweeps all 14 occurrences in IMPLEMENTERS-GUIDE.md + 1 in README.md and replaces them with the correct pattern. The “bridge multi-tenant subscription” section gets a more accurate representation: one Monitor with a broad scope routes by streamId, OR two Monitors share the same underlying socket — either way it’s one WebSocket per bridge backend. Pure docs; zero behavior change; zero code change in CMC itself.

docs(cmc): bridge multi-tenant subscription = standard one-socket pattern

HANDOVER Q6 asked whether bridges managing thousands of patients need a new multi-tenant socket.io push channel (“inboxArrived”) to avoid opening N WebSocket connections. After working through it: the concern is a misread of CMC’s data direction. CMC traffic from a counterparty lands on YOUR streams, not on theirs:

So the bridge opens ONE socket.io connection on its OWN token, with the SAME standard monitor.subscribe(':_cmc:inbox', ...) pattern already documented, and receives push for every event from every patient over that single connection. The counterparty slug in the streamId identifies the patient. No new socket.io channel needed, no new auth model.

The only N-connection concern is reading patient DATA streams (e.g. real-time vitals push per data-grant) — that’s a Pryv API surface question outside CMC’s scope.

Added a “Bridge / multi-tenant subscription” section to IMPLEMENTERS-GUIDE.md’s Socket.io reference making this explicit. Zero code change. Closes HANDOVER Q6.

docs(cmc): “no new HTTP route namespace” pinned as a design pillar

HANDOVER Q5 asked whether the doctor’s “did patient X click my invite yet?” UX needs a dedicated GET /cmc/capability/<id>/status endpoint. The answer is no — after the Q1 Phase 1 lifecycle the same data lives on the capability access (clientData.cmc.capability.state), reachable via the existing accesses.get. Documented two query paths in IMPLEMENTERS-GUIDE.md (“dashboard render” via accesses.get + “real-time” via socket.io monitor on :_cmc:inbox).

Pinned the “no /cmc/* route namespace” rule as a fourth design pillar in components/cmc/README.md (alongside “plugin, not storage engine” + “zero new storage primitives”). Keeps the plugin a true plugin — no API-surface ownership. Future CMC needs go via clientData filters, trigger-event queries, or socket.io patterns, never via a dedicated /cmc/* route.

CMC dispatch — structural loop avoidance via event.createdBy

The chat / system / scope-update / revoke handlers POST outbound to the peer via the counterparty access. Without a structural guard, a peer-delivered event would re-trigger dispatch on the receiving side and POST right back — the classic A→B→A→B ping-pong. Previously the rate-limiter (rateLimit.ts, 100/60s per (source, recipient)) was the only thing cutting the loop, at the cost of a defensive ceiling that doubles as both abuse defence and runaway-control.

This change splits the concerns:

cmc 327 → 336 (+9). CMCHS handshake 3/3 unchanged (the chat round-trip in CN13 now exits cleanly on the peer side instead of relying on the rate-limiter to cut the loop).

Per-app-code rate-limit override (the original HANDOVER Q4 ask) is captured as a separate backlog plan — operationally useful for high-volume collector apps, but no urgency now that loop defence sits at the right structural layer.

CMC scope-update auto-merges CMC-machinery permissions

handleSystemScopeUpdate’s local-apply branch (the path that synchronously updates the local data-grant before delivering the peer notification) previously wrote newPermissions verbatim. If the caller’s newPermissions omitted the CMC-machinery streams (:_cmc:inbox create-only, the per-peer :_cmc:apps:*:chats:<slug> and collectors:<slug> contribute permissions), those plugin-owned permissions silently disappeared — and chat / system delivery from this peer broke until the next handshake.

Auto-merge now reads the current access via mall.accesses.get, identifies the existing :_cmc:* permissions as machinery, filters the caller’s newPermissions to user-facing only, and overlays the machinery back. Caller can include :_cmc:* perms — they’re filtered out; the plugin owns those.

CMC capability lifecycle — Phase 1 (single-use state machine)

Builds on the typed error-id catalogue: introduces a real two-state lifecycle on the capability access (openconsumed / invalidated) so re-clicks on an already-accepted single-use invite are rejected at events.create time with a typed cmc-capability-consumed error.id instead of silently re-running handleIncomingAccept (and relying on the bug #12 duplicate-name fix to avoid a duplicate back-channel mint).

boiler: skip override-config.yml under NODE_ENV=test

config/override-config.yml is .gitignored and intended only for NODE_ENV=development node bin/master.js local iteration. When a developer left it on disk, the boiler config loader (priority slot .1, above everything else) merged it on top of test-config.yml for just test runs as well, shifting service.api / auth.adminAccessKey / etc. out from under tests that hardcode the canonical test expectations. Three tests ([SVIF] config: serviceInfo, [RGRC] register-records-admin, [SYRO] system route) plus the MFA-DELETE subroutes broke this way for local development; CI never saw it because the file isn’t committed.

CMC typed error-id catalogue (HANDOVER BLOCK-1)

Surfaces the stable kebab-case error.id strings the plugin emits via content.failure.reason on failed trigger events as a single authoritative catalogue. hds-macro Plan 59 Phase 5a’s per-outcome UX can now pattern-match on these constants instead of parsing English error.message.

Plan 68 reopen — CMC test surface hardening

Follow-up to the Plan 68 TEST-GAP-DEBRIEF: Plan 68 shipped with 309 cmc unit tests + 1018 api-server tests but the real-deploy validation suite still found 18 production-only code bugs + 3 fixture issues + 4 CI-only issues. The unit fakes accepted any wire shape, and no test exercised the two-user handshake end-to-end. This reopen closes the two highest-leverage gaps (debrief Phase 1 + Phase 2). Phase 3 (deploy-smoke CI) is parked in the _plans/XXX-Backlog/cmc-acceptance-harness/ backlog.

CMC plugin component — internals (Plan 68)

The :_cmc: namespace + write-hooks + orchestration handlers ship as a new top-level component components/cmc/. The plugin is loaded by the api-server like other components (event-content validation, capability-mint, inbox write-hook, dispatch middleware) plus a post-hook on accesses.update. No new storage engine — the entire plugin runs on standard per-user storage (PostgreSQL / MongoDB) + the existing pubsub layer.

Surface skipped migrations at boot (Plan 69)

A demo deploy on 2026-05-13 hit a ~20 min outage when new code shipped against an unmigrated schema: the operator’s override-config.yml carried migrations: { autoRunOnStart: false } and bin/master.js skipped the migration block in total silence — no log line at all. Every API call against the schema-dependent endpoints returned unexpected-error: column "head_id" does not exist.

The opt-out itself is intentional (operators want manual review on prod). The bug was the silent skip. master.js now always consults the runner.

Behaviour matrix:

autoRunOnStart pending migrations log level shape
true (default) any info unchanged from previous releases
false none info 1 info line: Migrations skipped …; no pending …
false ≥ 1 warn WARNING summary + per-engine WARNING line per row

Access versioning — tests + storage hardening (Plan 66 Phase F)

The [ACUP] test family validates Plan 66 end-to-end and uncovered several storage-path issues that needed fixing.

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

Access versioning — read API + composite-id serialization (Plan 66 Phase D)

Internal plumbing for the new accesses.getOne and the composite-id wire format (see CHANGELOG-v2.md).

Access versioning — update handler + storage snapshot (Plan 66 Phase C)

Wire-up for the revived accesses.update (see CHANGELOG-v2.md). Internal-only plumbing notes:

Access versioning — business primitives (Plan 66 Phase B)

Internal-only utilities that the upcoming accesses.update (Phase C) will consume. No behavior change today besides the Rule D retrofit on accesses.create (see CHANGELOG-v2.md).

Access versioning — storage primitives (Plan 66 Phase A)

Lays the schema and storage-layer plumbing for the upcoming accesses.update revival. No API surface change yet (the wire-format composite id <base>:<serial> and the revived method land in later phases). Both baseStorage engines (PostgreSQL, MongoDB) get the same treatment; engines that don’t store accesses (sqlite, rqlite, filesystem, influxdb) are untouched.

Default storages.series.engine flipped from influxdb to postgresql

In-process HFS ingress dispatcher (api-server)

boiler config getters — getConfigSync() companion; defer module-top reads

ESM flip — components + tests + storages (Plan 57 Phase 5c.2 → 5f close)

TypeScript conversion — storages/ top-level + shared + datastores (10 files, Plan 57 Phase 5c.1)

tsconfig: module/moduleResolution: nodenext (Plan 57 Phase 5b)

Pre-flight characterization tests for ESM flip (Plan 57 Phase 5a)

TypeScript conversion — api-server component (82 source files)

TypeScript conversion — business component (67 source files)

TypeScript conversion — middleware, audit, hfs-server, test-helpers (74 source files)

TypeScript toolchain — direct dep + emit pipeline (no source changes yet)

PG $inc JSONB-path collision — multiple assignments to same column

Audit syslog transport — error listener prevents worker crash on missing socket

Bootstrap bundle schema — v2 (forward-compat-friendly)

Cluster-mode state fixes — accessState on PlatformDB + cluster_kv primitive

A class of bugs where module-scope new Map() looks fine in single-process tests but breaks under cluster.fork() because each worker holds its own copy. Surfaced in production as a 50 % auth-poll failure rate (/reg/access/:key polls round-robin across workers; the second poll lands on a worker whose Map is empty).

Post-deps-bump fix-ups — uuid call sites + backloop.dev lazy require

Two follow-ups missed when the deps bump landed; both crashed the production Docker image at boot before any config was read.

Deploy hardening — single-core LE first-boot, embedded DNS, Dockerfile

A bundle of five fixes surfaced by a fresh single-core Dokku deploy with letsEncrypt.enabled: true + embedded DNS + ACME DNS-01 wildcard.

z-schemaajv (with z-schema-shaped error wrapper)

mongodb driver 4.17 → 7.2 bump

Drop bluebird from production runtime

Drop async (callback control-flow lib) from production runtime

cuid@paralleldrive/cuid2 for production ID minting

lru-cache 7.14 → 11.0; cron 2.4 → 4.4

Tracing as a no-op shim; drop jaeger-client + cls-hooked + opentracing

Dependency cleanup batch — Plan 52 Phase 4

superagent → native fetch complete; superagent moved to devDependencies

superagent → native fetch for business/types.js and business/webhooks/Webhook.js

@pryv/boiler vendored as an in-tree workspace package

CI back to fully green; PostgreSQL-only test job

AGENTS.md — orientation doc for LLM coding agents

In-process mail component (services.email.method = ‘in-process’)

Docker image layout: rqlited moved to /app/bin-ext/

Default baseStorage engine: PostgreSQL

Optional observability — internal shape

Tests

Multi-core registration, service-info, and auth-popup fixes

Surfaced during pryv.me v2 rollout. The items below make cross-core registration atomic, expose the SDK-expected shape of /service/info + /reg/access, and fix several subtle multi-core plumbing bugs that appeared once a real two-core deployment hit a freshly-delegated domain.

Cross-core registration: transparent HTTPS forward

Previously, a POST /users landing on a core whose core.id didn’t match the user’s chosen hosting would call Platform.validateRegistration, which reserved unique fields + wrote user-core/<username> in PlatformDB, then returned {core: {url: targetCoreUrl}} for the client to re-POST. Non-compliant SDKs silently swallowed the redirect, stranding orphaned user-core rows and empty PG on the target core.

/service/info multi-core shape

Distribution-reserved DNS subdomains

/reg/access (auth popup) shape

Multi-core plumbing

systemStreams plugin: sync → pluginAsync

Latent bug since the v2 snapshot — only visible on a cluster that runs under NODE_ENV=production with a production-config.yml that does not re-declare custom:systemStreams:account. On the pryv.me cluster this surfaced as welcome-mail failing with recipient.email = undefined despite POST /users carrying email in the body and returning 201.

Root cause: @pryv/boiler loads default-config.yml AFTER running synchronous plugin extras, but BEFORE awaiting pluginAsync extras (via config.initASync()). The systemStreams plugin reads config.get('custom:systemStreams:account') and builds accountMap + accountFields. When registered as plugin (sync), it ran against a config that still had no custom.* block, so accountMap was missing :system:email, User.loadAccountData never copied params.email → user.email, and registration.js::sendWelcomeMail saw undefined. In dev/test this was hidden because {development,test}-config.yml declare custom.systemStreams.account in the base scope (loaded before sync plugins).

Fix: 16 occurrences of { plugin: require('.../config/plugins/systemStreams') } changed to { pluginAsync: require(...) }. pluginAsync.load(config) is awaited in initASync() (boiler config.js:220), after default-config.yml loads at line 156. All downstream code that reads config.get('systemStreams') (notably accountStreams.init() via await getConfig() in components/business/src/system-streams/index.js) already awaits configInitialized, so no race.

Files touched: bin/{master,bootstrap,migrate,backup,dns-records,integrity-check}.js, components/api-server/src/application.js, components/webhooks/src/application.js, components/hfs-server/src/application.js, components/previews-server/src/{server,runCacheCleanup}.js, components/api-server/test/helpers/core-process.js, components/test-helpers/src/api-server-tests-config.js, components/test-helpers/scripts/dump-test-data.js, components/webhooks/test/test-helpers.js, components/hfs-server/test/acceptance/test-helpers.js.

Test matrix re-verified after the switch — PG 1654/0, Mongo 1676/0. No test asserts a specific accountFields order that would have flipped with the new merge behaviour.

Config validation: fail fast on unresolved ${VAR} placeholders

production-config.yml uses shell-style ${PRYV_LOGSDIR} / ${PRYV_DATADIR} placeholders in path values, but nothing in the boiler/nconf stack actually expands them. When the env var was unset at NODE_ENV=production (e.g. a stray bin/server run during live debugging), Winston’s file transport treated the literal string as a path and created a directory named ${PRYV_LOGSDIR} on disk.

Fix: config/plugins/config-validation.js::checkIncompleteFields now matches \$\{([A-Z_][A-Z0-9_]*)\} in every string value alongside the existing REPLACE sentinel scan. Unresolved placeholders fail startup with a clear error naming the missing env var. Same active: false / enabled: false block-skip rules apply. .gitignore also picks up the literal ${PRYV_LOGSDIR} / ${PRYV_DATADIR} names so an accidental stray dir doesn’t pollute git status.

v1→v2 restore: user-core/* rows from register/servers.jsonl.gz

Tests

Validator + service-info method: fixes unearthed by the full test matrix

Surfaced when running the full matrix against the distribution changes above. Changes are small, isolated, and carry no API behaviour impact.

Engine-agnostic schema migration runner

New primitive

Legacy removed

Wiring

Tests

Persistent DNS records — admin surface

Context: end-to-end persistence of runtime DNS records (PlatformDB interface, rqlite backend, DnsServer load-on-start + 30 s refresh + write-through, POST /reg/records persistence, existing [RGRC] test) was shipped earlier in the 2.0.0-pre line. This change adds the DELETE symmetry and the offline-capable admin CLI — the remaining gap for operating DNS records in production without depending on the HTTP API being healthy.

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

Green-field installs previously needed separate DNS + ACME + reverse-proxy setup before serving a single HTTPS request; multi-core wildcards required a DNS plugin plus manual cert copies across every node. Opt-in letsEncrypt.* folds all of that into the core: issuance, renewal, cluster-wide distribution, hot-swap on rotation.

New module components/business/src/acme/ (8 files)

PlatformDB primitives

Wiring

Integration test

Real-world validation (outside the CI test suite)

A 3-level spike against Let’s Encrypt STAGING in _plans/35-letsencrypt-integration-atwork/spike/ proved the end-to-end flow: our dns2 authoritative server published _acme-challenge.test-dns.datasafe.dev TXT records through the full . → .dev → datasafe.dev (Infomaniak) → test-dns (us) delegation chain. LE issued a real staging wildcard cert (*.test-dns.datasafe.dev + test-dns.datasafe.dev). 15 distinct validator IPs across 5+ AWS regions (Frankfurt, Singapore, Stockholm, Oregon, Ohio) all retrieved TXT + CAA correctly — multi-perspective validation fully exercised. Spike also confirmed https.Server.setSecureContext() hot-swaps the cert for new TLS connections without breaking in-flight keep-alive HTTP sessions.

Test totals

Multi-core bootstrap CLI + rqlite mTLS

Single-to-multi-core upgrade no longer requires hand-editing override YAML on the new host or copying platform secrets across by hand. An operator runs one CLI on the existing core, transfers a sealed bundle to the new core, and starts the new core in --bootstrap mode. Raft traffic between cores is mutually-authenticated TLS by default.

rqlite mTLS argv passthrough

components/business/src/bootstrap/ (new, 8 modules)

Wiring

End-to-end test

Test totals

Test hardening + deploy validation

Dockerfile bundling & external rqlite mode

Test data cleanup: just clean-test-data resets MongoDB + rqlite

Backup chunking fix

Multi-core refinements & platform config model

DNSless multi-core minimum

Persistent DNS records via PlatformDB

Configuration model: platform-wide vs per-core

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

New business module components/business/src/mfa/

API methods components/api-server/src/methods/mfa.js

HTTP routes components/api-server/src/routes/mfa.js

Login integration components/api-server/src/methods/auth/login.js

Config defaults config/default-config.yml

Tests

Dropped

rqlite as the only platform engine

Platform DB

master.js / lifecycle

Migration script moved

Test infrastructure

Documentation

Bug fix discovered during rqlite-engine live test (me-dns1.pryv.io v1.9.0 backup)

Test deploy with Dokku

Multi-core support

Deployment tooling

RestoreOrchestrator

Migration toolkit v1→v2

Backup writer: target file size on compressed output

Backup, restore & integrity

Backup/restore system (storages/interfaces/backup/)

Integrity verification (components/business/src/integrity/IntegrityCheck.js)

CLI tools

pryv-datastore v1.0.2

Tests

Test coverage & dead-code removal

Coverage tooling (tools/coverage/)

Bug fixes

Dead code removed

Full PostgreSQL backend

PG as complete single-core engine

Performance

Reliability

Engine tests

Config

Performance tracking

Benchmark tool (tools/performance/)

Registration service merged into core (from service-register)

Config & storage

Registration

Multi-core

DNS server

Legacy routes

Tests

Replace GraphicsMagick with sharp

Integrate lib-js tests

Consolidated master process (single Docker image)

Quick wins (inlined RPC & webhooks)

Cluster master with API + HFS workers

Previews worker

Single Dockerfile

Socket.IO cluster compatibility

Removed: openSource:isActive flag

System streams refactor

Account store architecture

Dead-code removal in system streams

Simplify permissions on system streams

Decouple tests from SystemStreamsSerializer

Remove active/unique markers

Flatten and reduce serializer

Rename and finalize system streams module

Cleanup NATS/Axon naming remnants

Replace NATS with built-in TCP pub/sub

Remove Axon test messaging

Remove FerretDB support

Engine-agnostic series, deletion, and test fixes

Engine-Agnostic Series Connections for Tests

PG Series Nanosecond Fix

Engine-Agnostic HF Data Deletion

Test Infrastructure Fixes

Fix PG tests, remove FollowedSlices, engine-agnostic cleanup

FollowedSlices Removal

Engine-Agnostic Test Helpers

HFS-Server Engine-Agnostic Fixes

Integrity Checks

Dual storage engine — PostgreSQL backend

Global Storage PG Backends

User-Scoped Storage PG Backends

Account & Index PG Backends

PG DataStore for Mall (Events + Streams)

PG Series Connection (InfluxDB Replacement)

Schema Update

Wiring

Dual storage engine — configuration & abstraction

Unified Storage Engine Configuration

Storage Engine Helper

PostgreSQL Connection Wrapper

Engine-Aware Routing

Dependency

Parallel test migration

Enforce interface usage

Move verified Pattern C tests to parallel

Deduplicate sequential tests

Result

Formalize storage interfaces

User-scoped storage interface (Group B)

Global storage interfaces (Group C)

Dual-engine storage interfaces

UserAccountStorage Interface (Group D)

UsersLocalIndexDB Interface (Group E)

PlatformDB Interface (Group F)

EventFiles Interface (Group G)

Series / InfluxDB interface (Group I)

Audit / UserSQLite interfaces (Group J)

Exports & Migration Scripts

Removed deprecated features from v1

Trivial cleanup

Stream ID prefix backward compatibility

Remove deprecated /register/create-user endpoint

Remove streamId (singular) backward compatibility

Remove tags backward compatibility

Final cleanup