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):
[PA01]—accesses.createwith a:_cmc:apps:<new-app>perm auto-provisions the leaf (verified by re-attempting the leaf create and assertingitem-already-exists).[PA02]—accesses.createreferencing a pre-existing leaf perm returns 201 (idempotent on the provisioning side).[PA03]—accesses.updatethat adds a per-app perm provisions the new leaf. (Send bare{level, streamId}perms in the update body —accesses.updaterejects the lenientaccesses.createshape per the documented permissions-shape asymmetry.)[PA04]—accesses.createwith a deep:_cmc:apps:<app>:chats:*perm also provisions the leaf, since any descendant create requires it.
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).
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:
clean-test-data-parallelauto-derivesWORKERSfromMOCHA_JOBS/cpus-1(was hardcoded'8', hiding workers w8..w13 on 15-core boxes).- Adaptive
computePgPoolMax()inparallelWorkerSetup.ts+ bumpedvar-pryv/postgresql-data/postgresql.confmax_connections50 → 300; also bumped in fresh-installstorages/engines/postgresql/scripts/setup. DynamicInstanceManager.start()force-pins per-worker DB names into the spawned api-server's tempfile config, defending against mid-suite boiler-config reverts.- Per-worker
mochaHookscoverage extended towebhooks/,previews-server/,storages/engines/postgresql/. Newstorages/engines/rqlite/.mocharc.cjsso engine tests don't inherit a non-applicabletest/hook.js. business/mocharc + hook now chainhelpers.dependencies.init()intobeforeAllso every parallel worker initialises the StorageLayer proxies — fixes the[WHBK] Repository.insertOnehang.[XS12]accessState clear routed through cluster.request(0, 'clear') because parent test process doesn't init storages.[ACMEINT]acme-integration.test.jsnow uses per-worker rqlite URL (via boiler env-mirror) — eliminates the worker-0 leader-election 503 race.setupParallelWorkerpre-inits the per-worker rqlitekeyValuetable (closes[PCRO]/[RGLG]"no such table" race).- Integrity-check
beforeEach/afterEach(sequential mode) now gated onstorages.storageLayerbeing initialised — closes the historical Darwin Mongo[EVNT]crash whereensureBarrel()early-init locked pluginLoader to the wrong engine beforeinjectTestConfigcould apply the override. - Cross-component HttpServer port collision on 6123 between
api-server/test/support/httpServer.jsandbusiness/test/acceptance/webhooks/support/httpServer.jsresolved by per-worker port shift (6123 + MOCHA_WORKER_ID*10) + a properawait listen()Promise wrapping'listening'/'error'.
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.
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 chartacter → character 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):
[AP01]—accesses.createwith:_cmc:apps:<app>+:_cmc:inboxperms returns 201 + preserves perms.[AP02]— full doctor-dashboard / app-web-auth-3 shape (local app stream + two:_cmc:*perms in one call).[AP03]— regression-pin: truly invalid local streamIds still get rejected withinvalid-request-structure+ the fixedforbidden character(s)message.
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.
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.
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:
- NEW
[HA01F]—handleAcceptreads features fromtriggerEvent.content.featuresand stamps them on the data-grant;content.extradecoy is ignored. - NEW
[HA01G]— whencontent.featuresis absent the data-grant'sclientData.cmc.featuresstaysnulleven ifcontent.extrais set (compat with legacy SDK callers).
Wire-up + tests for the API-facing changes documented in CHANGELOG-v2.md (CMC security hardening). Adds:
components/cmc/src/hooks.ts: four new middleware factories —createAccessCreateForgePreventionHook,createAccessUpdateForgePreventionHook,createStreamDeleteReservedRootHook,createCounterpartyFromStampingHook,createEventsGetInternalGuardHook,createEventGetOneInternalGuardHook,createStreamsGetInternalGuardHook. All follow the pure-factory pattern (no api-server deps, unit-testable with fake deps).components/cmc/src/constants.ts: newisCmcInternalStreamId(streamId)predicate.components/cmc/src/errorIds.ts: newCLIENTDATA_CMC_FORBIDDEN(cmc-clientdata-cmc-forbidden) + reusedCHAT_NO_REMOTE_APIENDPOINTsemantics.components/api-server/src/methods/accesses.ts: wires forge-prevention hook into accesses.create + accesses.update.components/api-server/src/methods/events.ts: wires from-stamping + events.get/getOne internal guards.components/api-server/src/methods/streams.ts: wires streams.delete reserved-root + streams.get internal guard.
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:
- Phase 1.1 (inviteEventId stamping) —
handleIncomingAcceptlooks up the capability access once, stampsinviteEventIdon the inbox mirror fromcapabilityAccess.clientData.cmc.requestEventId. Closescmc.revokeRelationship({inviteEventId})doctor-side convenience path. lib-js:[CMCL1RC]+[CMCL1RD]cover the post-stamping lookup contract. - Phase 2.1 (TTL configurable per-invite) —
capabilityMintHookreadsevent.content.request.expiresAt, bounds-checks to[60s, 30d], rejects out-of-range withcmc-capability-ttl-out-of-range. Default unchanged (7d whenexpiresAtomitted). - Phase 2.2 (features gating) —
handleChat/handleSystemconsultclientData.cmc.features.{chat, systemMessaging}on the counterparty access at send time. Reject withcmc-chat-disabled/cmc-system-messaging-disabledwhen explicitlyfalse(default-permit on omission). - Phase 3.1 (scope-update suppression) — verified
accessesUpdateHookrunsaccesses.updateinsiderunWithSuppressionso the post-hook doesn't double-fire the peer notification. - Phase 3.2 (
requestEventIdreal-flow) — newcreateCapabilityPostCreateHookmiddleware runs AFTERcreateEventassigns the trigger's real id, callscapability.setRequestEventIdOnAccess(...)to stamp it on the capability access'sclientData.cmc.requestEventId. Pre-fix the mint hook fired pre-persist whenevent.id === nullsorequestEventIdwas always null in production (HDS-reported).
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.
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.
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.
- CHANGE
methods/account.ts: replaceauthSettings/servicesSettingscaptures withgetAuth/getEmailgetters. Drop the PR-71 request-time fallback (auth.passwordResetPageURLre-read + warn) — the §2A REQUIRED_WHEN check now guarantees the key is populated at boot. TheRESET_LINKPug substitution stays because it's a real ergonomics improvement. - CHANGE
methods/auth/login.ts:getAuthgetter alongside the pre-existinggetMfaConfig. - CHANGE
methods/auth/register.ts: pass a getter function tonew Registration(). - CHANGE
methods/events.ts:getAuth+getUpdatesgetters. ThefilesReadTokenSecretHMAC seed andupdates.ignoreProtectedFieldsvalidator gate are the highest-severity audit rows — every attachment file-read token and everyevents.updatevalidator depends on them. - CHANGE
methods/streams.ts,methods/system.ts,methods/webhooks.ts: lazy getters forupdates,services,webhooksrespectively. - CHANGE
methods/helpers/commonFunctions.ts::getTrustedAppCheck: signature changes from(authSettings)to(getAuthSettings). The closure-cachedtrustedAppslist is dropped — re-parsed per-request from the fresh slice. Negligible cost; strictly more correct. - CHANGE
methods/helpers/commonFunctions.ts::catchForbiddenUpdate: second argignoreProtectedFieldUpdates→ignoreOrGetter(back-compat: accepts literal OR function viatypeof === 'function'branch). - CHANGE
methods/helpers/eventsGetUtils.ts::findEventsFromStore: first argfilesReadTokenSecret→secretOrGetter(same back-compat shape). - CHANGE
business/src/auth/registration.ts: constructor storesgetServicesSettings(function); accepts literal OR getter. Welcome-mail send path readsthis.getServicesSettings()?.emailper-call.
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.
- NEW
ready()incomponents/boiler/src/index.ts. On the current codebase semantically equivalent togetConfig()— the value-add is the documented contract + the hook point for Wave 2. - CHANGE 14 factory call sites under
components/api-server/src/methods/*.ts(account, mfa, service, system, utility, auth/delete, auth/register, helpers/setCommonMeta, events, streams, trackingFunctions, webhooks, auth/login, helpers/updateAccessUsageStats) now useawait ready()instead ofawait getConfig().getConfig(),getConfigSync(),getConfigUnsafe()remain exported and unchanged for non-factory consumers. - NEW
components/api-server/test/boiler-ready-seq.test.js—[CONFIG-RDY]describe block. Six unit tests pin the exported shape, the identity withgetConfig()/getConfigSync(), the resolved test-config values, idempotency, and the key contract that Plan 70 §2C + Plan 61 both depend on:config.set()afterready()resolves is visible to the next.get()call.
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.
- NEW
REQUIRED_WHENtable in config/plugins/config-validation.js. Each entry pairs a colon-separated config path with awhen(config)gating predicate. When the predicate is truthy, the key must resolve to a non-empty, non-sentinel value (REPLACE …,${VAR}env placeholders,null/undefined, empty string all fail) — otherwise the existingprocess.exit(1)path fires after logging every problem in one pass. - Initial seed:
auth:passwordResetPageURL(when reset-password email is enabled — mirrors the runtime gating inmethods/account.ts:174againstservices.email.enabled),auth:adminAccessKey(always),auth:filesReadTokenSecret(always; the multi-core bootstrap bundle already enforces this — single-core deploys had no equivalent guard),letsEncrypt:atRestKey+letsEncrypt:email(whenletsEncrypt:enabled === true). - NEW exports:
validate,checkRequiredWhen,isMissingOrSentinel,REQUIRED_WHEN. Lets the new[CV-REQ]unit-test suite exercise the validator with a fake config object without booting the boiler init lifecycle. - NEW
components/api-server/test/config-validation-required-when-seq.test.js—[CV-REQ]describe block. Twelve unit tests cover the predicate matrix (enabled-missing / enabled-present / disabled), theisMissingOrSentinelclassifier, and the shape ofREQUIRED_WHEN.-seqbecause the api-server mocha hooks run a Platform DB integrity check around tests; the validator tests themselves do not touch storage. - CHANGE
config/test-config.ymladdsauth.passwordResetPageURL: http://test.pryv.local/reset-passwordso tests that overrideservices.email.enabled = true(e.g.[G1VN],[HZCU]inaccount-seq.test.js) pass the new REQUIRED_WHEN check. The URL itself is not used by the test (the SMTP transport is mocked) but the key must be present to satisfy the boot validator.
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.
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):
- Scope is fixed at construction. You can't add/remove streams
after
start()— to watch a different scope, construct another Monitor (each shares the underlying socket.io connection via the@pryv/socket.ioadd-on). - One
'event'callback per Monitor. Branch onevent.type/event.streamIds[0]inside the callback.
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.
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:
- Patient sends chat-cmc → arrives on bridge's
:_cmc:apps:bridge-app:chats:<patient-slug>. - Patient sends notification/ack → arrives on bridge's collectors stream.
- Patient writes revoke / accept → arrives on bridge's
:_cmc:inbox.
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.
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.
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:
components/cmc/src/dispatch.ts— newOUTBOUND_LOOPABLE_TYPESset +isPeerDeliveredEventhelper. Before invoking a handler for one of those types, the dispatch resolvesevent.createdByto its access on this mall; if the access hasclientData.cmc.role === 'counterparty'the event arrived from a peer's POST → return{ status: 'skipped', reason: 'cmc-incoming-from-peer' }, no outbound. The rate-limiter remains as defence-in-depth for abuse / quota.- Lifecycle handlers (accept / refuse / back-channel / request) are
exempt — their dispatch is direction-aware via
isOnInbox, and the incoming variants do real protocol work. - IMPLEMENTERS-GUIDE.md gains a "Reference — Loop avoidance" section documenting the two-layer defence.
[CMCDISP-LOOP]test block — 9 new tests: 6 outbound types × skip-when-counterparty, plus non-counterparty-passes, missing- createdBy-passes, lifecycle-type-exempt.
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.
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.
components/cmc/src/handleSystem.ts— local-apply branch updated. ~20 lines change.[HS28a]+[HS28b]tests cover both the auto-merge happy path and the caller-supplied-CMC-perm filtering.- IMPLEMENTERS-GUIDE.md scope-update section gains a paragraph documenting the auto-merge contract.
- cmc 325 → 327 (+2).
Builds on the typed error-id catalogue: introduces a real two-state
lifecycle on the capability access (open → consumed /
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).
- API surface —
consent/request-cmc.content.capability.mode(optional, default'single-use'):'single-use'enforces the state-flip,'open-link'mints with mode set but state stays'open'until Phase 2 lands (lifecycle enforcement for open-link is the backlog plan). components/cmc/src/errorIds.ts— renameCAPABILITY_UNKNOWN→CAPABILITY_INVALID(BC: the prior name only existed on the Plan 68 reopen branch, never on master). New constantsCAPABILITY_CONSUMED+CAPABILITY_INVALIDATED.cmc-capability-invalidcovers "never existed + expired past TTL";cmc-capability-consumedis the new state-flip rejection.components/cmc/src/capability.ts—mintCapabilityaccepts an optionalmodeparam (defaults to readingtriggerEvent.content.capability.modeif present, else'single-use') and stampsclientData.cmc.capability = { mode, state: 'open', stateChangedAt }on the access. Two new exports:findCapabilityAccess(userId, capabilityId)andmarkCapabilityConsumed(userId, capabilityId). The pre-existing legacysingleUse: trueadvisory flag is preserved.components/cmc/src/capabilityResponseHook.ts(new) — events.create middleware that gates writes to:_cmc:_internal:responses:<capId>by the capability access's state.'consumed'→ typed errorcmc-capability-consumed;'invalidated'→cmc-capability-invalidated. Legacy capabilities (minted before this lifecycle field existed) and'open'state pass through.components/cmc/src/handleIncomingAccept.ts— after a successful accept-arrives flow (back-channel access minted), callsmarkCapabilityConsumedto flip state. Open-link mode capabilities skip the flip (state stays'open'). Best-effort; the back-channel is already minted so the relationship is established even if the state-flip fails (only the next re-click would mint a duplicate back-channel — same as today's behaviour).components/api-server/src/methods/events.ts— wires the new hook into the events.create chain right aftercmcInboxWriteHook, before the persist step.- IMPLEMENTERS-GUIDE.md — Error id catalogue updated; the gaps
list narrows (the formerly-distinct
cmc-capability-staleis collapsed intocmc-capability-invalid; tombstone-based finer discrimination is the explicit backlog work). - Tests:
[CMCCAP-LF]7 new incapability.test.jscovering mint-time field stamps, find/markConsumed primitive, idempotency.[CMCCRH]6 new incapabilityResponseHook.test.jscovering passthrough + rejection paths. cmc total 312 → 325 (+13). CMCHS handshake 3/0 unchanged.
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.
components/boiler/src/index.ts+src/config.ts— newskipOverrideConfigoption onboiler.init(). Whentrue, the override-config.yml load is skipped; the rest of the chain (memory → test → argv → env →${NODE_ENV}-config.yml→ extras → default-config) is unchanged.- Default behaviour:
skipOverrideConfigdefaults totrueunderNODE_ENV === 'test'and tofalseotherwise. Production and development runs continue to loadoverride-config.yml. Callers can still passskipOverrideConfig: falseto force-load. components/test-helpers/src/api-server-tests-config.tspasses the flag explicitly for documentation; child processes spawned bySpawnContextinheritNODE_ENV=testand therefore get the default skip without each spawn-target having to know about it.
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.
- New
components/cmc/src/errorIds.ts—CmcErrorIdsconstants object (as const) enumerating 22 stable ids across capability lifecycle, trigger content, handler routing, counterparty resolution, access mint, outbound delivery, and chat/system handler outcomes. Type-exportCmcErrorIdfor TS consumers. - New typed detection:
readOfferViaCapabilityinacceptOrchestration.tsnow stampsCmcErrorIds.CAPABILITY_UNKNOWN(cmc-capability-unknown) on HTTP 401 responses. Previously these collapsed into the genericcmc-handler-offer-read-failed. Covers three runtime cases that look identical from the client today — token never existed, token expired (past TTL, plugin-GC'd), token already consumed. Finer discrimination (cmc-capability-stale,cmc-capability-already-accepted) requires capability tombstones — design call deferred pending the 07-recapture probe outcome. - No rename of existing reasons. Pre-existing
cmc-handler-*strings inhandleAccept.tsstay as-is for back-compat with any client matching oncontent.failure.reason.errorIds.tsexposes them under semantic names but value-strings are unchanged. - Exported via
cmcindex:cmc.CmcErrorIds+ the namespacecmc.errorIds(matches the existingcmc.constants/cmc.slugpattern). - Documented: IMPLEMENTERS-GUIDE.md gains a new "Reference — Error id catalogue" section listing every id, when it fires, and the gaps that need a design call.
- Test:
[AO04B]asserts the 401 →cmc-capability-unknownmapping. cmc 311 → 312 (+1).
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.
- Phase 1 — Unit-test fakes pin the wire contract. New shared
helper
components/cmc/test/_fake-assertions.cjsexportsassertEventUpdateShape(rejects{ id, update: {...} }— Plan 68 bug #1) andassertOutboundUrl(whitelists Pryv API paths + permitted query params; rejects?streamIds=— bug #2). Wired into everyfakeMall.events.updatesite (4 files) and everyfakeFetchsite (7 files;outbound.test.jsdeliberately skipped since it IS the URL builder under test). - Phase 2 — In-process two-user handshake. New file
components/api-server/test/cmc-handshake.test.jsexercises the full request → accept → back-channel → chat handshake between two real users on the in-process api-server. Three tests:[CN12]happy-path handshake;[CN13]chat round-trip;[CN14]accept re-delivery idempotency (regression for bugs #12 + #13). Transport: a fetch shim that routes URLs whose host matches127.0.0.1:3000/localhost:3000(the test override-config'sservice.api) throughcoreRequest(the in-process supertest agent); pass-through for any other host (data-typesflat.json, rqlited at:4001). - Supporting change
components/api-server/src/methods/events.ts— the cmc plugin deps now captureglobalThis.fetchlazily via(url, init) => globalThis.fetch(url, init)instead offetch: globalThis.fetch, so a test that installs a fetch shim after middleware registration is picked up by the dispatch loop. Production: one extra closure indirection per call. Two call sites tweaked (cmcDispatchMiddlewaredeps + the opt-instartRetryLoopIfEnableddeps). - Test counts:
just test cmc309/0 unchanged.just test api-server+3 from[CMCHS](baseline preserved at 1018 → 1021 combined with the boiler skipOverrideConfig fix above).
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.
- NEW component
components/cmc/with module surface:src/constants.ts— namespace + event-type constants; reserved-parent tree; classification predicates (isCmcStreamId,isAppNestedPluginStream,getAppCode); stream-id builders.src/slug.ts—counterpartySlug+parseCounterpartySlug(load-bearing--separator).src/validators.ts— hand-rolled per-type content schemas for the 9 cmc/* event types.src/hooks.ts— content-validation hook + reserved-root rejection hook.src/inboxWriteHook.ts—:_cmc:inboxwrite-hook (role check + content.from stamping).src/capability.ts— single-use capability access mint + GC (creates real per-capability offer/responses streams + a shared access; cleaned up on consumption or TTL).src/capabilityMintHook.ts— fires onconsent/request-cmcevents.create.src/outbound.ts— federated HTTPS client (postToPeer + DeliverResult discriminated union; classify network/timeout/4xx/5xx).src/acceptOrchestration.ts— read offer via capability, build data-grant payload, deliver accept-via-capability.src/anchorStreams.ts— sharedprovisionAnchorStreamsused by both sides at acceptance time.src/handleAccept.ts— accepter-side: creates data-grant + provisions anchors + delivers accept via capability + rollback on 4xx.src/handleRefuse.ts— accepter-side refuse delivery.src/handleIncomingAccept.ts— requester-side: mints back-channel access + provisions anchors when a consent/accept-cmc lands on the requester's:_cmc:inbox.src/handleChat.ts,src/handleSystem.ts,src/handleRevoke.ts— per-type orchestration handlers.src/dispatch.ts—dispatch(...)type-router +createDispatchMiddleware(fire-and-forget). Auto-enqueues retryable failures.src/accessesUpdateHook.ts—createAccessesUpdatePostHook(deps)+runWithSuppression(fn)(AsyncLocalStorage-backed double-fire suppression).src/rateLimit.ts— per-worker sliding-window rate limiter (100 events / 60s window per (source, recipient) pair).src/retryQueue.ts— events-in-:_cmc:_internal:retriesretry mechanism with exponential backoff + reason-based retryability classifier.src/retryScheduler.ts—RetrySchedulerclass wrapping the loop on an interval; operator-supplieduserIdsProvider.
- NEW mall routing —
:_cmc:*stream-ids route toLOCAL_STORE_IDpassthrough (mirrors:_system:*precedent). Patched incomponents/mall/src/helpers/storeDataUtils.ts. - NEW api-server wiring —
components/api-server/src/methods/events.tsregisters the three CMC write-hooks beforecreateEvent+ the dispatch middleware afternotify.components/api-server/src/methods/accesses.tsregisterscmcAccessesUpdatePostHookafteremitUpdateNotifications. Both are fire-and-forget. - NEW slugify-skip —
events.tsandstreams.tsskip slug normalization for ids starting with:_cmc:(otherwise colons would be munged and the path-style namespace destroyed). - TEST COVERAGE — 285 cmc unit tests across
components/cmc/test/; api-server matrix at 1018 passing / 14 pending / 0 failing (PG default). - Known follow-up: reserved-parent auto-provisioning at user-creation time is currently no-op due to a state-dependent regression in
account-seq.test.js [AC04 6041](cumulative AC0X cycles + provisioning invalidate the test's stored personal-access token). Workaround: lazy creation at first:_cmc:*write + at acceptance time (handleAccept + handleIncomingAccept useprovisionAnchorStreams). Documented as TODO inbusiness/src/users/repository.ts.
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.
- NEW
storages/interfaces/migrations/applyOrAnnounce.ts— small policy helper. WithautoRun=trueit applies pending migrations (current behaviour byte-for-byte). WithautoRun=falseand pending migrations, it logs a top-line WARNING naming the count, the affected engine count, and a remediation hint (Run \node bin/migrate.js up` to apply.), followed by per-engine WARNING lines listing the pending filenames and current version. WithautoRun=false` and nothing pending, a single info line confirms the runner was consulted. - CHANGE
bin/master.js— replace the inline migration block with a singleapplyOrAnnouncecall. Adds awarn(msg)closure mirroring the existinglog(msg)so warnings hit both the boiler logger (logger.warn) and stdout (console.warn) with a[master] WARNING:prefix. - NEW
components/api-server/test/migrations-skip-warn-seq.test.js—[MIGSKIP]describe block. Five pure unit tests ([MS01]–[MS05]) against fake runner + fake logger cover the run-applied / no-pending / skipped-no-pending / skipped-one-pending / skipped-many-pending-across-engines cases. Pin the exact info/warn line counts and message templates so the deploy-warning contract can't regress silently.
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 |
The [ACUP] test family validates Plan 66 end-to-end and uncovered several storage-path issues that needed fixing.
- NEW
components/api-server/test/accesses-update.test.js— 17 tests across 7 sub-describes covering: composite-id bare-vs-versioned semantics, 409 stale-resource on update/delete, canUpdateAccess matrix (personal immutable, no self-update, app-can-update-managed-shared), Rule A/B/D (shared scope ⊆ managing app, narrowing parent rejects withoffendingChildren, expiry chain on update + create), soft-deleted → unknownResource, accesses.getOne with current head / obsolete composite / unknown id /?includeHistory=true, pubsub coarse + fine-grained notifications, andcheckApphead-only semantics. - CHANGE
storages/engines/postgresql/src/user/BaseStoragePG.ts:DEFAULT_COLUMN_MAP— addedcreatedBySerial → created_by_serialandmodifiedBySerial → modified_by_serial. Without these, PG lowercased the unmapped camelCase identifier and raisedcolumn "modifiedbyserial" does not existon the firstaccesses.update. - CHANGE
snapshotHeadrewrites on both engines — route the history-row insert through the standardinsertOnepath soapplyDefaults's integrity-recompute fires against the snapshot row's actual fields. The original approach copied the head's integrity hash verbatim, which never matched the snapshot row's (fresh_id+ newheadId) shape and the periodicintegrity-final-checkrejected every snapshot. Side effect:snapshotHeadis now callback-based to fit BaseStorage's existing callback API. - CHANGE dropped the storage-layer
headIdstrip (PGAccessesPG.rowToItemdelete item.headId+ MongostripHeadIdconverter). The integrity hash was includingheadIdat insert and the strip at re-read made every recompute miss. The strip moved tocomposeWireAccess(api-server seam) so the hash is consistent inside storage ANDheadIdstill never appears on the wire. - CHANGE
composeWireAccessnow also stripsheadId, alongside the internal serial fields. - CHANGE
components/test-helpers/src/dependencies.ts:initruns the Plan 32 migration runner so the test DB matches the deployed shape.bin/master.jscalls it in production; the test harness used to skip it, leaving the unique-token index without the newhead_id IS NULLpredicate and causing Plan 66's firstaccesses.updateto hit a duplicate-token violation. - CHANGE
components/api-server/test/migrations-runner-seq.test.js:beforeEachresets each engine'sschema_migrationstracking state before every test. The Phase F migration-runner invocation independencies.initwould otherwise leave the tracker at v1 and trip [MR01]'s "fresh engines report v0" assertion.
- CHANGE
audit/src/Audit.ts:buildDefaultEvent— readscontext.access.serial(set byAccessLogic'sdeepMerge(this, access)from the storage row). When non-null, the event'sstreamIdsarray now carries bothaccess-<base>andaccess-<base>:<serial>followed by the existingaction-<methodId>. When null, behaviour is identical to before (singleaccess-<base>entry). Cost: ~30 bytes per audit row on versioned-access activity. No schema change. - CHANGE
api-server/src/socket-io/Manager.ts:messageFromPubSub— handles both legacy string payloads (event-name only, used byeventsChanged/accessesChanged/streamsChanged) and the new structured object payloads ({ type, …data }). For structured payloads, looks up the socket event name bypayload.typeand forwards the entire payload as the socket.io message argument. Backwards-compatible: existing listeners that subscribe to'accessesChanged'keep receiving arg-less calls. - CHANGE
messageMap[pubsub.ACCESS_UPDATED] = 'accessUpdated'added — without this entry the structured payload would logXXXXXXX Unknown structured payloadand silently drop. - CHANGE
methods/accesses.ts:emitUpdateNotifications— restructures the second emission so the payload is the entire structured object ({ type, accessId, serial }) rather than a 3rd arg topubsub.emit(which only takes(eventName, payload)and would silently drop the data). The first emission stays as a stringUSERNAME_BASED_ACCESSES_CHANGEDfor the legacyaccessesChangedsocket event.
Internal plumbing for the new accesses.getOne and the composite-id wire format (see CHANGELOG-v2.md).
- NEW
composeWireAccess(row, historyOfBase?)inbusiness/src/accesses/refs.ts. Takes a storage row and emits the wire-format access object: composes the compositeid/createdBy/modifiedByfrom the row's bare<col>+ sibling<col>Serialcolumns, and strips the now-redundantserial/createdBySerial/modifiedBySerialfields so the response stays inside the schema'sadditionalProperties: falsewhitelist. WhenhistoryOfBaseis passed (history-row case), the wireiduses that base instead of the storage's fresh history-row id — so<base>:<serial>always means "this version of base." - NEW
composeStoredRef(storedRef, serial)helper insiderefs.ts. Handles the<base> <callerId>tracking-author format: splices the:<serial>into the access-id slice and preserves the space-separated caller tail. (MethodContext.ts already parses callerId from the first space, so this round-trips cleanly.) - NEW storage
Accesses.findHistory(userOrUserId, baseId)on both engines. Returns history rows whereheadId === baseId, sorted bymodifiedASC (oldest first). PG usesWHERE user_id = $1 AND head_id = $2; Mongo uses{ userId, headId: baseId }.sort({ modified: 1 }). Each engine's existingrowToItem/applyItemFromDBpipeline stripsheadIdbefore returning — the caller (composeWireAccess) gets the base from the query parameter. - NEW
accesses.getOnemethod handlerfindOneAccessinmethods/accesses.ts. Parses composite id, looks up the head by base, applies visibility check (app callers see only self + their managed shareds, by base), then either (a) returns the head when bare/serial matches, (b) returns the historical snapshot +currenthint when serial < head's, or (c)unknownResourcefor never-existed serials.?includeHistory=trueappends the full chronological history. - NEW
methodsSchema.getOneentry —params: { id, includeHistory? },result: { access, current?, history?: [...] }. - NEW route
GET /accesses/:id→accesses.getOne, withtryCoerceStringValuesonincludeHistoryso the boolean comes through correctly from query string. AccessLogic.can('accesses.getOne')— added to the switch, returns!isShared()(same gate asaccesses.get).accesses.getOneregistered in auditALL_METHODS(audit/src/ApiMethods.ts) — without it,API.registerthrows at boot and every api-server test that initializes the API crashes in thebefore allhook. (First Phase D test run had 298 failures from this single oversight.)composeWireAccessapplied to:accesses.get(list),accesses.getdeletions,accesses.createresult,accesses.updateresult (replacing the ad-hoc id rewrite from Phase C),accesses.checkApp(matching + mismatching). Audit-driven and storage-driven internals continue to operate on the raw(base, serial)columns — composition is purely a presentation-layer concern.- Phase C's
snapshotAndApplyUpdaterewritten to delegate id composition tocomposeWireAccessinstead of building the composite string in-place. Behaviour identical; less duplication.
Wire-up for the revived accesses.update (see CHANGELOG-v2.md). Internal-only plumbing notes:
- NEW
Accesses.snapshotHead(userOrUserId, baseId)on both engines. Reads the current live head row by base, clones every column/field, replacesid/_idwith a freshly-minted cuid and setshead_id/headIdto the original base. The unique-token partial filters (WHERE deleted IS NULL AND head_id IS NULLin PG;partialFilterExpression: { deleted: $type null, headId: $type null }in Mongo) exclude the new history row, so the head and snapshot can share a token without violating uniqueness. - Handler chain (
api-server/src/methods/accesses.ts):basicAccessAuthorizationCheck → schema-validate → loadAccessForUpdate → enforceUpdateChainRules → snapshotAndApplyUpdate → emitUpdateNotifications.loadAccessForUpdateparses the composite id, conflict-checksserial, treats soft-deleted asunknownResource, and gates onAccessLogic.canUpdateAccess.enforceUpdateChainRulesresolvesexpireAfter → expiresand applies Rules A/D for shared targets + Rules B/C/D for app targets (iterating the user's live shareds, matching bybase). AccessLogic.can('accesses.update')— added to the switch, returns!isShared(). Without this,basicAccessAuthorizationCheckthrew on the unknown methodId and the handler crashed withunexpected-error.- Tracking fields —
update.modifiedByis set by the standardMethodContext.updateTrackingProperties(caller's bare base).update.modifiedBySerialis set explicitly fromcontext.access.serial(null today since AccessLogic doesn't yet carry serial — Phase D will plumb it).update.serialis set to(prev || 0) + 1. - NEW error
stale-resource(ErrorIds.StaleResource, 409) added toerrors/{ErrorIds.ts,factory.ts}. Used byaccesses.updateandaccesses.deleteon composite-id mismatch. accesses.deletecomposite-id check —checkAccessForDeletionparses the composite, conflict-checks the serial against the head, then rewritesparams.idto the bare base for downstream stages (findRelatedAccesses,deleteAccesses) which all expect bare ids.- Schema —
accessesMethods.tsgains__ex_update = { params: { id, update: access(Action.UPDATE) }, result: { access: access(Action.READ) } }. The UPDATE-actionaccessschema already exists (Action.UPDATE branch) with the mutable-fields whitelist. - Test updates — 4 pre-Plan-66 "endpoint is gone" tests refreshed to assert the new behavior:
[11UZ]app-cannot-update-sibling-app → 403,[U04A]unknown-id-on-PUT → 404,[1WXJ]create-only-shared-cannot-update → 403,[OS36]deletedin update body →invalid-parameters-format. Test IDs preserved; assertions and descriptions updated in place. - Race safety — no transaction (accesses storage has no transactional API). The composite-id check at entry catches the common stale-caller case; the read-then-snapshot-then-update window is narrow enough that genuine concurrent updates are rare. Plan 66 Q12.5=a explicitly accepts no-locking; honest audit captures the version that handled each request.
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).
- NEW
components/business/src/accesses/refs.ts—parseAccessRef(ref)andserializeAccessRef({ base, serial }). Wire format is bare cuid when no serial,<base>:<serial>otherwise; separator is:(URL-safe, never appears inside a cuid/cuid2 id). Throws on malformed input. - NEW pubsub constant
ACCESS_UPDATED = 'access-updated'(components/messages/src/constants.ts+index.ts). Payload shape (set when Phase C fires it):{ accessId: '<base>:<serial>', serial: number }. Companion to the existingUSERNAME_BASED_ACCESSES_CHANGEDevent so fine-grained subscribers can act on a specific update without refetching. - NEW
AccessLogic.canUpdateAccess(target)— encodes the §3 caller-vs-target matrix from the plan: no self-update (parses both ids so a future composite ref still matches the caller's bare base), personal-immutable, personal-can-update-non-personal, app-can-update-only-shared-it-manages (chain match bybaseviaparseAccessRef(target.createdBy).base === this.id), shared-cannot-update-anything.canUpdateAccessis the gate; chain-rule application (A/B/C/D on changes) is enforced by the call path in Phase C.
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.
- NEW PG columns on
accessestable:serial INTEGER,head_id TEXT,created_by_serial INTEGER,modified_by_serial INTEGER— all nullable. Added via the new migrationstorages/engines/postgresql/migrations/20260512_132200_access_versioning.js(Plan 32 framework). Same migration tightens the two unique indexes (idx_access_token,idx_access_name_type_deviceName) to predicateAND head_id IS NULLso future history rows don't collide on token/(name+type+deviceName) uniqueness, and adds a new partial indexidx_access_head_id ON accesses(user_id, head_id) WHERE head_id IS NOT NULLfor history-lookup queries.SCHEMA_SQLinDatabasePG.tskeeps the pre-Plan-66 index predicates becauseCREATE TABLE IF NOT EXISTSis a no-op on existing installs — fresh installs converge once the migration runs at boot. - PG migrations directory CJS scope — added
storages/engines/postgresql/migrations/package.jsonwith{ "type": "commonjs" }so migration files keep the README'smodule.exports = { async up () {} }shape despite the engine package being"type": "module". Without this scope-override the runner'srequire()failed withmodule is not defined in ES module scope. - PG
AccessesPG—hasHeadIdCol = true.rowToItemstripsheadIdfrom the returned item (internal storage marker, never on the wire).BaseStoragePG.findOnenow addsquery.headId = nullwhenhasHeadIdColis true so the auth-by-token path andfindOne({ id })can never return a history row. - Mongo
Accesses— both unique indexes'partialFilterExpressionextended withheadId: { $type: 'null' }; new non-unique{ headId: 1 }partial index for history queries. NewsetHeadIdNullIfMissingconverter forces live-row inserts to setheadId: nullexplicitly (the$type: 'null'partial filter only matches BSON null, not missing fields). NewstripHeadIdconverter onitemFromDBkeepsheadIdoff the wire. Newbootstrap()method drops the pre-Plan-66 unique indexes (token_1,name_1_type_1_deviceName_1) and backfillsheadId: nullon legacy rows so they re-enter the new unique-token set; idempotent over fresh DBs (NamespaceNotFound/IndexNotFoundare silent successes). - Mongo
BaseStorage.findOne— addsquery.headId = null. Equality-null in Mongo matches both missing-and-null fields, so this is a no-op for tables without aheadIdfield. initStorageLayeris now async — Mongo's flavor awaitsstorageLayer.accesses.bootstrap()after construction; PG's stays sync (returnsundefined).StorageLayer.initaddsawaitaccordingly.- Behavior unchanged today. No history rows are written yet (Phase C lands
accesses.updateand the snapshot logic); no composite-id format on the wire yet (Phase D);accesses.updatestill returnsgoneResource. The schema and storage just sit ready. - Tests: full PG matrix at close: 1873 passing, 0 failing, 7 pending (Plan 67 close baseline ~1872, +1 within noise). Mongo matrix not re-run in this phase — schema-only change paired with engine-specific
bootstraptest coverage; full Mongo re-run gated on Phase C when behavior actually diverges. - Integrity hash — no format-version bump. Plan 66 §6 called for adding
serialandheadIdto the canonical-fields list and rolling the integrity-format version. In practice no bump was needed:@pryv/stable-object-representation/access.js:stringifyAccess0already serialises every field on the access object (deep-clone + strip known volatile fields likeintegrity/lastUsed/calls/apiEndpoint). New nullable fieldsserial/head_idsimply roll into the hash automatically — never-updated accesses have absent values (no hash change), versioned accesses includeserial: <N>, and history rows include bothserial(frozen) andheadId(base). TheACCESS:0:prefix stays as-is. If we ever want a tamper-detection guarantee that REJECTS a serial removal (vs just detecting an integrity mismatch), bumping toACCESS:1:becomes warranted then; current behavior is detection-via-mismatch.
- CHANGE
config/default-config.yml:storages.series.engine: influxdb → postgresql. Operators who want influxdb-backed series storage must now setstorages.series.engine: influxdbexplicitly in override-config.yml (and run a reachable influxd onhttp.ip:storages.engines.influxdb.port). - CHANGE
config/test-config.yml: pinsseries.engine: influxdbexplicitly. Test matrix behavior preserved — series tests still run against influxdb. Pin can be removed once the matrix is re-validated against postgresql series. - Why: raw deploys (the now-canonical install shape per Plan 67's ingress dispatcher) rarely ship influxd. Setting
cluster.hfsWorkers > 0with the inherited influxdb default produced a silent footgun: HFS workers came up, requests reached the worker, but every write hit a missing backend. PostgreSQL seriesStorage has been first-class since Plan 19. Flipping the default removes the trap for fresh installs. - Migration: existing deploys with
engine: influxdbset explicitly are unaffected. Deploys without an override that have an actual influxd running need to either keep it running (and add the explicit override) or migrate any existing series data — note: PG and influxdb series stores are NOT cross-compatible, this is a forward-going default.
- NEW
components/api-server/src/hfsIngress.ts—buildHfsIngress({ hfsHost, hfsPort, logger })factory returns a(req, res, fallback) => voiddispatcher. - CHANGE
components/api-server/src/server.tsnow constructshttps.createServer(opts, requestHandler)whererequestHandleris a wrapper that calls the HFS dispatcher first, then falls through toapp.expressApp. The fall-through preserves the prior behavior for all non-HFS paths. - Routes:
^/<user>/events/<id>/seriesand^/<user>/series/batchare forwarded viahttp.requesttohttp.ip:http.hfsPort(default127.0.0.1:4000), streaming both request and response bodies. JSON-shaped 502 on upstream unreachable. - Tests: 6
[HFSI]cases incomponents/api-server/test/hfs-ingress.test.jscover regex matching, dispatch, fallback, and 502. - Note on extraction. The dispatcher lives inside api-server today because it was the minimum-viable shape for a single dispatcher. When more in-process dispatch lands (previews, mail-templates UI, etc.) or someone needs a clean nginx-swap story, the right shape is to extract the public listener + TLS + dispatcher into a dedicated
components/ingress/component — filed at_plans/XXX-Backlog/EXTRACT-INGRESS-COMPONENT.md.
- NEW
@pryv/boilerexportsgetConfigSync()— sync access to the fully-loaded config. Throws ifinit()hasn't been called or if async config-loading is still pending. Use anywhere a sync read is needed at request/test time post-init. - CHANGE
getConfigUnsafe(warnOnly)retained as the documented escape hatch for genuine pre-init reads (returns partial config; withwarnOnly: truewarns instead of throws). Two production sites remain on it after the cleanup:components/business/src/integrity/integrity.ts:9(module-top capture; preserves cross-process symmetry between mocha-parent and api-server forked-child that the fixture-time hash compute relies on) andcomponents/storage/src/index.ts:_ensureMongoDatabase(test-helpers/dependencies lazy-loads MongoDB at module-load). Plus 3 test-helpers fixture files (data.ts,dynData.ts,dependencies.ts) which run pre-init by lifecycle. - CHANGE Deferred 4 module-top
getConfigUnsafe(true)reads into function bodies:components/cache/src/index.ts—loadConfiguration()is nowasyncand awaitsgetConfig(). Module-bottom auto-call becomes fire-and-forget with stderr log on misconfig; cache stays inactive on failure instead of killing the worker. Cache ops short-circuit on!isActiveso the brief async window matches legacy partial-config behavior.components/previews-server/src/attachmentManagement.ts— module-toppreviewsDirPathcapture → lazy-memoizedgetPreviewsDirPath(). All 3 callers run at request/test time.components/previews-server/src/runCacheCleanup.ts— module-top sync config reads → async IIFE that awaitsgetConfig()before constructingCache. Strictly safer than the legacy race against partial config.components/api-server/src/routes/register.ts— drops(true)warnOnly; caller is express bootstrap post-await getConfig().
- CHANGE
components/test-helpers/src/data/events.ts— module-topArray.mapcallingintegrity.events.set(event, false)synchronously at module-load → idempotentensureIntegrity()helper. Called fromresetEvents()and fromhelpers-base.ts beforeAll. Decouples fixture integrity from module-load timing. - CHANGE Renamed
getConfigUnsafe()(no warnOnly) →getConfigSync()at 7 production sites:api-server/{API,middleware/errors,routes/auth/login,routes/register}.ts,business/src/accesses/AccessLogic.ts,previews-server/src/attachmentManagement.ts,utils/src/api-endpoint.ts. - NOTE Test matrices at close: PG
1857/1(only pre-existing[ASTE]flake), Mongo1849/2(pre-existing[ASTE]+[3TMH]timing-sensitive webhooks test that passes on standalone re-run).
- CHANGE All 22 production sources flipped to ESM. Every
package.jsonfor components + storages + engines now has"type": "module"; every.tssource file usesimport { createRequire } from 'node:module'; const require = createRequire(import.meta.url);to keep its existing CJS-style internalrequire()calls working under the ESM module loader. Default exports useexport default X; named exports useexport { X }orexport const X = .... Sub-package.json{"type":"commonjs"}overrides dropbin/server(each component's CJS fork target), threetest/helpers/directories that use themodule.exports = { ...require('test-helpers') }spread-mutation pattern, and a few CJS-only fixture/test-engine subtrees. - CHANGE All 13 test directories flipped to ESM same way.
.mocharc.cjsfiles stay CJS as Node requires (mocha config is loaded via CJS); the_ts-registershim is loaded viarequire:from the base mocha config. - CHANGE
components/middleware/src/project_version.ts— added an ESM-safe fallback forreadStaticVersion()that walks upward from the file's own__dirnamelooking for.api-version. Fixes a regression that surfaced once forked children (HFS test-helpers, accessStateWorker, etc.) became ESM:process.mainModuleandrequire.mainboth went undefined, the function returned null, and the api-version header fell through to the git-describe stamp — breaking every consumer that asserted/^\d+\.\d+\.\d+/on it. - CHANGE
components/test-helpers/src/helpers-base.ts:121-128— the dynamicfor (const method of methods) { const loaded = require('api-server/src/methods/' + method); if (typeof loaded === 'function') { ... } }loop was silently skipping every method registration once api-server became ESM (Node 24require(esm)returns the namespace object, not the function). Fixed: unwraploaded.defaultfirst. This single fix turned a "119 failing" intermediate api-server run into 978/0. - CHANGE Storages barrel (
storages/index.ts) exposesdatabase,databasePG,connection,storageLayer,userAccountStorage,usersLocalIndex,platformDB,auditStorage,seriesConnection,dataStoreModuleas live-boundletexports updated by_refreshExports()afterinit()andreset(). New test-only_setPlatformDBForTest(db)setter replaces the now-incompatibleObject.defineProperty(storages, 'platformDB', ...)pattern (ESM module namespace properties are non-configurable). - CHANGE Business
system-streams/index.ts— top-levelletbindings replace the previousmodule.exports = X; X.foo = Ymutation pattern; live-bound exports propagate state changes (e.g.accountChildrenreassignment ininitializeState()) to all consumers without breaking the namespace. - CHANGE Storages PG engine:
storages/engines/postgresql/test/{global,audit-conformance}.test.jshad a pre-existing_internals.getLogger = Xdirect assignment that was always wrong (the export definesgetLoggeras a get-only property; CJS plain-object assignment silently shadowed the getter, ESM frozen namespace throwsCannot add property getLogger). Fixed by destructuring{ _internals }and using the proper_internals.set('getLogger', X)API. - CHANGE Several
module.exports = ClassNamedefault-exports in test fixtures (HttpServerin business + api-server,SyslogWatch,InfluxConnection.test.js'sconformanceTestsfactory) becameexport default ClassName, with consumers patched to.default-unwrap. - CHANGE
bin/_ts-register.jsis retained — itsrequire.extensions['.ts'] = require.extensions['.js']mutation is still load-bearing for the three CJS helper subdirectories and the forked CJSbin/servertargets that do extensionless deep.tsrequires (e.g.require('test-helpers/src/helpers-base'),require('messages/src/cluster_kv')). Phase 5g experiment (commenting out the line) regressed the matrix; full removal moves to backlog. - NOTE Test matrices: PG
1857/3, Mongo1852/1— failures are pre-existing flakes ([ASTE]audit matrix-state,[3TMH]webhook timeout, occasional webhook DELETE app-token race with stale DB residue). No ESM regressions; both engines are correctness-equivalent to the pre-5f baselines (1858/2 and 1853/0). - NOTE Top-level
awaitis now syntactically available in any.tsfile. The storages barrel still usesasync init()(consumer-compatible with Node 24require(esm)interop); switching it to TLA is the unblocker forXX-finalize-storages-plugin-laterand is left as a follow-up.
- CHANGE
.js → .tsfor the 10 production source files instorages/that Phases 1–3 left untouched (they were outside thestorages/interfaces/*andstorages/engines/*scope):storages/{index,internals,manifest-schema,pluginLoader}.ts,storages/shared/{DeletionModesFields,treeUtils,localStoreEventQueries}.ts,storages/datastores/account/{index,AccountUserEvents,AccountUserStreams}.ts. Added module markers + two minor TS-narrowing fixes: optional positional args onfieldToEvent (fieldName, value, streamConfig, time?, createdBy?)andconst engines: Record<string, any> = {}in pluginLoader. - NOTE
components/+storages/production source is now 100% TypeScript. Only.mocharc.jstest-config files instorages/engines/*remain.js(intentional — mocha config). Runtime is still source-loaded viabin/_ts-register.jsshim. Sets up Phase 5c.2 (rewrite sourcerequire()→import).
- CHANGE
tsconfig.json—module: commonjs→nodenext,moduleResolution: node→nodenext. Undernodenext, tsc decides per-file CJS-vs-ESM emit based on the closest enclosingpackage.json"type"field. Since no package.json declares"type": "module"yet, all files still emit CJS — no runtime change. Validates the toolchain switch in isolation; per-file emit format flips in Phase 5c onwards as packages opt into"type": "module". - NOTE Build verification:
dist/components/api-server/src/server.jsstill hasObject.defineProperty(exports, "__esModule")+require()(CJS shape). PG matrix 1860/0 unchanged.
- NEW
storages/test/barrel-init-order.test.js— 5[BIO]cases pinning the current CJS barrel contract: pre-init getter access returnsundefined(does not throw),pluginLoaderis exposed regardless,init()is idempotent,reset()returns to pre-init state. ESM with top-levelawaitin the barrel would fundamentally change pre-init semantics — without this pin a regression direction (silent vs throw) lands undetected onfeat/ts-esm. - NEW
components/messages/test/worker-fork-ts-loading.test.js+fixtures/wftl-worker.js— 2[WFTL]cases pinning the Phase 1 NODE_OPTIONS-shim mechanism: parent process hasNODE_OPTIONS=--require=…/_ts-register, forked children inherit it and canrequire()a.tssource file without explicit shim load. Phase 5e drops the shim — must remain functionally equivalent under native ESM.tsloading or everychild_process.fork()target (cluster_kv master, accessStateWorker, hfs background) breaks. - NEW
storages/test/engine-runtime-contract.test.js— 33[ERC]cases, one per (engine × required export) combination across all 6 engines (filesystem, mongodb, postgresql, sqlite, rqlite, influxdb). Asserts each engine's loaded module exports the methods thatpluginLoader.REQUIRED_EXPORTSdemands for each storageType in its manifest. ESMexport { foo }vs CJSmodule.exports = { foo }typically shifts exports under adefaultnamespace — without this pin the silent shape change crashes consumers at deferred runtime, not at compile time. - NOTE All 40 tests run against the current CJS state. Their value is in pinning behavior so Phase 5b–5g regressions are loud at unit-tier instead of surfacing in production.
- CHANGE
.js → .tsfor every source file undercomponents/api-server/src/across 8 sub-folders: top-level (5:API,Result,application,expressApp,index,server),methods/(12),methods/auth/(3),methods/helpers/(6),methods/streams/(5),middleware/(3),routes/(15 incl.routes/auth/+routes/reg/),schema/(22),socket-io/(2). Total: 82 source renames. Plus the established Phase 4 fix-up patterns: module markers,: anycasts on mutable response/options/query objects (e.g.routes/reg/access.tsresponse,methods/accesses.tsquery,routes/events.tsparams,schema/{stream,user,access}.tsmutable schema base), class field declarations (Server.isAuditActive), Promise typed on void-resolving constructors (server.tshttps-options ack), optional positional args (?:) onnextElement/runNextMethod/isAccessExpired/nextPermission,Object.entries/Object.valuescasts for nested config iteration inmethods/auth/register.tsregions/zones/hostings, oneas anycast onsystem.tsuser-list entry that gets a.coremutated. - CHANGE
components/api-server/package.jsonmainbumpedsrc/index.js → src/index.ts. - FIX
application.ts:259— pre-existing typothis.customAuthStep(undefined) at logger.debug 2nd arg, masked the latent crash where passing a function to boiler'sinspectAndHidethrowsJSON.parse('undefined')(JSON.stringify(fn) === undefined). Removing the 2nd arg entirely (the boolean is already in the message string) preserves the log information without crashing the worker on any test that exercisesgetCustomAuthFunction(). - PLAN57-FIXUP
methods/accesses.ts:362-368— pre-existing operator-precedence bug since685034dd(2023-10-13):if (!accessToDelete.type === 'personal')always evaluates to false (precedence:!before===), so the early-return branch is dead andfindRelatedAccessesruns for every access type. Preserved current observed behavior with a defensive cast and a marker comment; tracked at_plans/XXX-Backlog/ACCESSES-FIND-RELATED-PRECEDENCE-BUG.md. - NOTE Runtime is still source-loaded via the
bin/_ts-register.jsshim from Phase 1; deployability invariant preserved. Allcomponents/+storages/source code is now 100% TypeScript — Phase 5 (ESM flip + drop the shim) is unblocked.
- CHANGE
.js → .tsfor every source file undercomponents/business/src/across 14 sub-folders: top-level (2),accesses/(2),acme/(12),auth/(2),backup/(3),bootstrap/(10),integrity/(4),mfa/(7),observability/(6),series/(8),system-streams/(1),types/(5),users/(5),webhooks/(2). Total: 67 source renames. Module markers added (import type {} from 'node:fs'), class field declarations (foo: any;) added whereverthis.foo = ...was set in constructor, static class members declared (static PERMISSION_LEVEL_*onAccessLogic,static replaceAll/replaceRecursivelyonmfa/Service), destructure-default opts annotated{ x, y }: any = {}, mutation-after-new Error(...)patterns annotatedconst err: any = new Error(...),Object.entries(map)widened withas Array<[string, any]>casts at iteration sites,Object.values(map)casts toas any[],Promise<void>typed on void-resolving constructors, optional positional args?:on_restoreSingleUser/_verifyEventIntegrity/setUserPassword/#issue. One existing-bug callout:IntegrityStreamconstructor castas anybecause the upstream type signature lacks the algorithm arg. - CHANGE
components/business/package.jsonmainbumpedsrc/index.js → src/index.tsso the workspace symlink resolution +bin/_ts-register.jsshim resolve.tsfirst. - NOTE Runtime is still source-loaded via the
bin/_ts-register.jsshim from Phase 1; deployability invariant preserved.
- CHANGE
.js → .tsfor every source file undercomponents/middleware/src/(15),components/audit/src/(16),components/hfs-server/src/(16), andcomponents/test-helpers/src/(26 — 21 top-level + 5 indata/). Total: 74 source renames pluscomponents/api-server/.mocharc.jsrequire: 'test-helpers/src/helpers-c.js' → '…/helpers-c.ts'. Module markers added (import type {} from 'node:fs') on every renamed file (script-vs-module disambiguation), default-arg objects annotated: anyon consumers that mutate them,Promise<void>typed on void-resolving constructors, optional callback params (?:) on tail-call recursive helpers,super_accesses onutil.inheritsclasses cast(SubClass as any).super_, variadicargumentsrewritten as...rest: any[], and oneerrorlogger→errorLoggertypo fix onServer(lowercase declaration was inconsistent with assignment + use sites). - CHANGE
package.jsonmainbumpedsrc/index.js → src/index.ts(orsrc/server.js → src/server.tsfor hfs-server) on each touched component so the workspace symlink resolution +bin/_ts-register.jsshim resolve.tsfirst. - NOTE Runtime is still source-loaded via the
bin/_ts-register.jsshim from Phase 1; deployability invariant preserved.
- CHANGE
package.json—typescript ^5.9.3and@types/node ^24.0.0added todevDependencies. TypeScript was previously pulled transitively vianeostandard; now it's pinned directly so the build pipeline doesn't break when neostandard updates. - CHANGE
tsconfig.json— reshape from a JSDoc-only checker config to an emit-capable config:target: es2022,module: commonjs,outDir: ./dist,rootDir: .,esModuleInterop: true,skipLibCheck: true.checkJsflipped tofalse— the previouscheckJs: truebaseline accumulated 7,200+ silent errors that nobody enforced; quality going forward is gated through new.tsconversions instead.allowJs: trueremains so the codebase keeps building during incremental conversion. Includes broadened to covercomponents/,storages/, andbin/(wascomponents/+test/only). - NEW
justfilerecipes —just typecheck(tsc --noEmit) andjust build(tsc emit to ./dist). - CHANGE
.gitignore—/distignored. - NOTE Runtime is unchanged.
bin/master.jsstill loads from source undercomponents/+storages/;dist/is informational until later phases convert sources to.tsand flip the runtime entry. The deployability invariant — every commit on this branch keepsbin/master.jsrunnable from source — is preserved.
- FIX
storages/engines/postgresql/src/user/BaseStoragePG.ts—_buildUpdateClauses$incloop emitted oneSET col = jsonb_set(...)clause per dotted-path entry. When a single update contained ≥2 entries sharing the same top-level column (e.g.$inc: { 'calls.events:get': 1, 'calls.accesses:get': 1 }, produced by every batched API call whenaccessTracking.isActive: true), Postgres rejected the UPDATE withmultiple assignments to same column "calls". Per-method counters were silently dropped (the storage error was caught + logged byupdateAccessUsageStats.jsafter the API response had already returned 200). Fix groups dotted-path$incentries by top-key and emits ONE nestedjsonb_set(jsonb_set(..., k1, v1), k2, v2)clause per column. Each nested call reads the original column value (col->>$key), preserving Mongo$incdisjoint-paths semantics. - TEST new
[BTRK]regression incomponents/api-server/test/root-seq.test.js: issues a real batchedPOST /<username>withevents.get+accesses.getand asserts both per-method counters move (and by the same delta). Pre-fix the assertion fails because the SQL crashes and counters stay at baseline.
- FIX
components/audit/src/syslog/Syslog.js— thewinston-syslogtransport's underlyingunix-dgramsocket emits'error'on the first send when the configured socket path doesn't exist (typical containerized deploy with no/dev/log).winston-transportextendsstream.Writable, andWritable.emit('error', err)with no listener throws synchronously → worker exits code 7 → cluster master recycles → user-visible: registration row landed inusers_indexbut the auth poll oncore-<id>.<domain>/reg/access/<key>times out with no token issued. Nowtransport.on('error', err => logger.warn('audit syslog dropped', err))so audit emits become best-effort observability instead of a load-bearing path. - CHANGE
config/default-config.yml—audit.syslog.active: false(operator-facing, inCHANGELOG-v2.md). Defense in depth: even if the listener regresses, the gate ataudit/src/syslog/index.js:18still short-circuits the whole code path.
- CHANGE
components/business/src/bootstrap/Bundle.js:BUNDLE_VERSIONbumped1→2. v2 adds an optionalplatformSecrets.letsEncrypt.atRestKeyfield carrying the cluster-wide AES-GCM key used byAtRestEncryption. - CHANGE
Bundle.validate()accepts any version in1..BUNDLE_VERSION(was strict equality on the current version). Producers always emit the latest version; consumers reject only forward-compat unknown versions orversion <= 0. Restores graceful upgrade across mixed-version clusters during the rolling-out window. - CHANGE
Bundle.assemble()only emitsplatformSecrets.letsEncryptwhen the input supplies anatRestKey— keeps v2 bundles minimal when the issuing core has no LE secret to ship. Bundle version stays2regardless. - CHANGE
applyBundle.writeOverrideConfig(): when the bundle shipsplatformSecrets.letsEncrypt.atRestKey, write it underletsEncrypt.atRestKeyin the joiner'soverride-config.yml. - CHANGE
cliOps.newCore()acceptssecrets.letsEncryptAtRestKey; forwards toBundle.assemble.bin/bootstrap.jsreadsconfig.get('letsEncrypt:atRestKey')and threads it through (only when it's a real value —REPLACE MEplaceholder is filtered out byisUsableSecret). - TESTS
[BUNDLE]+6 cases (omits/carriesletsEncrypt, accepts v1 shape, accepts v2 shape, rejects version 0, rejects malformedletsEncrypt);[APPLYBUNDLE]+1 case (writesoverride.letsEncrypt.atRestKey);[BOOTSTRAPE2E]+1 case (issuer → consumer round-trip ofatRestKey).just test business: 363/0 (was 354).
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).
- FIX
components/api-server/src/routes/reg/accessState.js— replaces the in-memorynew Map()with PlatformDB-backed storage (rqlitekeyValuerows underaccess-state/<key>). API turned async; the route inroutes/reg/access.jsis now async with try/catch wrappers. POST splits intobuildState()→ URL decoration (pollUrl/authUrl) →persist()so the URLs computed from per-core routing land in the stored state without an extra round-trip. Cluster-wide AND restart-survivable for free; the lazy expire ongetmatches the existingtls-cert/*posture. - NEW
storages/interfaces/platformStorage/PlatformDB.js— four new methods on the interface:setAccessState(key, value, expiresAt),getAccessState(key),deleteAccessState(key),sweepExpiredAccessStates(now?). rqlite engine implements them;[ACCESSSTATE]8 conformance cases. - NEW
components/messages/src/cluster_kv.js— master-held key/value store + worker IPC primitive for the ephemeral cross-worker state class (single-core scope only; cross-core state goes to PlatformDB). Wire formatkv:get/set/delete/clearwith namespaced replies. Lazy expire onget+ 60 s sweeper. In-process fallback whenprocess.sendisn't available (single-process tests, CLI tools). Wired inbin/master.jsaftertcpPubsub.init(). 14 unit cases under[CLUSTERKV]. - FIX
components/business/src/mfa/SessionStore.js+components/business/src/mfa/index.js— MFA session store backed oncluster_kvinstead of a per-instanceMap. API turned async (create/has/get/clear/clearAll). Same bug family as the accessState §12: login lands on worker A, verify hits worker B → "MFA session not found". The header comment that said "single-core only" reworded — undercluster.fork()"process-wide" is per-worker, not per-core.[MT5A]cross-worker case added. - NEW
components/test-helpers/src/clusterFixture.js+components/api-server/test/clusterWorkers/accessStateWorker.js+components/api-server/test/access-state-cluster-seq.test.js— multi-worker test fixture (forks N children viachild_process.fork, JSON-RPC over IPC, ready handshake on boot).[XS12A/B/C]regression cases would fail against the pre-Plan-55 in-memory Map.
Two follow-ups missed when the deps bump landed; both crashed the production Docker image at boot before any config was read.
- FIX
components/business/src/mfa/Profile.js+components/business/src/mfa/SessionStore.js— swapconst { v4: uuidv4 } = require('uuid')→const { randomUUID: uuidv4 } = require('node:crypto'). Same alias, no call-site churn. Theuuidpackage was dropped frompackage.jsonin the earlier deps bump but these two MFA files still required it; first MFA- touching require chain (api-server/methods/auth/login → mfa) crashedMODULE_NOT_FOUND. RFC-4122 v4 byte-equivalent. - FIX
components/api-server/src/server.js— moverequire('backloop.dev').httpsOptionsAsyncfrom top-level into theif (config.get('http:ssl:backloop.dev'))block.npm install --omit=devskipsbackloop.devbecause workspace promotion marks it"dev": truein the lockfile, so the production image has no copy on disk; lazy-require keeps the dev-loop path working while letting prod boot.
A bundle of five fixes surfaced by a fresh single-core Dokku deploy with
letsEncrypt.enabled: true + embedded DNS + ACME DNS-01 wildcard.
- NEW
components/business/src/acme/selfSignedPlaceholder.js— whenletsEncrypt.enabledis on and the configuredhttp.ssl.keyFiledoesn't exist yet, master writes a 1-day self-signed RSA-2048 cert at the configured paths before forking workers. Workers'https.createServerENOENT-races would otherwise restart-loop the cluster until ACME issued the first cert. Real cert hot-swaps viasetSecureContextwhen ACME completes (existingacme:rotateIPC +reloadTls()path). CN + SAN derived fromderiveHostnames()— same hostname the eventual ACME cert carries. Pure node-forge (already a transitive of acme-client; no new dep). - CHANGE
storages/engines/rqlite/src/rqliteProcess.js—-disco-mode dns+-bootstrap-expect 1flags now gated on a newcluster.discoveryEnabled: trueopt instead of unconditionally ondnsDomain != null. Single-core deploys withdns.domainset (so the embedded DNS can serve<coreId>.<domain>) no longer have rqlited block for 30 s waiting forlsc.<domain>peers via the embedded DNS that only starts after rqlited is ready. - CHANGE
components/business/src/bootstrap/applyBundle.js— bootstrap bundle now writescluster.discoveryEnabled: trueinto the joiner'soverride-config.ymlwhen the bundle ships acluster.domain(DNS-based multi-core). DNSless multi-core deliberately leaves the flag unset — peers find each other via explicitcore.urlinstead. - CHANGE
components/dns-server/src/DnsServer.js— embedded DNS now resolves<coreId>.<domain>from PlatformDB. Previously such queries fell into the#answerUsernamepath → NXDOMAIN, leaving the hostname advertised inhostings.*.availableCore(and used for inter-core HTTP routing) unreachable unless the operator pre-populateddns.staticEntries. New private branch consultsplatform.getCoreInfo(prefix)betweenstaticEntriesand the username fallback; record-emission tail extracted into#emitCoreInfoRecordsand shared with#answerUsername. Operator overrides viadns.staticEntriesstill win. - CHANGE
components/platform/src/Platform.js—coreIdToUrl()now returns slash-terminated URLs in all branches (cache hit, derived, dnsLess fallback). Centralized via a smallwithTrailingSlash()helper. Three downstream consumers that didcoreUrl + '/something'updated to drop the leading slash and avoid double-slash:business/src/auth/registration.jscross-core forward POST,api-server/src/routes/reg/legacy.js?username=redirect,api-server/src/routes/reg/access.jspollBase(defensive — operator may supplycore:urlwith or without slash). Tworegister.jssites that bypasscoreIdToUrl()(thecoreUrl || ApiEndpoint.build('', null)fallbacks for the unknown-email and single-core/unconfigured branches) wrapped at the call site. - CHANGE
Dockerfile—EXPOSEnow declares80 443 3000 3001 4000 53/udp(was just3000). Dokku'sdokku ports:addonly publishes ports the Dockerfile exposes, so native HTTPS + embedded DNS deployments needed an explicitdocker-options:addworkaround. EXPOSE is informational only — no port is actually bound until the operator publishes it.INSTALL.mdDokku section gained a paragraph aboutdokku ports:add http:80:80 https:443:443. - Local validation: PG
business354/0 (was 346, +8 new[SSPL]for the self-signed placeholder); PGdns-server29/0 (was 26, +3 new[DN35][DN36][DN37]for the PlatformDB-resolved<coreId>.<domain>branch); rqlite-engine[RQARGS]18/0 (was 15, +3 covering single-core / no-domain / discovery-without-domain); api-server PG full 967/0. Fulljust test all(PG) andjust test-mongo allmatrix at plan close.
- DEP
z-schemaremoved frompackage.jsondependencies. Replaced withajv@^8+ajv-draft-04(our schemas use the draft-04id:keyword) +ajv-formats. - NEW
components/utils/src/jsonValidator.js— backed by ajv-draft-04, exposes the slice of the legacy z-schema API that callers depend on (validate(data, schema, callback?)either sync-returning-bool or async-via-callback,validateSchema(schema),getLastError()/getLastErrors()/lastReport). Errors are reshaped to z-schema's wire format:{ code, params: [], message, path }with z-schema-style codes (PATTERN,OBJECT_MISSING_REQUIRED_PROPERTY,INVALID_TYPE,MIN_LENGTH,MAX_LENGTH, etc.) socommonFunctions._addCustomMessageand themessages: { CODE: { code, message } }blocks in schema files keep working unchanged.required-error paths end with/(e.g.#/) to match z-schema's exact shape so paramId fallback in_addCustomMessageresolves correctly. - CHANGE wrapper uses per-schema fresh ajv instances. Pryv schemas build new schema objects per request (e.g.
access.permissions(action)returns a fresh top-level object each call, with nestedid: 'streamPermission'); a shared registry would error with "reference resolves to more than one schema" on the second compile. - CHANGE wrapper pre-processes each schema with
stripUnreferencedIds— drops schema-levelidstrings whose values aren't$ref-targeted from anywhere in the schema. Distinguishes schema-levelid: 'foo'(drop if unused) from data-property-namedid: { type: 'string' }(always keep). Top-level id is preserved so self-references (e.g.systemStreamsSchema → $ref: 'systemStreamsSchema') keep resolving. - CHANGE
components/api-server/src/schema/event.js—id-pattern regex replaced\\:(escaped colon) with plain:. ajv compilespatternstrings with theu(unicode) flag, which rejects unnecessary escapes; the colon is not a regex metacharacter and works in both modes. - CHANGE
components/api-server/src/schema/methodError.js—subErrors.items.$ref: '#error'(id-fragment) replaced with$ref: '#'(root self-reference). ajv-draft-04 doesn't auto-treat top-levelid: 'error'as an in-document anchor; root self-ref is the portable form across both validators. - CHANGE Three production callers migrated from
require('z-schema'):components/api-server/src/schema/validation.js(the API method validator entry point),components/business/src/types.js(TypeRepository for event-type validation; both the lazyTypeValidator.validateWithSchemaand the eagerTypeRepository._validator),components/api-server/test/helpers/validation.js(test-side response-shape assertions). Callers consume the wrapper viaconst { jsonValidator } = require('utils'); const v = jsonValidator(). - Local validation: PG
just test all→ 1742 / 0; Mongojust test-mongo all→ 1735 / 0 (PG-pool exhaustion flake during cross-engine matrix runs cleared afterpg_ctl restart— environmental, not from this slice).
- DEP
mongodbbumped from^4.11.0to^7.2.0. Three majors of driver: v5 removed callback-style APIs entirely (Promise-only), v6 dropped legacyfindOneAndUpdate{ value: doc }wrapper (returns the doc directly), v7 ships BSON v7 + new connection-string parser +@mongodb-js/saslprep. - CHANGE
storages/engines/mongodb/src/Database.js— every collection method (findOne,find().toArray(),insertOne/Many,updateOne/Many,findOneAndUpdate,deleteOne/Many,countDocuments,drop,listIndexes,dropDatabase) wrapped via two new local helpersp2c(promise, callback)/p2cWithDup(promise, callback). The Database class still exposes its callback-shaped public API to consumers (storages/business/api-server) — only the driver-facing internals changed.findOneAndUpdatenow returns the doc directly: dropped ther && r.valueindirection. The connection bootstrap no longer issuesdb('admin').command({ setFeatureCompatibilityVersion: '6.0' })— server FCV is an operator concern, not application init (and v7'sconfirm: truerequirement breaks against older servers). - Connection options (
connectTimeoutMS,socketTimeoutMS,writeConcern: { j, w },appname) all forward-compatible. mongodb-corewas a staledevDependencywith zero consumers — left in the file for now (separate cleanup if anyone touches it).- Local validation: PG
just test all→ 1742 / 0; Mongojust test-mongo all→ 1735 / 0 (one PG-pool exhaustion flake during the cross-engine run sequence, not a regression — cleared after apg_ctl restart -m fast).
- DROP
bluebirdfrom rootpackage.jsondependencies. 26 production files migrated. Pass 1 (8 sites) replacedbluebird.try/bluebird.all/bluebird.map/bluebird.mapSerieswith native equivalents (Promise.all,Promise.all(arr.map(fn)), for-of + await). Pass 2 (74 sites) replacedbluebird.fromCallback((cb) => fn(args, cb))with a tiny in-tree helperfromCallbackexposed fromcomponents/utils/. - NEW
components/utils/src/fromCallback.js— 9-line wrapper that turns a(cb) => ...thunk into a Promise resolving with the callback's value (or rejecting with the callback's err). Identical semantics to bluebird'sfromCallback. Exported asutils.fromCallback. - MOVE
bluebirdfromdependenciestodevDependencies— three test files (components/storage/test/hook.js,components/hfs-server/test/support/child_process.js,storages/engines/mongodb/test/hook.js) still import it for legacy promise wiring; migrating them is a test-infra refactor for later (or folds into TS+ESM).npm ls bluebird --omit=devshows no direct production dep;bluebird@3.7.2survives only viaemail-templates → consolidate(out of our control). - CHANGE
components/test-helpers/src/helpers-base.js— dropped the no-opbluebird: require('bluebird')re-export. Zero consumers grep-confirmed. - Local validation: PG
just test all→ 1742 / 0; Mongojust test-mongo all→ 1735 / 0 (one transient[WHBK] [BLNP]/[1VIT]webhook-retry-state flake on a single matrix run, cleared on re-run — same family as the documented[WH01]flakes; not caused by this slice).
- CHANGE 9 production files migrated from
async.series/forEachSeries/forEachOfSeries/untilto nativeasync/await+ for-of/while loops. Affected:components/api-server/src/API.js(forEachSeries → manualrunNextMethodchain to preserve tracing+error semantics),components/api-server/src/Result.js(forEachOfSeries →nextElementchain),components/api-server/src/methods/accesses.js(forEachSeries + nested series → IIFE async/await),components/api-server/src/methods/events.js(series → linear async/await),components/api-server/src/methods/profile.js(series → callback chain),components/hfs-server/src/metadata_cache.js(series of mixed sync+promise → linear async/await; deleted unusedtoCallbackhelper),components/test-helpers/src/{InstanceManager,DynamicInstanceManager}.js(until → while loop),components/test-helpers/src/data.js(5 series sites → IIFE async/await; introduced tinyrunSerieshelper fordumpCurrent/restoreFromDumpwhich mix callback-style step lists). - MOVE
asyncfromdependenciestodevDependencies. Several*-seq.test.jsfiles still useasync.series/async.eachSeries; migrating those is a test-infra refactor we can do alongside the TS+ESM conversion. npm ls async --omit=devshows no direct dep;async@3.2.6remains as a transitive ofnconf(boiler) andwinston— out of scope here.- Local validation: PG
just test all→ 1742 / 0; Mongojust test-mongo all→ 1735 / 0.
- DEP Added
@paralleldrive/cuid2@^3.3.0todependencies. Movedcuid@^2.1.8fromdependenciestodevDependencies— test-helpers still usescuid.slug()(cuid2 has no.slug()equivalent) so cuid is kept as a dev-only dep. - CHANGE 17 production files migrated from
require('cuid')toconst { createId: cuid } = require('@paralleldrive/cuid2')(orcreateId: generateIdwhere the local alias wasgenerateId). All call sites already usecuid()(default 24-char form) — cuid2'screateId()is a clean drop-in. - CHANGE
components/api-server/src/schema/event.js— id-format pattern broadened to accept three alternatives: system-stream id (:scope:name), legacy cuid v1/v2 (^c[a-z0-9-]{24}$), and cuid2 (^[a-z][a-z0-9]{23}$). The legacy pattern stays because existing IDs in databases are still cuid v1/v2 strings; the new pattern is required because cuid2 IDs don't share thec…prefix. - NOTE — externally visible format change: every newly minted event/stream/access/webhook/session/password-reset ID will be 24 lowercase alphanumeric chars without a
cprefix (cuid2 format), versus the priorc[a-z0-9-]{24}(25 chars total) cuid v1/v2 format. Existing IDs in production databases remain valid (string columns; no migration). Clients that regex-validate IDs against the legacy^c[…]pattern will need updating; the relaxed schema regex above accepts both. - Local validation: PG
just test all→ 1742 / 0; Mongojust test-mongo all→ 1735 / 0.
- DEP
lru-cachebumped from^7.14.1to^11.0.0. The default export is now{ LRUCache }(renamed in v8). Six call sites updated with the alias trickconst { LRUCache: LRU } = require('lru-cache')so the existingnew LRU({ … })constructions stay verbatim. Affected:components/cache/src/index.js,components/hfs-server/src/metadata_cache.js,components/hfs-server/src/web/op/store_series_batch.js,storages/engines/postgresql/src/AuditStoragePG.js,storages/engines/sqlite/src/userAccountStorage.js,storages/engines/sqlite/src/userSQLite/Storage.js. Constructor options (max,ttl,dispose(value, key)) are forward-compatible. - DEP
cronbumped from^2.4.4to^4.4.0. v4 changed the constructor:new CronJob({ cronTime, onTick })no longer works (the constructor is positional in v4); useCronJob.from({ cronTime, onTick })instead. Single call site updated incomponents/previews-server/src/routes/event-previews.js. Cron pattern format unchanged (6-field with seconds slot still supported). - Local validation: PG
just test all→ 1742 / 0; Mongojust test-mongo all→ 1735 / 0.
- CHANGE
components/tracing/src/Tracing.js— collapsed to a singleDummyTracingno-op class. The exportedTracingandDummyTracingsymbols both now point at the same no-op. The architectural slot is preserved so a future tracer (e.g. an OpenTelemetry adapter) can plug in here without touching consumers. - CHANGE
components/tracing/src/index.js— dropped theisTracingEnabled/launchTagsconfig branches;initRootSpanalways returns aDummyTracinginstance.tracingMiddlewaresimplified to(req, res, next) => { req.tracing ??= new DummyTracing(); next(); }. - CHANGE
components/tracing/src/databaseTracer.js— replaced the Jaeger-driven monkey-patcher withmodule.exports = function patch () {};. Callers incomponents/storage/src/index.jsandstorages/index.jsneed no edits. - CHANGE
components/tracing/src/HookedTracer.js— replaced with a no-opHookedTracerclass. - CHANGE
components/hfs-server/src/tracing/cls.js— replaced with a no-opClsclass.setRootSpan/getRootSpan/startExpressContextall return null or pass through. - CHANGE
components/hfs-server/src/tracing/middleware/trace.js— passthrough that callsnext(). - CHANGE
components/hfs-server/src/application.js— droppedopentracingandjaeger-clientimports;produceTracerremoved; replaced with an inlineNoopTracer/NoopSpanminimal stub used byContext#childSpan. - CHANGE
components/hfs-server/src/server.js— removed theif (traceEnabled)block that registered the trace+cls middleware. ThetraceEnabledconfig flag and theclsWrapFactory/tracingMiddlewareFactoryimports are gone. - CHANGE
components/hfs-server/src/web/controller.js—storeErrorInTraceno longer readsopentracing.Tags.ERROR; it tags the root span (now always null) with the literal string'error'. With cls returning null, the function early-returns; behaviour is unchanged from the priortrace.enable: falsedefault. - DROP from
package.jsondependencies:jaeger-clientcls-hookedopentracingshimmer
npm ls jaeger-client opentracing cls-hooked shimmer --omit=devis empty.- AGENTS.md truth #6 rewritten to describe the slot-shim model and direct future tracer authors to
components/tracing/src/Tracing.js. - New Relic APM (Plan 38) is the active observability path and runs in parallel, not through
components/tracing/. Operators using New Relic see no change. Operators relying ontrace.enable: true(none we're aware of) will find the flag is now ignored — Jaeger is gone. - Local validation: PG
just test all→ 1742 / 0; Mongojust test-mongo all→ 1735 / 0.
- DROP
hjsonfrompackage.json. Zero call sites in the entire repo (production or test). - DROP
urlfrompackage.json. The singlerequire('url')site incomponents/test-helpers/src/spawner.jsresolves to the Node 24 built-inurlmodule (same name) — the npm package was a no-op shadow. - DROP
mkdirpfrompackage.json. Replaced 8 call sites across 6 production files + 1 test-helper file withfs.mkdir(path, { recursive: true })/fs.mkdirSync(path, { recursive: true })(Node ≥ 10). Affected:components/business/src/integrity/MulterIntegrityDiskStorage.js,components/previews-server/src/attachmentManagement.js,components/storage/src/userLocalDirectory.js,storages/engines/filesystem/src/EventLocalFiles.js,storages/engines/sqlite/src/usersLocalIndex.js,storages/engines/rqlite/src/rqliteProcess.js,components/test-helpers/src/data.js. - DROP
body-parserfrompackage.json. Replaced 3 production sites + 3 test sites with the express-built-in equivalents (express.json()/express.urlencoded()) — Express 4.16+ ships them. Affected:components/api-server/src/expressApp.js,components/hfs-server/src/server.js,components/previews-server/src/expressApp.js, plus three local-HttpServertest mocks. - MOVE
awaiting,fs-extra,backloop.dev,msgpack5fromdependenciestodevDependencies:awaitingis required by 3 acceptance test files and zero production files.fs-extrais required by 1 storage test file and zero production files.backloop.devis loaded only behind thehttp:ssl:backloop.devconfig flag incomponents/api-server/src/server.js, a local-dev convenience; production runs use ACME (Plan 35) or operator certs.msgpack5is required bycomponents/test-helpers/src/{child_process,spawner}.jsonly.
- AGENTS.md: added architectural truth #6 documenting that
components/tracing/remains a real production dependency (8 hot-path call sites) even when Jaeger is disabled, and thattrace.enable: falseonly short-circuits theTracingbody — wiring is hot-path. Future deletion ofcomponents/tracing/requires touching all 8 callers in the same patch (filed asXXX-Backlog/PLAN52-PHASE4-TRACING-RIPOUT.md). - Local validation: PG
just test all→ 1742 / 0; Mongojust test-mongo all→ matches Plan 52 Phase 3.S.2 baseline. - Out of scope (filed for follow-up): drop
asynccallback-control-flow lib (XXX-Backlog/PLAN52-PHASE4-ASYNC-CALLBACK-DROP.md), dropbluebird(recommended to fold into TS+ESM migration), replaceunix-timestamp's duration DSL, major bumps forlru-cache/cron/slug/email-templates/nodemailer(_plans/XX-deps-major-bumps-later/PLAN.md),mongodb4→7 (own plan TBD),z-schema→ajv(own plan TBD),cuid→cuid2(own plan TBD).
- CHANGE
components/api-server/src/methods/helpers/mailing.js—_sendmail()uses nativefetch. Callback contract preserved (cb(err, res));parseError()now also matchesENOTFOUND/ECONNREFUSEDin the unreachable-endpoint branch since native fetch's reject messages differ from superagent's. - CHANGE
components/business/src/mfa/Service.js—_makeRequest()uses nativefetch, JSON-encoding non-string POST bodies and explicitly throwing on!res.okso the existingtry/catch → invalidOperation('mfa-sms-provider-error')translation still fires. Consumers (SingleService,ChallengeVerifyService)awaitwithout reading the response body, so the swap is transparent at call sites. - DEP
nockbumped from^13.2.9to^14.0.13(latest stable). v14's headline feature is nativefetchinterception via@mswjs/interceptors, which is what unblocked the two swaps above. Engine constraint>=18.20.0 <20 || >=20.12.1is satisfied by Node 24. No test API surface change required —nock(host).post(...).reply(...)chain works identically. - FIX
components/api-server/test/mfa-seq.test.js—nock.enableNetConnect('127.0.0.1')widened toenableNetConnect(/127\.0\.0\.1|localhost/). nock v14 intercepts nativefetchtoo, and the rqlite client (DBrqlite.query/execute) connects tolocalhost:4001—'127.0.0.1'and'localhost'are not aliased by the allowlist. - DEP
superagentmoved fromdependenciestodevDependencies(still needed bycomponents/test-helpers/src/{request,parallelTestHelper}.js). Production runtime no longer pullssuperagent— and therefore no longer pulls its transitiveformidable@2.1.5.npm ls formidable --omit=devis now empty;formidablesurvives only via the test surface. - Local validation: PG
just test all→ 1742 / 0; Mongojust test-mongo all→ 1734 / 0 (one pre-existing flake[AUTH] [AU01] [FMJH]on concurrent login race, not caused by this slice — re-runs cleanly). - Closes Plan 52 Phase 3.S.2 (combined with the previous Phase 3.S.1 commit, all four production
superagentcall sites are now on nativefetch). Phase 3.F (formidable cleanup) auto-closed: production dep graph isformidable-free.
- CHANGE
components/business/src/types.js—TypeRepository.tryUpdate()now fetches the remote event-types definition via Node's nativefetchinstead ofsuperagent. Throws an explicitError("Event types fetch failed: HTTP <status> <statusText>")on non-2xx so the existingtry/catch → unavailableError(err)path still triggers. No behavior change at the call sites. - CHANGE
components/business/src/webhooks/Webhook.js—makeCall()uses nativefetch. To preserve the prior superagent semantics consumed byrunOnce()and thewebhooks.testAPI method,makeCall()now explicitly throws on!res.okwitherr.response = { status }attached; nativefetchdoes not throw on 4xx/5xx by default. Removed the unusedrequest = require('superagent')import. - NOT IN THIS SLICE:
components/api-server/src/methods/helpers/mailing.jsandcomponents/business/src/mfa/Service.jsstill usesuperagent. Both call sites are exercised by tests that intercept HTTP vianock@^13.5.6, which does not intercept Node 24's nativefetch(Undici dispatcher). Migrating these two requires either upgrading tonock@^14(native fetch interceptor) or switching the affected tests to a real local HTTP server. Tracked in the next Phase 3.S.2 slice; out of scope here. superagenttherefore stays in runtimedependenciesfor now. The two completed swaps still reduce the production runtime's reliance on it.- Local validation: PG
just test all→ 1742 / 0; Mongojust test-mongo all→ 1735 / 0 (both match Phase 3.L baseline).
- NEW
components/boiler/workspace package — exact copy of the@pryv/boiler@1.2.4source tree (8 files, 4 src/ files + lib/nconf-yaml + README + LICENSE + package.json). Resolves under the existing npm-workspace symlink atnode_modules/@pryv/boilerso everyrequire('@pryv/boiler')call site continues to work unchanged. package.json—@pryv/boilerremoved from runtimedependencies; the workspace package now satisfies the import. No longer pulls boiler from the upstreampryv/pryv-boiler.git#semver:^1.2.4git URL at install time.package-lock.json— boiler's transitive deps (debug,js-yaml,nconf,superagent,winston,winston-daily-rotate-file) now resolve against the in-tree workspace; root-level entries unchanged in production behaviour.- Local validation:
just test all(PG default) → 1742 / 0 (matches pre-vendoring baseline). - Why: this is the first slice of a phased removal. With boiler in-tree we can drop the remote-config
superagentpath, the unusednotifyAirbrake/airbrake stubs, and thepluginAsyncordering surface in follow-up commits without coupling those changes to apackage.jsondep change. Each simplification step is a standalone commit with its own test pass.
- FIX
storages/engines/rqlite/scripts/setup— replaced$0with${BASH_SOURCE[0]}forSCRIPT_FOLDERresolution. The script is sourced (not exec'd) fromscripts/setup-dev-env, which made$0resolve to the parent script's directory. As a resultREPO_ROOT=$SCRIPT_FOLDER/../../../..landed one parent above the actual repo root, andbin-ext/rqlitedwas installed outside the repo. The start script (which uses its own correct path resolution) then could not find the binary, rqlited never came up, and every test that touches PlatformDB failed withTypeError: fetch failed → ECONNREFUSEDagainstlocalhost:4001. Masked since 2026-04-14 bycontinue-on-error: trueon the test jobs. - FIX
storages/engines/mongodb/scripts/setup— same one-line fix for consistency. The latent bug did not manifest for mongo because mongo's setup uses$VAR_PRYV_FOLDER(exported correctly by the parent) for path computation rather thanSCRIPT_FOLDER. - FIX
storages/engines/postgresql/src/userAccountStorage.js—getPasswordHash()now returnsundefined(notnull) when no password row exists, matching the conformance contract and the MongoDB engine.getCurrentPasswordTime()now throwsError("No password found in database for user id ...")when no row exists, matching the MongoDB engine's behaviour. Closes the three pre-existing PG-side[UAST]conformance failures ([V54S]+ theclearHistory()and_clearAll()round-trip checks). .github/workflows/ci.yml—test-mongojob removed. PostgreSQL is the default baseStorage engine since 2026-04-24; MongoDB is opt-in (just test-mongo all) and validated locally rather than in CI.continue-on-error: truestopgap removed fromtest-postgres; the job is fully blocking again.dockerjob depends only ontest-postgres+lint.
- NEW
AGENTS.mdat repo root — fast-orientation guide for LLM coding agents (Claude Code, Cursor, Copilot, etc.) bootstrapping against open-pryv.io v2. Covers the "single-binary codebase" framing, annotated repo map, local-run + test commands, five architectural truths (master.js lifecycle, native TLS, wildcard certs viaderiveHostnames, pluggable storage engines, cluster CA lifecycle), common pitfalls, config precedence, and a curated block of in-repo + pryv.github.io links. README.md— "For LLM coding agents" paragraph at the bottom points atAGENTS.md.- The draft that preceded this entry had drifted against the tree (non-existent
just dev/just test-postgresrecipes, wrong engine-config YAML keys, stale meta-repo framing, outdatedREADME-DBs.mdwarning). All such issues fixed; file length 218 lines, under the 250-line soft cap.
- NEW
components/mail/workspace package — portsSender/Template/errorsfrom the standalone service-mail repo; addsTemplateRepositoryagainst an injectedtemplateExists(so the backing store can be tmp-dir, disk or PlatformDB) and a tmp-dir-materializeemailTemplatesDeliveryadapter around theemail-templatesnpm module. Façadeinit()/isActive()/send()/refresh()/close()with silent no-op before init so callers don't need to guard. - NEW
components/mail/src/TemplateSeeder.js— idempotentseedIfEmpty({platformDB, templatesRootDir}). Walks<root>/<type>/<lang>/*.pugand populates PlatformDB only when zeromail-template/*rows already exist. - NEW master-boot wiring — invokes the seeder after
storages.init()whenservices.email.method === 'in-process'. Try/catch guard: a malformedtemplatesRootDirnever blocks master startup. - NEW PlatformDB interface methods —
setMailTemplate/getMailTemplate/getAllMailTemplates/deleteMailTemplate(type, lang, part?). Keyspacemail-template/<type>/<lang>/<part>on the existing rqlitekeyValuetable.deleteMailTemplate(type, lang)with nopartwipes both html + subject scoped to that<type>/<lang>/prefix only. - NEW
components/api-server/src/methods/helpers/mailing.js— new'in-process'case in themethodswitch. First call in a worker lazy-inits themailfaçade withstorages.platformDB.getAllMailTemplates+ the per-core SMTP config. Callback contract preserved — existing callers (registration.js::sendWelcomeMail,account.jsreset-password flow) don't need any edit. - NEW admin surface —
bin/mail.jsCLI +/system/admin/mail/*routes (seeCHANGELOG-v2.md). Write routes emitprocess.send({type:'mail:template-invalidate'})so master broadcasts the nudge to every sibling worker (including the originating one is skipped); each worker'scomponents/mail/src/index.jssubscribes viaprocess.on('message', …)ininit()and callsrefresh()on receipt. - NEW master IPC handler —
cluster.on('message', …)case formail:template-invalidate; broadcasts to all workers except the originator. - DEPS:
email-templates@^10.0.1,nodemailer@^6.9.16,pug@^3.0.4added as production deps on the rootpackage.json. No transitive conflicts with the existing stack. - TESTS:
[MAILTMPL]7 cases oncomponents/platform/test/conformance/PlatformDB.test.js— round-trip, null-absent, overwrite, bulk decode, single-part delete, lang-wide delete scoped, namespace isolation fromdns-record/*/user-core/*/observability/*.[MAILSEND]/[MAILTMPL]/[MAILREPO]/[MAILADAPT]/[MAILFCD]/[MAILSEED]— 21 unit tests undercomponents/mail/test/.[MLIP]2 cases oncomponents/api-server/test/methods/helpers/mailing.test.js— end-to-end Pug render + nodemailer jsonTransport dispatch via the helper.[MAILCLI]9 subprocess cases +[MAILADM]9 HTTP cases oncomponents/api-server/test/.
Dockerfile— rqlited binary relocated from/app/var-pryv/rqlite-bin/rqlited→/app/bin-ext/rqlited. Operators who bind-mount/app/var-pryv(intending to persist rqlite data) no longer shadow the baked-in binary. The only persistent path docker operators need is/app/var-pryv/rqlite-data, now declared asVOLUME.- Dev layout aligned:
var-pryv/rqlite-bin/rqlited→bin-ext/rqlitedin the setup script, start script, rqlite manifest default, bin/master.js fallback, and two test files that hard-coded the path..gitignorecovers the new location. - Operators who override
storages.engines.rqlite.binPathinoverride-config.ymlare unaffected either way. INSTALL.md— new "Docker / Dokku deployment" section with a "What to persist" checklist, Dokku-specific storage mount commands, and an explicit note aboutdokku ps:restartrequiringdokku proxy:build-config <app>afterward (nginx upstream caching bug that doesn't refresh on restart). Also documents theDATABASE_URL-not-auto-consumed caveat and the UDP/53 docker-options workaround for DNS-active multi-core on Dokku.
config/default-config.yml—storages.base.engineis nowpostgresql(wasmongodb). Mongo remains fully supported; setstorages.base.engine: mongodbinoverride-config.yml(or exportSTORAGE_ENGINE=mongodbfor tests) to pick it explicitly. Deployments that pin the engine inoverride-config.ymlare unaffected.justfile—just test+just test-parallel+ all othertest-*recipes run PG by default. Newjust test-mongo/just test-mongo-parallelrecipes for the Mongo path. Removed:test-pg,test-pg-parallel(now redundant)..github/workflows/ci.yml—test-postgresjob runsjust test all,test-mongojob runsjust test-mongo all.
- New module
components/business/src/observability/— provider-agnostic façade.isActive() / setTransactionName / recordError / recordCustomEvent / startBackgroundTransaction. Every provider call wrapped in try/catch so observability can never break a request. - New module
components/business/src/observability/logForwarder.js— wraps a boiler logger to mirror its level methods intoobservability.recordError / recordCustomEvent. Errors always go to the provider's Error inbox regardless of log level; warn/info/debug becomePryvLogcustom events queryable via NRQL. - New module
components/business/src/observability/providers/newrelic/{boot,adapter,newrelic.config.template}.js— thin wrapper over thenewrelicnpm package. Agent config is driven entirely by env vars the master process populates, so no on-disk config edits are required per deployment. - New shim
bin/_observability-boot.js— must berequire()d first in every entrypoint. Bypasses inNODE_ENV=testor whenPRYV_OBSERVABILITY_PROVIDERis unset; otherwise dispatches to the provider's boot module so the underlying agent loads beforehttp/express/pg/ etc. - PlatformDB surface: new
setObservabilityValue / getObservabilityValue / getAllObservabilityValues / deleteObservabilityValue. Keyspaceobservability/<key>in the existing rqlitekeyValuetable — no schema change. - Platform surface: new
getObservabilityConfig()merges local YAML override + PlatformDB rows + derived fields (hostname fromnew URL(core.url).hostname, appName fallback). Localobservability.enabled: falsealways wins; otherwise PlatformDB is authoritative. Secret rows decrypted on demand viaAtRestEncryptionwith HKDF-derived keys (source:auth.adminAccessKey, per-key purpose label). - Master wiring: reads
platform.getObservabilityConfig()before forking workers, builds a sharedobservabilityEnvobject, and spreads it into everycluster.fork({...})call (api / hfs / previews). Environment variables includePRYV_OBSERVABILITY_PROVIDER,NEW_RELIC_LICENSE_KEY,NEW_RELIC_APP_NAME,NEW_RELIC_PROCESS_HOST_DISPLAY_NAME,NEW_RELIC_LOG_LEVEL,NEW_RELIC_HIGH_SECURITY=true,NEW_RELIC_HOME. - Admin CLI
bin/observability.js—storagesbarrel directly, no HTTP. Parses--helpbefore boiler init (same pattern asbin/dns-records.js).
storages/engines/rqlite/test/platformdb-conformance.test.js— 6 new[RQPF]cases under the sharedcomponents/platform/test/conformance/PlatformDB.test.js: round-trip, overwrite/rotation, bulk read, delete, namespace isolation vsdns-record/*anduser-core/*.components/api-server/test/observability-seq.test.js—[OBS]suite (9 cases): Platform round-trip with encryption, localenabled:falseoverride wins, appName fallback, hostname derivation, façade no-op when no provider, shimNODE_ENV=testbypass, shim unset-env no-op, logForwarder errors-only default, logForwarderwarnlevel forwards errors + warns.
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.
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.
components/business/src/auth/registration.js— newforwardIfCrossCorestep inserted into theauth.registerchain betweenprepareUserDataandvalidateOnPlatform. Callsplatform.selectCoreForRegistration(hosting); if target ≠ self, HTTPS-POSTs the original body to{targetUrl}/users(the target's ownforwardIfCrossCoreis idempotent when target == self). Target's response (minus its ownmetablock) is merged intoresult.forwarded. Atomic on the target: unique-field reservation, user-core assignment, user insert, welcome mail all on one core.validateOnPlatform,createUser,buildResponse,sendWelcomeMailall short-circuit onresult.forwarded— no duplicate work, no duplicate mail.components/platform/src/Platform.js—validateRegistration(username, invitationToken, uniqueFields, hosting)now takes + honours the caller-provided hosting. Previously it always calledselectCoreForRegistration()without the hosting filter, so with a least-users tiebreak a new aws-us-east-1 registration could leak to aws-eu-central-1 just because the latter had fewer users.components/api-server/src/methods/auth/register.js— wiresforwardIfCrossCoreinto theauth.registermethod chain.
components/api-server/src/schema/service-info.js— added optionalversionfield.components/api-server/src/methods/service.js— populatesversionfromgetAPIVersion().lib-js+app-web-auth3gate onversion >= 1.6.0to pick the direct-core/usersregistration endpoint. Before this, our/service/infohad no version → SDKs fell back to the legacy/reg/uservia reg.{domain} round-robin, which (before the forward fix) compounded the orphaned-user-core bug.config/plugins/public-url.js— in multi-core (dnsLess.isActive: false) mode,register: https://reg.{domain}/andaccess: https://access.{domain}/access/instead of the oldregister: https://core-{id}.{domain}/reg/. The reserved-subdomain URLs are core-symmetric and match the v1 Pryv.io URL shape;regSubdomainPathMapmiddleware (below) handles the/regprefix inside the core.config/plugins/config-validation.js— newREQUIRED_SERVICE_FIELDS = ['name', 'serial', 'home', 'support', 'terms', 'eventTypes']check. Master fails fast with a clear error instead of starting into an api-server crash-loop when operators forget theservice:block.bin/master.js— addedconfig-validationplugin to master's boiler init (previously only in api-server'sapplication.js), so the service-required-fields check triggers on master bring-up too.
components/dns-server/src/DnsServer.js— newRESERVED_SERVICE_NAMES = ['reg', 'access', 'mfa']. The embedded DNS auto-resolves these three subdomains to every available core's IP (viagetAllCoreInfos()), nodns.staticEntriesrequired. Operators still ownsw,mail, etc. via staticEntries; documented inconfig/default-config.yml.components/api-server/src/expressApp.js— two multi-core-only middleware additions:subdomainToPath'signoredSubdomainslist now includesreg,access,mfa, and every key fromdns.staticEntries. Without this,access.pryv.me(6 chars, matches the username regex) was rewritten to/access/…and fell into the username router.- New
regSubdomainPathMapmiddleware: whenHost: reg.{domain}(oraccess./mfa.), prepend/regtoreq.urlbefore route matching. Lets clients use rootless v1-style URLs (reg.pryv.me/perki/server,reg.pryv.me/service/info) while the internal routing stays under/reg/*. Idempotent — skips when the path already starts with/reg/.
components/api-server/src/routes/register.js— whendnsLess.isActive: false, also exposeGET /service/infoat the root (alias for/reg/service/info). Lets SDKs bootstrap fromhttps://reg.{domain}/service/infodirectly.components/api-server/src/routes/reg/legacy.js—GET+POST /reg/:uid/servernow look up the core viaplatform.getUserCore()(PlatformDB, replicated) instead ofusersRepository.usernameExists()(per-core SQLite index). Without this, round-robin DNS on reserved subdomains caused 50 % 404s because only the user's home core had them in its local index.getCoreUrlForUserreturnsnullwhen no mapping exists so the handler 404s cleanly.
components/api-server/src/routes/reg/access.js— POST/reg/accessresponse now includes:authUrl(primary) — built fromaccess.defaultAuthUrl+ query params (lang, key, requestingAppId, poll, poll_rate_ms, serviceInfo). SDKs open this URL in the sign-in popup.url(deprecated) — same value, kept for v1 SDK compatibility.poll— core-affine URL built fromcore.url, not the cluster-wideservice.register. The poll state is in-memory per core, so a poll GET must pin to the same core that served the POST; usingservice.registerround-robined across cores and causedunknown-access-keyon half the polls.lang,returnURL+returnUrl(camelCase lib-js expects),serviceInfo(v1-compatible — SDKs re-hydrate from the body).
- GET
/reg/access/:keyNEED_SIGNIN response now mirrors the same fields (poll, authUrl, url, lang, returnUrl/returnURL, serviceInfo) soapp-web-auth3'scontext.init() → setServiceInfo(accessState.serviceInfo)doesn't crash with "Cannot read properties of undefined (reading 'name')" and clients re-hydrating state from the poll body see the poll URL. state.pollUrl+state.authUrlare stashed on the in-memory access state at POST time so the subsequent GETs echo them verbatim.
bin/master.js—cluster.setupPrimary({ args: process.argv.slice(2) })before forking workers.cluster.fork()by default runs the worker with only[node, master.js]— argv after the script name is silently dropped. Deployments that layered--config host-config.ymlhad their workers fall back toNODE_ENV-based config and silently use the wrong storage engine / ports.components/middleware/src/project_version.js—process.mainModule || require.main || modulefallback.process.mainModulewas deprecated and can beundefinedin Node 22 when the entrypoint is loaded via a wrapper or cluster fork; the old code threwTypeError: Cannot read properties of undefined (reading 'paths')which was swallowed by boiler's file logger and surfaced as silent api-server worker crash loops.components/api-server/bin/server— catch-block mirrors fatal errors toprocess.stderr. Master'sapi worker died (code=1, signal=null)now always has an actionable cause attached instead of being silent.components/business/src/acme/CertRenewer.js+AcmeOrchestrator.js+bin/master.js—PlatformDBDnsWriteraccepts an optionaldnsServerand callsdnsServer.refreshFromPlatform()immediately after writing_acme-challenge.<zone>TXT to PlatformDB. Previously relied on the DnsServer's 30 s periodic refresh, so LE's DNS-01 validator often failed with "No TXT records found".AcmeOrchestrator.build()threadsdnsServerthrough;bin/master.jspasses it. Real LE wildcard issuance on a fresh cluster now succeeds on the first attempt instead of 15–30 min after rqlite caught up.components/business/src/auth/registration.js::sendWelcomeMail— guards against missingservices.emailin config (fresh bundle-bootstrapped cores have no default) and against forwarded registrations (target core already sent the mail). Before, a missingservices.emailthrewCannot read properties of undefined (reading 'enabled')AFTERcreateUserhad already persisted the user, leaking a 500 response to the client even though the registration had technically succeeded.
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.
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.
storages/interfaces/backup/FilesystemBackupReader.js— newreadServerMappings()method that streams{username, server}rows fromregister/servers.jsonl[.gz]. No-op when the register/ subdir is absent (open-pryv.io v1.9 or v2→v2 backups).storages/interfaces/backup/BackupReader.js— defaultreadServerMappings()on the base interface yields an empty async iterator, so sources without register data (any reader that doesn't override it) inherit a safe default.components/business/src/backup/RestoreOrchestrator.js—_restorePlatformnow also iteratesreadServerMappings(); for each mapping, writes auser-core/<username>row to PlatformDB. Maps the v1 server hostname (e.g. "co1.pryv.me") to whichever core is the SOLE available core on the destination — the common case for single-core restore. Multi-core destinations with more than one available core are left as a no-op for now; a future pass can accept a--core-mapoption. Previously v1→v2 restores left every user's DNS resolution broken until the operator manually INSERTeduser-core/*rows.
components/api-server/test/reg-multicore-dnsless-false-seq.test.js(new) — regression suite covering the cross-core forward,/reg/accessPOST+GET shape,/service/inforequired fields + version + reserved subdomains, and the v1→v2 register-mappings restore path. Uses a targetedglobal.fetchinterceptor (passes through to realfetchexcept for the inter-core forward URL) so the rqlite HTTP client keeps working during the test.components/api-server/test/reg-multicore-seq.test.js[MC01A/B]— rewritten from "must return redirect" to "HTTPS-forwards POST to target + atomic on failure" to match the new behaviour; same targeted-fetch interceptor.components/api-server/test/service-info.test.js[FR4K]— tolerates the newversionfield and the response-envelopemetablock.components/dns-server/test/dns-server.test.js[DN11]— asserts reserved subdomainreg.{domain}resolves to A records (all core IPs), not CNAME.components/cache/test/acceptance/cache.test.js[FELT]—this.retries(3)on the 15%-cache-gain timing assertion. The thresholded comparison was flaky under scheduler noise on shared dev VMs (5–15 % gain range); retries turn transient noise into eventual success without weakening the signal.
Surfaced when running the full matrix against the distribution changes above. Changes are small, isolated, and carry no API behaviour impact.
config/plugins/config-validation.js—checkIncompleteFieldsnow skips theREPLACEsentinel scan on any block whereactive === falseorenabled === false. Fixes dead-codeif (obj.active && !obj.active) return(always false). Unblocks default-config placeholders likeletsEncrypt.email: 'REPLACE ME'/letsEncrypt.atRestKey: 'REPLACE ME'(operators only set these whenletsEncrypt.enabled: true) from tripping startup in vanilla config.components/api-server/src/application.js—config-validationis now registered aspluginAsync(previouslyplugin). Required becauseserviceInfo(scope loaded fromserviceInfoUrl) is itself async; the validator's required-service-fields check would otherwise fire beforeservice.*was populated and always fail-fast with "service fields missing".components/api-server/src/methods/service.js— removed the first-callthis.serviceInfocache. Service info is now read live from config every request. The cache leaked state between tests sharing a single api-server and would also prevent future runtimeservice:updates (e.g. admin-API edits) from being visible without a restart.components/api-server/src/routes/reg/legacy.js—getCoreUrlForUserin single-core mode now verifies the user exists (usersRepository.usernameExists()) before returning the core URL. Previously any arbitrary username would resolve to the local URL, shadowing the 404 the/reg/:uid/serverroutes are supposed to emit for unknown users.
storages/interfaces/migrations/— contracts + conventions for forward-only, timestamp-ordered schema migrations.migration.d.tsdefines the{ up, down? }shape;MigrationRunner.d.tsdefines the runner +MigrationCapableEnginecontract;README.mdcaptures the model (integer version +1 per migration,YYYYMMDD_HHMMSS_<slug>.jsfilenames, idempotency requirement, per-engineschema_migrationsstorage).storages/interfaces/migrations/MigrationRunner.js— runtime.discoverMigrations()walks an engine'smigrations/dir and lex-sorts;status()reports per-engine{ currentVersion, pending };runAll({ targetVersion, dryRun })appliesup()in order and bumps version via the engine'ssetVersion().createMigrationRunner()auto-wires from the active storages barrel, iterating engines that exportgetMigrationsCapability().- Per-engine tracking:
storages/engines/postgresql/src/SchemaMigrations.js— lazyCREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, updated_at TIMESTAMPTZ ...); current version =MAX(version).storages/engines/rqlite/src/SchemaMigrations.js— JSON row in the existingkeyValuetable under keymigrations/version.- Mongo does not participate in the v2 scheme — it has no schema evolution pressure in v2.
- Deleted
storages/engines/mongodb/src/Versions.js, the entirestorages/engines/mongodb/src/migrations/directory (1.9.0.js..1.9.4.js,MigrationContext.js,index.js),storages/engines/mongodb/test/migrations/(old test fixtures),storages/engines/postgresql/src/VersionsPG.js,storages/interfaces/baseStorage/Versions.{js,d.ts},storages/interfaces/baseStorage/conformance/Versions.test.js. - Removed
versionstable DDL fromDatabasePG.js(it was unused and never populated anyway —_internals.migrationswas never registered). - Removed
migrations/MigrationContext/softwareVersionfrom both engine_internals.jsand the barrel'sregisterInternals(). - Updated both engine manifests (
storages/engines/{mongodb,postgresql}/manifest.json) to drop the three deadrequiredInternals. StorageLayerno longer carries aversionsfield;components/test-helpers/src/dependencies.js+data.js+databaseFixture.jsno longer reference it.- v1 → v2 migration is now explicitly an export-via-
dev-migrate-v1-v2→bin/backup.js --restoreoperation. No code path in v2 reads pre-v1.9.3 shapes.
bin/master.js— replacedstorageLayer.versions.migrateIfNeeded()withcreateMigrationRunner().runAll()gated bymigrations.autoRunOnStart(default true). Renamed fromcluster.runMigrationsinconfig/default-config.yml.bin/migrate.js(new) — standalone CLI:status/up [--target N] [--dry-run]. Opens storages barrel directly; no HTTP; works whether master is running or not.- Each engine's
index.jsnow exportsgetMigrationsCapability()returning{ id, migrationsDir, getVersion, setVersion, buildContext }ornullwhen the engine is inactive. The runner auto-discovers capabilities across all loaded engines.
components/api-server/test/migrations-runner-seq.test.js—[MIGRUN]suite (9 cases): fresh state, single migration, ordered multi-migration, dry-run, target version, idempotent re-run, engine-switch independence (two in-memory engines), failure-stops-run, live-barrel wiring.- Legacy conformance
storages/interfaces/baseStorage/conformance/Versions.test.jsremoved. - All pre-existing suites green in both engines:
storage13/13,business126/126,api-server908/908 (+9 new[MIGRUN]cases).
components/api-server/src/routes/reg/records.js: addedDELETE /reg/records/:subdomain. Path refactored to share auth + IPC-nudge helpers with the existing POST handler.bin/dns-records.js(new): standalone Node CLI — subcommandslist,load <file>,delete <subdomain>,export [file]. Reads/writes PlatformDB directly viastoragesbarrel (no HTTP dependency). Usesjs-yaml(already transitively present; no new dependency). Parses--helpbefore boiler init to avoid boiler's yargs swallowing it.components/api-server/test/dns-records-cli-seq.test.js(new,[DNSCLI]): spawns the CLI as a subprocess and round-tripslist/load(including--dry-runand--replace) /export/delete, plus error paths (missing subdomain, malformed file).components/api-server/test/reg-records-seq.test.js:[RR10]-[RR12]cover the DELETE route happy path, missing-auth rejection, and unknown-subdomain 404.
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.
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.
AtRestEncryption.js— HKDF-SHA256 key derivation + AES-256-GCM envelope. Source-agnostic (caller supplies the byte-string + purpose label). 22[ATRENC]tests.AcmeClient.js— stateless wrapper overacme-client@5.4.0:createAccount()+issueCert(). Parses leaf-cert validity from the returned PEM so callers get{certPem, chainPem, keyPem, issuedAt, expiresAt}. InjectableacmeLibfor unit tests. 9[ACMECLIENT]tests.certUtils.js—splitCertChain(pem)(leaf vs. chain),parseValidity(pem)(vianode:crypto X509Certificate),hostnameToDirName('*.x.com')→'wildcard.x.com'. 8[CERTUTILS]tests.CertRenewer.js— glues AcmeClient + AtRestEncryption + PlatformDB.ensureAccount()(idempotent; persists encrypted ACME account),renew({hostname, dnsWriter})(issues + encrypts + persists + returns metadata),getCertificate(hostname)(decrypted). Strips wildcard prefix in challenge record names. 15[CERTRENEWER]tests.PlatformDBDnsWriter— default DNS-01 writer for multi-core with embedded DNS.setDnsRecordappends to existing TXT values so apex + wildcard challenges coexist; propagation wait default 15 s.FileMaterializer.js— per-core polling loop. SHA-256 fingerprint-based change detection; atomic disk writes;onRotatefire-and-log semantics.runRotateScriptspawns an operator-supplied absolute-path script withPRYV_CERT_*env vars; SIGKILL-timeouts at 30 s with exitCode 124. 11[FILEMAT]tests.deriveHostnames.js— topology →{commonName, altNames, challenge}in priority order:dnsLess.publicUrl→ HTTP-01 single host ·core.url→ HTTP-01 single host ·dns.domain→ DNS-01 wildcard + apex. Throws on missing with actionable error. TreatsREPLACE MEplaceholder as unset. 8[DERIVEHOSTS]tests.AcmeOrchestrator.js— intervals and start/stop. Materialize tick (every core, default 60 s) + renew tick (only on the CA-holder core withletsEncrypt.certRenewer: true, default 24 h). Both prime on start().build({config, platformDB, atRestKey, onRotate})is the operator-facing factory thatbin/master.jscalls. 10[ACMEORCH]tests.
storages/interfaces/platformStorage/PlatformDB.{js,d.ts}— six new methods:setAcmeAccount/getAcmeAccount(singleton) +setCertificate/getCertificate/listCertificates/deleteCertificate(per hostname, wildcard keys stored as literals e.g.tls-cert/*.mc.example.com).storages/engines/rqlite/src/DBrqlite.js— impl. Keys:tls-acme-account,tls-cert/<hostname>.components/platform/test/conformance/PlatformDB.test.js— 9 new cases (setAcmeAccount/getAcmeAccountsingleton + overwrite;setCertificate/getCertificateround-trip + null + overwrite + wildcard keys;listCertificatesmetadata-only contract;deleteCertificate; namespace isolation between tls-cert / dns-record / user-unique).
config/default-config.yml— newletsEncrypt:block (9 keys:enabled,email,atRestKey,renewBeforeDays,staging,tlsDir,certRenewer,onRotateScript,directoryUrl).atRestKeyis operator-sync responsibility (same shape asauth.adminAccessKey).bin/master.js— whenletsEncrypt.enabled, decodeatRestKey(base64 → 32 bytes), callbuildAcmeOrchestrator(), start it. Shutdown path (SIGTERM/SIGINT) calls.stop(). Misconfig logs but doesn't take down master.onRotatecallback broadcastsacme:rotatecluster IPC to every live worker.components/api-server/src/server.js— keepsthis.httpsServerreference; newreloadTls()re-readshttp.ssl.{key,cert,ca}Filefrom disk and callssetSecureContext(). ExtractedbuildHttpsOptions()helper.components/api-server/bin/server—process.on('message', {type:'acme:rotate'})→server.reloadTls(). No-op on non-HTTPS workers (hfs, previews, http-only api).components/api-server/src/routes/system.js— newGET /system/admin/certsreturninglistCertificates()metadata +daysUntilExpiry. admin-key gated by the existingcheckAuth.
components/business/test/unit/acme-integration.test.js[ACMEINT]— wires real rqlite + mocked acme-client + real FileMaterializer. Three assertions: initial issuance (encrypted in rqlite, decrypted keyPem on disk 0600), no-op on not-yet-due, rotation on forced near-expiry. Raw rqlite row scanned forBEGIN PRIVATE KEYmarker — guards against plaintext regressions. Skips gracefully when rqlite isn't reachable.
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.
- 83 acme-* unit tests + 1 integration test, all green.
- 9 new PlatformDB conformance tests (rqlite: 30 → 39).
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.
storages/engines/rqlite/src/rqliteProcess.jsbuildArgs()— newtls: { caFile, certFile, keyFile, verifyClient, verifyServerName }block translates to rqlited flags-node-ca-cert,-node-cert,-node-key,-node-verify-client,-node-verify-server-name(rqlited 8.x naming).tls: null(default) → zero TLS flags emitted → identical pre-upgrade behaviour. No regression risk for single-core or VPN-protected multi-core.[RQARGS]14 unit tests cover flag formation across single/multi-core, with/without TLS,verifyClientbool,verifyServerNameoverride.[RQMTLS]integration test spins up tworqlitedprocesses wired with the same self-signed CA + node certs and asserts the cluster forms + a write replicates within 3 s.
ClusterCA.js—ensure()/getCACertPem()/issueNodeCert({ coreId, ip, hostname }). Shells out toopenssl(system dep) for X.509 signing; Node's built-incryptocan generate keys but not sign certs. CA private key never leavesdir(default/etc/pryv/ca, mode 0600); per-issuance temp dir for CSR + node key. EC P-256 keypairs throughout (10y CA / 1y node). 15[CLUSTERCA]tests.Bundle.js—assemble(input)produces the canonical bundle object (version 1);validate(bundle)rejects unknown versions, missing fields, malformed PEM. Pure (no I/O). Shape:{ version, issuedAt, cluster: { domain, ackUrl, joinToken, ca }, node: { id, ip, hosting, url, certPem, keyPem }, platformSecrets: { auth: { adminAccessKey, filesReadTokenSecret } }, rqlite: { raftPort, httpPort } }. 19[BUNDLE]tests.BundleEncryption.js—encrypt/decryptusing AES-256-GCM keyed off scrypt(passphrase, salt). 16-byte salt, 12-byte nonce, 16-byte tag, base64 + ASCII armor (-----BEGIN PRYV BOOTSTRAP BUNDLE-----). Deliberately uses node's built-incryptorather than addingage-encryption— the bundle is only ever consumed bybin/master.js --bootstrap, never manually inspected, and every dep adds supply-chain surface.generatePassphrase()returns 128-bit base64url chunkedAbCd-EfGh-IjKl-MnOpfor operator readability. 22[BUNDLEENC]tests.TokenStore.js— file-based one-time join-token lifecycle on the issuing core. Sha256-hashed at rest ({ "<sha256>": { coreId, issuedAt, expiresAt, consumedAt, consumerIp } }); raw token returned only at mint time. Atomic write (tmp + rename) at mode 0600.mint/verify/consume/listActive/revokeByCoreId/purge. Deliberately NOT in PlatformDB — the token consumer is the same core that issued it (the ack endpoint), so cross-core replication is not needed and we avoid adding methods to the PlatformDB interface + two-engine conformance. 26[TOKENSTORE]tests.DnsRegistration.js—registerNewCore({ platformDB, coreId, ip, url, hosting })calls PlatformDB's existingsetCoreInfo(withavailable:false) +setDnsRecord(coreId, { a:[ip] })+ read-merge-write to appendiptolsc.{domain}(the persistent-DNS API is last-writer-wins per subdomain; we want append).unregisterNewCoreis the symmetric undo, scoped so it only touches state belonging to thiscoreId+ip. Two concurrent bootstrap runs could race onlsc; the CLI surfaces a warning that adding cores is a single-operator action. 19[DNSREG]tests.cliOps.js— orchestratesnewCore/listTokens/revokeTokenforbin/bootstrap.js. Pure: takesplatformDB,caDir,tokensPath,secrets,rqliteports, output path. Owns the rollback (revoke token + unregister core) on any failure after PlatformDB writes. 7[BOOTSTRAPCLI]tests with a fake PlatformDB and tmp dirs.applyBundle.js(consumer side) — decrypts + validates a bundle, writes/etc/pryv/tls/{ca.crt, node.crt, node.key}with correct modes (key 0600), generatesoverride-config.ymlmapping the bundle intocore.{id,url,ip}/dns.domain/dnsLess.isActive:false/auth.{adminAccessKey,filesReadTokenSecret}/storages.engines.rqlite.{raftPort,url,tls.{caFile,certFile,keyFile,verifyClient:true}}.dns+dnsLessare emitted only whenbundle.cluster.domainis non-empty (DNSless variant skips both). Override file is mode 0600 (carries admin key). Exposessha256Fingerprint(certPem)matchingopenssl x509 -fingerprint -sha256output. 6[APPLYBUNDLE]tests.consumer.js(consumer-side driver) — reads bundle from disk, resolves passphrase (passphrasedirect arg orpassphraseFilewith newline-stripping), callsapplyBundle, POSTs ack tobundle.cluster.ackUrlwith TLS pinned to the bundled CA (https.request({ ca, rejectUnauthorized: true })), deletes bundle on 200, throws on non-200 (bundle is kept so the operator can investigate).httpClientinjectable for tests. 7[BOOTSTRAPCONSUMER]tests.ackHandler.js—makeHandler({ tokenStore, platformDB })returns a purereq → { statusCode, body }function. 200 ok flipsavailable:trueand returns a cluster snapshot; 400 missing field; 401 token unknown / expired / already-consumed / coreId-mismatch (single status code, reasons differentiated in body but no oracle for guessing); 404 token verifies but no pre-registration row. Token is consumed even on the 404 path so the operator must mint a new one. 9[ACKHANDLER]tests.
bin/bootstrap.js(new) — argparse + boiler init +cliOps.newCore/listTokens/revokeToken. Pullscore.url/dnsLess.publicUrlfor the ack URL base,auth.adminAccessKey+auth.filesReadTokenSecretfor platform secrets (refuses to ship a bundle if either is still on theREPLACE MEplaceholder),dns.domain, rqliteraftPort+ http port out ofstorages.engines.rqlite.url.bin/master.js— bootstrap mode runs before@pryv/boiler.initso theoverride-config.ymlit writes lands at the highest precedence in boiler's load order (override-config.yml→ env → argv →${NODE_ENV}-config.yml→ extras). Workers (cluster.fork()) skip the bootstrap block entirely.components/api-server/src/routes/system.js—POST /system/admin/cores/ackroute added.checkAuthshort-circuits for this single path so the new core can authenticate via the join token instead of the admin key.config/default-config.yml— addscluster.ca.path(default/etc/pryv/ca) andcluster.tokens.path(default/var/lib/pryv/bootstrap-tokens.json) under the existingcluster:block. Both are PER-CORE — only the issuing core uses them.components/business/src/bootstrap/index.js— barrel exporting all 8 modules.
components/business/test/unit/bootstrap-e2e.test.js[BOOTSTRAPE2E](5 tests) — round-tripscliOps.newCore→consumer.consume→ ack route → PlatformDB state with a realhttp.createServermounting the ack handler and an in-memory PlatformDB shared between issuer and ack endpoint. Cases: happy path (available flips, bundle deleted, token burned), replay (stashed copy fails 401 already-consumed), wrong passphrase (consumer fails before ack POST attempt, token remains active, pre-registration unchanged), expired token (401 expired), revoke-token after issue (401 unknown, pre-registration unwound). Multi-process / real-rqlited e2e (thereg-2core-seq.test.jspattern) is deferred — not blocking the v2.0.0-pre publication.
- Bootstrap unit + e2e suite: 135 cases green across 9 test files.
- Phase 1 (rqlite mTLS argv): 15 cases in
storages/engines/rqlite/test/. - Pre-existing suites unaffected.
Dockerfile: rqlite binary now bundled in the Docker image (/app/var-pryv/rqlite-bin/rqlited).master.jsspawnsrqliteddirectly — previous images lacked the binary. Also removed--omit=optionalsosharpinstalls for the previews worker.bin/master.js: newrqlite.externalmode — whenstorages.engines.rqlite.external: true, master.js skips spawning rqlited and connects to an already-running external instance (multi-core deployments sharing one rqlited on the host).storages/engines/rqlite/src/rqliteProcess.js: newwaitForExternal(url, timeoutMs, log)helper.components/api-server/src/methods/mfa.js: removed redundant internal docstring header.
justfileclean-test-datarecipe updated to droppryv-node-testMongoDB database and wipe the rqlitekeyValuetable, in addition to the SQLite user index + per-user directories it already cleaned.- Rationale: the rqlite-only platform-engine migration made rqlite authoritative, but
clean-test-datawas still cleaning the obsolete legacyvar-pryv/users/platform-wide.dbSQLite file. As a result, full-suite runs on a previously-used workstation inherited staleuser-*entries from rqlite and orphaned account-field rows from MongoDB, which caused theroot-seq.test.js [UA7B] beforeEachintegrity check to fail non-deterministically. - With the fix:
just clean-test-data && just test all→ 1568 / 0 with integrity checks ENABLED. Same forjust test-pg all→ 1543 / 0. NoDISABLE_INTEGRITY_CHECK=1workaround needed on sequential runs anymore. Parallel runs still use the workaround because parallel workers share state across processes.
storages/interfaces/backup/FilesystemBackupWriter.jswriteChunkedJsonlFiles()— compressed-mode chunking check now also fires whenrawSize >= maxChunkSize, not only every 100 items. Small datasets (< 100 items) with aggressivemaxChunkSizepreviously produced a single chunk regardless of target; they now respect the soft limit. Large datasets are unaffected (100-item batch check still dominates; the raw-bytes trigger is a lower bound).- Two tests in
components/business/test/unit/backup/filesystem-writer-reader.test.jswere subtly wrong — they used highly compressible payloads ('Hello world '.repeat(5), short fixed strings) that gzip to almost nothing, so the compressed size never reachedmaxChunkSize. Updated to use non-compressible pseudo-random payloads so the chunking assertions are deterministic. - New regression test
'round-trips a single event larger than maxChunkSize (soft-limit semantics)'documents that an individual oversized item is written to exactly one chunk — chunks cannot split items.
config/plugins/core-identity.jsnow honors an explicitcore.urlYAML override as the highest-priority source forcore:url. Falls back to id+domain derivation, then todnsLess.publicUrl.Platform.js:- New private
#coreUrlCache: Map<coreId, url>populated by_refreshCoreUrlCache()from PlatformDB oninit()and onregisterSelf(). LetscoreIdToUrl()stay synchronous (~10 call sites in api-server) while honoring explicit URLs registered by other cores. registerSelf()now writesurl: this.coreUrl || nullinto the core info row so DNSless multi-core deployments can advertise their externally-correct URL.coreIdToUrl(coreId): cache lookup → derivation from id+domain → self URL fallback. NOTE: cache stays cold for changes made by OTHER cores until the nextinit()— periodic refresh for dynamic cluster membership is a planned follow-up.
- New private
- New middleware
components/middleware/src/checkUserCore.js— wrong-core check on/:username/*. Returns HTTP 421 Misdirected Request with{ error: { id: 'wrong-core', message, coreUrl } }whenplatform.getUserCore(username) !== platform.coreId. No-op in single-core mode and for unknown users (existing 401/404 paths handle them). Test hook_resetPlatformCache()exposed for cross-test isolation. components/middleware/src/index.jsexports the new middleware asmiddleware.checkUserCore.components/api-server/src/routes/root.jsmountsmiddleware.checkUserCoreonPaths.UserRoot + '/*'BEFOREgetAuthandinitContextMiddlewareso wrong-core requests don't pay the cost of user/access loading.- New tests in
components/api-server/test/reg-multicore-seq.test.js: 5 wrong-core middleware tests[MC09A..MC09E](wrong core, right core, unknown user, /reg bypass, single-core no-op) and 3 explicit-URL tests[MC10A..MC10C](cache hit, derivation fallback, end-to-end through middleware).
PlatformDBinterface gainssetDnsRecord(subdomain, records)/getDnsRecord(subdomain)/getAllDnsRecords()/deleteDnsRecord(subdomain). The rqlite backend (storages/engines/rqlite/src/DBrqlite.js) implements them on the existingkeyValuetable using adns-record/{subdomain}key prefix — no schema migration needed.Platform.jsgains delegating methods for the four DNS record operations.DnsServer(components/dns-server/src/DnsServer.js) now loads runtime DNS records from PlatformDB onstart()and refreshes them every 30s by default (platformRefreshIntervalMsconstructor option). YAMLdns.staticEntriesare authoritative — admin runtime entries cannot shadow them.updateStaticEntry()is nowasyncand persists to PlatformDB before updating the in-memory map. NewdeleteStaticEntry()mirror.POST /reg/records(components/api-server/src/routes/reg/records.js) writes to PlatformDB first, then sends an IPC nudge to master so the local DnsServer refreshes immediately. Other cores in a multi-core deployment pick up the change via the periodic refresh.bin/master.jsIPC handler refreshes from PlatformDB ondns:updateRecordsinstead of trusting the IPC payload — single source of truth.- Multi-core impact: ACME challenges and other runtime DNS entries now survive master restart and propagate across all cores via rqlite RAFT replication.
default-config.ymlannotated with# === PER-CORE / PLATFORM-WIDE / BOOTSTRAP / MIXED ===section headers for every block.- New "Configuration model: platform-wide vs per-core" section in
README.mdexplaining the three categories and how multi-core operators must respect the split. Platform.registerSelf()logs a[platform-config-snapshot]line on boot with this core's observed values for known platform-wide keys (dns.domain,integrity.algorithm,versioning.deletionMode,uploads.maxSizeMb) plus a SHA-256 hash ofauth.adminAccessKey. Operators can compare these across core logs to detect drift without the admin key value ever appearing in logs.auth.adminAccessKeyis confirmed YAML-only (BOOTSTRAP, secret, never moves to PlatformDB).- A full PlatformDB-backed
platform_configtable with live drift warnings and per-key migrations is a planned post-v2 follow-up.
Profile.js— per-user MFA state model (content + recovery codes); replaces lodash_.isEmptywith native checkService.js— abstract base for SMS providers. Takes a plainmfaConfigobject (not boiler) for DI-friendly tests. StaticreplaceAll/replaceRecursivelyhelpers (immutable — original mutated input).ChallengeVerifyService.js— two-endpoint SMS provider (external SMS service generates + validates the code)SingleService.js— single-endpoint SMS provider (service-core generates the code + validates locally, templates it into an HTTP call that only delivers the SMS)generateCode.js— drops bluebird, usesnode:util.promisify+node:crypto.randomBytesSessionStore.js— in-memoryMap<mfaToken, {profile, context, _timeout}>with TTL via per-sessionsetTimeout().unref(). Single-core only; multi-core sharing deferred.index.js— barrel withcreateMFAService(mfaConfig)factory andgetMFAService/getMFASessionStoreprocess-wide singleton accessors
- Registers
mfa.activate,mfa.confirm,mfa.challenge,mfa.verify,mfa.deactivate,mfa.recoveron the v2 API. Added tocomponents/audit/src/ApiMethods.js ALL_METHODS(required for registration).mfa.recoveris inWITHOUT_USER_METHODS. - Reads
services.mfaconfig per-invocation (not module load) so tests can inject config dynamically. - Uses
errors.factory.apiUnavailable(HTTP 503) when MFA is disabled server-wide. saveMFAProfileuses theProfilestorage's dot-notation converter shape:{data: {mfa: X}}for set and{data: {mfa: null}}for unset — the converter turns NULL leaves into$unset['data.mfa'].
- Binds the 6 API methods to
POST /:username/mfa/*endpoints activateanddeactivateuseloadAccessMiddleware(personal access token required);confirm/challenge/verifyextractmfaTokenfrom the Authorization header (supports raw token andBearer <token>shapes) and pass it viaparams.mfaToken;recoveris unauthenticated.- New
Paths.MFA = /:username/mfaentry
- New
mfaCheckIfActivestep appended to theauth.loginmethod chain. When MFA is enabled server-wide AND the user hasprofile.private.data.mfaset, it callsmfaService.challenge(), stashes the issued{user, token, apiEndpoint}in the SessionStore under a freshmfaToken, and replaces the response with{mfaToken}— the caller must then callmfa.verifyto release the real token. - MFA disabled OR user has no
profile.mfa→ step is a no-op (login response unchanged).
- New
services.mfablock withmode: disableddefault. SMS endpoints are empty strings;sessions.ttlSeconds: 1800. Existing deployments are fully backwards-compatible.
components/business/test/unit/mfa/— 23 unit tests acrossgenerateCode(2),Profile(3),Service(6),createMFAServicefactory (5),SessionStore(7)components/api-server/test/mfa-seq.test.js— 15 acceptance tests ([MFAA]/[MA*]) covering the full activate→confirm→login→challenge→verify→deactivate→recover lifecycle withnock-mocked SMS endpoints andnock.disableNetConnect()for fast failure on missing mocks- Added
mfato the methods list incomponents/test-helpers/src/helpers-base.jsANDhelpers-c.js(the latter hardcodes the list) - Added
require('api-server/src/methods/mfa')tocomponents/api-server/test/helpers/core-process.js(multi-core tests)
service-mfa's separate HTTP proxy, Dockerfile, runit lane. Repo is archived (final commit adds README pointer to the merge commit).- Copied and dropped:
service-mfa/src/business/pryv/Connection.js(replaced by directuserProfileStorage+usersRepositorycalls), its ownmiddlewares/, its ownerrorsHandling.js(replaced by the coreerrors.factory).
storages.platform.enginedefault flipped fromsqlite→rqlite. rqlite is now the only supported runtime platform engine in v2.- SQLite engine no longer advertises
platformStorage: removed fromstorages/engines/sqlite/manifest.json, droppedcreatePlatformDBexport fromstorages/engines/sqlite/src/index.js, deletedDBsqlite.jsand the[SQPF]SQLite PlatformDB conformance test. SQLite remains in use forbaseStorage,dataStore, andauditStorage. mongodbandpostgresqlengines still shipPlatformDBimplementations for conformance tests, but cannot be selected as the runtime platform engine via config.
bin/master.jsalways spawns and supervises an embeddedrqlited(no engine guard, noexternalflag check). Thestorages.engines.rqlite.externalconfig (previously available) has been removed — master.js owns the rqlited lifecycle in both single- and multi-core mode.- Single-core: rqlited runs as a standalone Raft node.
- Multi-core: rqlited uses DNS discovery on
lsc.{dns.domain}to join peers.
bin/migrate-platform-to-rqlite.jsmoved todev-migrate-v1-v2/migrate-platform-to-rqlite.js. It is no longer needed for in-v2 single→multi-core upgrades (no migration step at all). Retained in the v1→v2 toolkit for the same shape of work, with a header note explaining the rework needed before reuse.
storages/engines/rqlite/scripts/setup— downloadsrqlitedv9.4.5 from GitHub releases (Linux/macOS, amd64/arm64), idempotent, mirrors mongodb patternstorages/engines/rqlite/scripts/start— single-node foreground/background launcher with pidfile and/readyzwaitstorages/engines/rqlite/manifest.json: declaredscripts.setupandscripts.startscripts/setup-dev-env: invokes rqlite setup after mongodb
INSTALL.md: rqlite added to prerequisites; minimal config updated;data/rqlite-data/documentedSINGLE-TO-MULTIPLE.md: rewritten — removed manual rqlite install + data migration steps; new flow is DNS → config → restart → deploy second core (224 → 145 lines)README.md: storage engines table updated (rqlite added,platformremoved from MongoDB/PostgreSQL/SQLite rows)README-DBs.md: rewrote "Platform Wide Shared Storage" section to describe the rqlite-everywhere modelstorages/pluginLoader.js: staleplatform: engine: sqliteexample updated
RestoreOrchestrator._restorePlatform: v1 backups (and any future raw exports) write platform entries as{key, value}straight from the legacy SQLite/MongoDB platform-wide store, butplatformDB.importAllexpects the parsed shape{username, field, value, isUnique}. The orchestrator now bridges both shapes via a newparseRawPlatformEntryhelper, so v1→v2 migrations restore platform data correctly. v2→v2 round-trips still pass entries through unchanged.- Verified live: me-dns1 backup (14 users, 28064 events, 271 streams, 211 accesses, 63 platform records, 23 password hashes) restores cleanly into a fresh v2 instance with rqlite as platform engine — including end-to-end email lookup
pm@perki.com → perki.
storages.engines.rqlite.externalconfig: skip embedded rqlited, connect to external instancepublic-url.jsplugin: generateapi/register/accessservice info for multi-core mode- Config plugin order:
core-identityruns beforepublic-url subdomainToPathmiddleware: skip core's own subdomain in multi-core mode/reg/cores: check shared PlatformDB before local users_index for cross-core lookups
INSTALL.md: standalone HTTPS (backloop.dev / custom certs) + nginx reverse proxy guideSINGLE-TO-MULTIPLE.md: step-by-step single→multi-core upgrade guidebin/migrate-platform-to-rqlite.js: reads users from base storage, populates rqlite platform DB- Dockerfile:
--ignore-scripts+ rebuild native modules, audit syslogactive:falsesupport
- Ensure default account fields for v1 backups (fixes "Unknown user" after migration)
- Engine-agnostic backup sanitize:
streamId/profileId→id
FilesystemBackupWritermaxChunkSizenow applies to the compressed output size (was uncompressed)bin/backup.jsaccepts--target-file-size <MB>as alias for--max-chunk-size- Soft limit (~10% overshoot acceptable) — checks compressed size every 100 items
- Engine-agnostic backup: JSONL+gzip format, chunked events/audit, flat attachments by fileId
BackupWriter/BackupReaderinterfaces with filesystem implementation- Data sanitization: strips
_id/__v/userId, promotes_idtoid(except streams) BackupOrchestrator: snapshot consistency (snapshotBefore),--incrementalmode (auto-detects per-user timestamps)RestoreOrchestrator: conflict detection,--skip-conflicts,--overwrite,--verify-integritywith rollback- Series data export/import via engine
exportDatabase()/importDatabase() - Overwrite protection on backup (requires
--incrementalto write to existing path)
- Per-user integrity checking: recomputes hashes on events and accesses
- Reusable from CLI and from restore (
--verify-integrity)
bin/backup.js: full backup/restore CLI (all-users, single-user, incremental, compressed)bin/integrity-check.js: standalone per-user integrity verification (--user,--json, exit code 0/1)
- Added
exportAll,importAll,clearAlltoUserStreamsandUserEventsinterfaces
- 22 unit tests: sanitize, filesystem round-trip, chunking, attachments (single/multi/1MB binary), multi-user, unicode
- V8-native coverage via
NODE_V8_COVERAGE+c8 report(replaces NYC) collect.js: bypassescomponents-run, runs mocha from project root vianodedirectlypg-early-init.js: fixes barrel init race — injects PG config beforeglobal.test.jslocks to MongoDBrun.sh: orchestrates 3-engine coverage (MongoDB + PG + SQLite), merged HTML report- Coverage baseline: 80% statements, 83% branches, 77% functions
- Previews-server:
DynamicInstanceManager(random port), fixed cache cleanup config key, asyncgetFiles(), test assertion fix — 15/0 (was process crash) - WebhooksService: null guard in
activateWebhook()for PG mode - Consolidated duplicate
encryption.js— engines now userequire('utils').encryption
- 6 dead files: JSDoc-only barrels, unused Transform stream, old DeletionModesFields location, serializer shim
- 17 dead test data files: followedSlices, migrated data (0.3.0–0.5.0), structure versions (0.7.1–1.7.0)
- Dead functions:
findStreamed/findDeletionsStreamedstubs (MongoDB + PG),stateToDB,LocalTransaction.commit/rollback,Database.findStreamed,hasStreamPermissions,User.getEvents/getUniqueFields,MallUserEvents.getStreamed,storage.getDatabase/getDatabasePG,pluginLoader.getConfigFor
- PostgreSQL now implements all 5 storage types: baseStorage, dataStore, platformStorage, seriesStorage, auditStorage
- Series storage on PG replaces InfluxDB with batch INSERT optimization (up to 5000 rows per statement)
- Audit storage on PG replaces SQLite (optional — SQLite recommended for performance)
- PG integrity checking works out of the box —
DISABLE_INTEGRITY_CHECKremoved from test recipes
- PG +8.7% avg throughput vs MongoDB+InfluxDB (12/18 benchmarks faster)
- Batch INSERT for series writes: single-row INSERT → multi-row VALUES (batch10: 2x faster than InfluxDB)
- Composite index on
event_streams(user_id, stream_id, event_id)for stream-parent queries - 5 new PG indexes: events (trashed, modified, head_id, end_time), streams (trashed)
- Serialized
DatabasePG.ensureConnect()with promise guard — fixespg_type_typname_nsp_indexrace condition when multiple callers initialize concurrently - Schema DDL runs exactly once via
_initSchemaOnce()with_schemaReadyflag connectedflag set only after schema is ready (prevents queries against missing tables)- Dedicated audit connection pool (default 5 connections) — no longer contends with main pool (default 20)
- 49 PG engine tests: schema conformance, series CRUD, PlatformDB, audit conformance
- lib-js integration tests pass in PG mode (169 passing)
storages.engines.postgresql.auditPoolSize— configurable audit pool size (default 5)justfile: removedDISABLE_INTEGRITY_CHECK=1fromtest-pgandtest-pg-parallelrecipes
- Reusable performance test suite — measures throughput, latency, resource usage
- 7 scenarios: events-create, events-get (no-filter/stream-parent/time-range × master/restricted auth), streams-create (flat+nested), streams-update, series-write (batch 10/100/1000), series-read (1K/10K points), mixed-workload
- Two seed profiles based on real accounts: "manual" (perki.pryv.me, 100 streams) and "iot" (demo.datasafe.dev, 50 streams)
- Resource monitoring: tracks master + worker PIDs, aggregated RSS/CPU in results
- Concurrency sweep mode:
--sweep 1,5,10,25,50produces comparison tables - Results stored as JSON + markdown with system info, server config, latency percentiles (p50/p95/p99)
- Helper scripts:
perf-clean,perf-seed,perf-run,perf-full - Comparison tool:
bin/compare.jsfor side-by-side result analysis
- Unified config at
config/(merged from per-component configs) - Storage config restructured:
storages.{base,platform,series,file,audit}.engine+storages.engines.<name> - PlatformDB
setUserUniqueFieldIfNotExists()atomic method (all 4 backends) - rqlite engine (
storages/engines/rqlite/) for distributed PlatformDB - PlatformDB invitation token methods (SQLite + rqlite): create, get, getAll, update, delete
- Registration pipeline simplified:
validateOnPlatform → createUser → buildResponse Platform.js: removed service-register HTTP client, addedvalidateRegistration()with invitation tokens, reserved words, atomic unique field reservation- Invitation tokens now stored in PlatformDB; config
invitationTokensseeds on first boot; tokens consumed on registration - Deleted
service_register.js,reserved-words.json(124K words) copied to platform component repository.js: renamedupdateUserAndForward→updateUser, removedskipFowardToRegisterparameter- Removed
testsSkipForwardToRegisterconfig key,isDnsLessconditionals from registration logic - Register routes always loaded (no
isDnsLessguard)
- Core identity model:
core.id→ FQDN, self-registration in PlatformDB - rqlite process management in master.js (spawn, readyz wait, graceful shutdown,
-http-adv-addr) - PlatformDB user-to-core mapping, core registration, load-balanced core selection
- DNS discovery for rqlite cluster peers via
lsc.{domain}
components/dns-server/— dns2-based DNS server for{username}.{domain}resolution- Supports A, AAAA, CNAME, MX, NS, SOA, TXT, CAA record types
- Master.js integration: start/stop, IPC handler for worker-driven record updates
POST /reg/recordsadmin endpoint for runtime DNS entry updates
routes/reg/legacy.js— backward-compatible service-register endpoints- Email→username lookup, server discovery (redirect + JSON), admin servers, admin invitations
- 17 multi-core acceptance tests (
reg-multicore-seq.test.js) - 9 two-core integration tests (
reg-2core-seq.test.js) — real rqlite + 2 child processes + DNS - 19 DNS server unit tests (
dns-server.test.js) —dns.promises.Resolver+ raw dgram - 16 gap feature tests (
reg-gap-features-seq.test.js) - 16 legacy route + invitation tests (
reg-legacy-seq.test.js) core-process.js— child process boot script for integration tests- Removed all nock mocking for service-register
- Replaced
gm(GraphicsMagick wrapper, requires system binary) withsharp(npm-native, bundles libvips) - Removed
apt-get install graphicsmagickfrom Dockerfile — no system image dependencies for previews - Removed GM availability check from
master.js— previews worker always starts when enabled - Removed
bluebirdusage from event-previews.js (sharp is Promise-native)
- Added
components/externals/— runs lib-js test suite (169 tests) viajust test externals - HTTPS proxy (backloop.dev) routes API (:3001) and HFS (:4000) through single endpoint (:3000)
public-url.js: preserveservice.assetsandservice.featuresfrom config in dnsLess mode- Added
libjs-test-config.ymlfor dnsLess + HTTPS + pryv.me assets configuration - Excluded
external-ressources/from eslint and source-licenser - New lib-js tests: Streams CRUD, Accesses CRUD, Account/Password (contributed back to lib-js)
- Inlined metadata updater into HFS server — removed TChannel RPC,
metadataandtprpccomponents,tchannel/protobufjsdependencies - Moved webhooks service in-process within API server — removed separate webhooks container and
build/webhooks/(Dockerfile + runit)
- Created
bin/master.js— single master process using Node.js cluster module - TCP pub/sub broker runs in master; workers connect as clients
- N API workers share port :3000 via cluster (config:
cluster:apiWorkers, default 2) - M HFS workers share port :4000 via cluster (config:
cluster:hfsWorkers, default 1, 0 = disabled) - Workers auto-restart on crash; graceful shutdown on SIGTERM/SIGINT
- Worker log differentiation via
PRYV_BOILER_SUFFIX(-wN,-hfsN)
- Master forks 0 or 1 previews worker on port :3001 (config:
cluster:previewsWorker, default true) - GraphicsMagick availability check at startup — gracefully skips if GM not installed
- Replaced 3 per-component Docker images (core, hfs, preview) with a single image
- Entry point:
node bin/master.js— replaces runit-based orchestration - DB migrations run in master before forking workers (config:
cluster:runMigrations, default true) - Removed:
build/core/,build/hfs/,build/preview/(Dockerfiles + runit scripts),Dockerfile.component-intermediate,Dockerfile.common-intermediate - GraphicsMagick installed in unified image for previews support
- Socket.IO uses WebSocket-only transport in cluster mode (no HTTP long-polling)
- Avoids need for sticky sessions — WebSocket connections are long-lived and stay on one worker
- Single-process mode (tests, dev) retains long-polling fallback
- Removed
openSource:isActiveconfig flag and all gated code — features always enabled: webhooks, HFS/series events, distributed cache sync, email check route - Removed
isOpenSourcefields and constructor logic from Application, Server, Manager, NamespaceContext classes - Removed
openSourceSettingsconfig reads and conditional series creation/integrity/notification logic in events.js - Changed
isSynchroActivefrom!config.get('openSource:isActive')totruein cache - Removed early-return gate on HFS deletion in business/auth/deletion.js
- Removed
openSource:config sections from 5 config files (default, development, test, hfs-server, build/test) - Removed all test skips and conditional logic based on
openSource(12 test files) - Deleted dead code:
www/registerpackage requires in application.js that would crash if ever reached - Cleaned up unused imports across all modified files
- Extended
UserAccountStoragewith account field CRUD methods (getAccountFields,setAccountField,deleteAccountField,getAccountFieldHistory) - Created
accountStoreadapter implementing pryv-datastore interface, wrapping baseStorage account operations - Registered
accountStorein Mall alongside local + audit stores - Changed
storeDataUtils.jsrouting::_system:/:system:prefixes →accountstore (waslocal) - Removed system stream merge from Mall — handled by store routing
- Added migration 1.9.4: copies account events from local store to account-field storage
- Removed
forbidSystemStreamsActions()from streams.js — account store handles rejection - Removed
filterNonePermissionsOnSystemStreams()from utility.js — standard permissions apply - Removed 11 dead serializer methods + 5 static properties
- Removed
ForbiddenAccountStreamsModificationerror constant - Removed pre-1.9.0 migrations (1.7.0, 1.7.1, 1.8.0) + their test files
- Removed debug
console.log('XXXXX')traps from User.js
- Removed redundant
isAccountStreamIdhard block from AccessLogic —includedInStarPermissionshandles it - Simplified
noneprepend from serializer iterator to singleSTREAM_ID_ACCOUNTconstant - Replaced permission-based account exclusion in eventsGetUtils with direct config-based exclusion
- Created
systemStreamFilters.jsin test-helpers for test-only prefix helpers - Removed redundant
init()calls from hfs-server tests
- Removed
:_system:helpersstream (parent ofactive/uniquemarkers) - Account events: one event per field, no sibling demotion
- Platform coordination moved to events.js middleware
- Default event queries include both local and account stores
- Account store returns
structuredClone()to prevent readableTree mutation
- Flattened SystemStreamsSerializer class to plain eager-init module
- Dropped lodash dependency
- Migrated all 16 production callers to direct data access
- Removed all dead getter functions and helpers
- 639 → 154 lines (76% reduction), 20+ → 13 exports
- Extracted feature constants to
business/src/system-streams/features.js, then inlined as plain strings - Renamed:
SystemStreamsSerializer→accountStreams,forbiddenStreamIds→hiddenStreamIds,removePrefixFromStreamId→toFieldName,addCorrectPrefixToAccountStreamId→toStreamId,indexedFieldsWithoutPrefix→indexedFieldNames,uniqueFieldsWithoutPrefix→uniqueFieldNames - Deleted
serializer.js— content moved tosystem-streams/index.js
- Renamed all internal
nats/NATSvariable names, function names, comments, and config references to generictransport/Transportterms NATS_MODE_ALL/KEY/NONE→TRANSPORT_MODE_ALL/KEY/NONE(backward compat aliases kept)initNats()→initTransport(),isNatsEnabled()→isTransportEnabled(),setTestNatsDeliverHook()→setTestDeliverHook()- Removed dead code:
NATS_CONNECTION_URIin webhooks,axonMessagingexport alias,nats:uricompat fallback - Removed NATS references from CI, README, .gitignore, .dockerignore, .licenser.yml
- Replaced NATS server +
natsnpm package with zero-dependency TCP pub/sub broker using Node.jsnetmodule - Created
tcp_pubsub.js: embedded broker/client — first process binds port, others connect as clients - Protocol: newline-delimited JSON over TCP with noEcho (sender exclusion via client IDs)
- Updated 6 config files:
nats:uri→tcpBroker:port - Rewrote 3 test files to use raw TCP clients instead of NATS client library
- Removed NATS from Docker build (Dockerfile, runit/gnats, start-core wait loop)
- Removed
nats-server/binary directory andscripts/setup-nats-server - Removed
natsnpm dependency - No changes to PubSub class, constants, or any consumer code
- Replaced axon TCP pub/sub with Node.js built-in IPC for test notification forwarding
- Created
test_messaging.js(IPC-based EventEmitter +process.send()), deletedaxon_messaging.js - Updated InstanceManager, DynamicInstanceManager, spawner.js to use IPC instead of axon TCP sockets
- Renamed
axon-*message names totest-*across all test files (~20 files) - Renamed
axonMsgs/axonSocketvariables totestMsgs/testNotifier - Removed
axonnpm dependency andaxonMessagingconfig sections - No changes to production messaging (NATS) or caching
- Removed FerretDB feature entirely —
ferretDB/directory,test-ferretjustfile recipe,isFerretconfig/property, FerretDB connection string,ferretIndexAndOptionsAdaptationsIfNeeded(), FerretDB duplicate error handling, FerretDB test guards - Fixed bug in
Database.isDuplicateError(): FerretDB branch had missingreturn, causing all errors to be reported as duplicates - Cleaned: Database.js, localDataStore.js, accesses.js, accesses-personal.test.js, result-chunk-streaming-seq.test.js, database-seq.test.js, 4 migration test files, README-DBs.md
- Replaced
produceInfluxConnection()with asyncproduceSeriesConnection()factory in hfs-server and api-server test helpers - Added
getTimeDelta()helper to normalize time values across InfluxDB (INanoDate) and PG (numeric) - Renamed
produceMongoConnection→produceStorageConnectionglobally across 18 test files - Updated
store_data.test.js,batch.test.js,deletion-seq.test.jsto use engine-agnostic series queries/assertions
- Fixed
pg_connection.jsquery(): changeddelta_time * 1000→delta_time / 1e6(delta_time stores nanoseconds viaInfluxDateType.coerce, not seconds) - Fixed field ordering:
timeplaced before JSONB fields to match InfluxDB column order - Fixed
exportDatabase()andimportDatabase()for consistent nanosecond↔millisecond conversion
deletion.jsdeleteHFData()now dispatches to PG or InfluxDB based on storage engine (was hardcoded to InfluxDB)
- Separated caching disable (
MOCHA_PARALLEL=1) from integrity check disable (DISABLE_INTEGRITY_CHECK=1) inhelpers-base.jsandhelpers-c.js— fixes cache tests failing underjust test-pg - Fixed
Webhook.test.jsuser object to includeidproperty — PG requires non-NULLuser_idfor SQL equality comparisons (MongoDB was tolerant ofundefinedvia collection naming)
- Removed FollowedSlices feature entirely (storage backends, API methods, routes, schema, tests, audit, pubsub, deletion cascade)
- Deleted:
FollowedSlices.js,FollowedSlicesPG.js,followedSlices.js(methods),followed-slices.js(routes), schema files, test file - Cleaned references from: StorageLayer, storage/index, server.js, application.js, Paths.js, constants.js, pubsub.js, ApiMethods.js, deletion.js, AccessLogic.js, databaseFixture.js, dependencies.js, dynData.js, data.js, helpers-c.js, helpers-base.js, validation.js, usersLocalIndex.js, DatabasePG schema, test list files
dependencies.js— always reconfigures viaStorageLayer(removedSTORAGE_ENGINEguard)parallelTestHelper.js— always usesstorage.getStorageLayer()instead of engine switchtest-helpers.js—produceConnection()always returnsStorageLayerprofile-personal.test.js— usesstorageLayer.profileinstead of engine switch- Removed all
// CLAUDE:marker comments
application.js— removed unconditionalstorage.getDatabase()callcontext.js— removedmongoConnproperty; constructor no longer requires database connectionmetadata_cache.js—MetadataLoader.init()no longer takesdatabaseConnparam (was unused)
integrity-final-check.js— early return for non-MongoDB engines (uses raw MongoDB cursors)
SessionsPG— callback-based sessions with JSONB containment forgetMatchingPasswordResetRequestsPG— callback-based password reset with expiration on readVersionsPG— async versions with migration runner
BaseStoragePG— base class providing full UserStorage interface with SQL query building: camelCase↔snake_case mapping, JSONB serialization, MongoDB-style query→SQL translation ($gt, $ne, $in, $or, $type, $set, $unset, $inc, $min, $max, JSONB dot-notation)AccessesPG— token generation, integrity hash, integrity-aware delete/updateOneWebhooksPG— aggressive field unsetting on soft-deleteProfilePG— JSONB data with key-value set updatesStreamsPG— tree build/flatten via treeUtils, cache invalidation
userAccountStoragePG— password history, key-value store (StoreKeyValueData)usersLocalIndexPG— username↔userId mappingDBpostgresql— platform unique/indexed fields
localDataStorePG— DataStore factory implementing @pryv/datastore interfacelocalUserEventsPG— full events API with junction tableevent_streamsfor stream queries, intermediate query format→SQL conversion, streaming supportlocalUserStreamsPG— streams API with tree building, cache integrationLocalTransactionPG— PG transaction wrapper
pg_connection.js— implements InfluxConnection interface usingseries_datatable- Handles writeMeasurement, writePoints, simplified InfluxQL→SQL query parsing
- Full migration support (exportDatabase, importDatabase)
- Added
stream_ids JSONBcolumn to events table for denormalized reads
StorageLayer._initPostgreSQLinstantiates all PG backends- All routing points wired: StorageLayer, index.js, mall, platform, hfs-server
- All PG backend TODOs resolved
- Added
storageEngineconfig key ('mongodb' | 'sqlite' | 'postgresql') todefault-config.yml - When set, overrides all per-component keys (
database:engine,storageUserAccount:engine, etc.) - Falls back to per-component keys when absent (full backward compatibility)
- Added
postgresqlconnection config block (host, port, database, user, password, max)
- New
storage/src/getStorageEngine.js— unified engine resolution with validation - Used by all routing points: StorageLayer, index.js, mall, platform, hfs-server
- New
storage/src/DatabasePG.js— connection pooling viapg, schema DDL for all tables - Methods:
ensureConnect(),waitForConnection(),query(),getClient(),withTransaction(),initSchema(),close() - Full schema: streams, events, event_streams, accesses, webhooks, profile, sessions, password_resets, versions, passwords, store_key_values, users_index, platform tables, series_data
- Static helpers:
isDuplicateError(),handleDuplicateError()(mirrors MongoDB pattern)
StorageLayer.js— refactored to dispatch to_initMongoDB/_initSQLite/_initPostgreSQLstorage/src/index.js— engine routing forgetUserAccountStorage(),getStorageLayer(); exportsDatabasePG,getDatabasePG,getStorageEngineusersLocalIndex.js— engine routing viagetStorageEnginemall/src/index.js— datastore selection by engineplatform/src/getPlatformDB.js— PlatformDB selection by enginehfs-server/src/application.js— series connection selection by engine
- Added
pg(node-postgres) to root dependencies
- Replaced
dropCollection()withremoveAll()inbusiness/src/auth/deletion.js(interface compliance)
- Renamed 3 sequential files to parallel:
webhooks,acceptance/accesses,login-parallel - Evaluated 5 additional candidates; confirmed they must stay sequential (
getApplication()shared state)
- Extracted
permissions-seq.test.jssections AP01, AP02, YE49 → newpermissions.test.js(Pattern C, parallel-safe) - Removed 19 duplicate tests from
events-seq.test.js(covered byevents-patternc.test.js) - Removed 18+5 duplicate tests from
streams-seq.test.js(covered bystreams-patternc.test.js) - Added defensive assertions to
events-mutiple-streamIds.test.jsfor parallel-mode debugging
- Parallel pool: 13 → 17 files (+4)
- Sequential: 21 → 13 files
- 58 duplicate tests removed, 0 coverage lost
- New
UserStorageinterface withvalidateUserStorage()instorage/src/interfaces/ - Validates all BaseStorage subclasses: Accesses, Profile, FollowedSlices, Streams, Webhooks
- Added migration methods (
exportAll,importAll,clearAll) toBaseStorage - Conformance test suite covering full CRUD + migration lifecycle
- StorageLayer validates all user-scoped storages at construction time
- Sessions:
validateSessions()interface, migration methods (exportAll,importAll) - PasswordResetRequests:
validatePasswordResetRequests()interface, migration methods (exportAll,importAll) - Versions:
validateVersions()interface, migration methods (exportAll,importAll) - Conformance test suites for all three
- StorageLayer validates all global storages at construction time
- New interface prototype +
createUserAccountStorage()factory instorage/src/interfaces/ - Wrapped Mongo and SQLite implementations with factory
- Added standardized migration methods:
_exportAll,_importAll,_clearAll - Conformance test suite; existing unit test now delegates to it
- New interface prototype +
validateUsersLocalIndexDB()validation instorage/src/interfaces/ - Added migration methods (
exportAll,importAll,clearAll) to Mongo and SQLite classes - Validation called after construction in
usersLocalIndex.js
- New interface prototype +
validatePlatformDB()validation inplatform/src/interfaces/ - Added migration methods (
exportAll,importAll,clearAll) to Mongo and SQLite classes - Validation called after construction in
getPlatformDB.js
- New interface prototype +
createEventFiles()factory +validateEventFiles()instorage/src/interfaces/ - Validation called after construction in
getEventFiles.js
- New
InfluxConnectioninterface withvalidateInfluxConnection()inbusiness/src/interfaces/ - Added migration methods (
exportDatabase,importDatabase) toInfluxConnection - Conformance test suite [IC01]-[IC09] covering full lifecycle
- Exported via
business.series.interfaces.InfluxConnection
- New
UserSQLiteStorageinterface withvalidateUserSQLiteStorage()instorage/src/interfaces/ - New
UserSQLiteDatabaseinterface withvalidateUserSQLiteDatabase()instorage/src/interfaces/ - Added migration methods (
exportAllEvents,importAllEvents) toUserDatabase - Conformance test suite [SQ01]-[SQ16] covering Storage manager + Database contract
- Exported via
storage.interfaces.UserSQLiteStorageandstorage.interfaces.UserSQLiteDatabase
storage/src/index.jsexports all interfaces underinterfaceskey- Migration scripts (
switchSqliteMongo/) simplified using standardizedexportAll/importAll/clearAll
- Removed commented debug code in
components/audit/src/Audit.js - Removed unused
factory.periodsOverlaperror andErrorIds.PeriodsOverlap
- Removed
backwardCompatibility.systemStreams.prefixconfig from all config files - Removed
isStreamIdPrefixBackwardCompatibilityActivevariable and all guarded code inevents.js,streams.js,accesses.js,eventsGetUtils.js - Removed prefix conversion functions from
backwardCompatibility.js - Deleted
ChangeStreamIdPrefixStream.js - Removed
disableBackwardCompatibilityproperty anddisable-backward-compatibility-prefixheader fromMethodContext.js - Removed
PATTERN_C_BACKWARD_COMPATenv var handling from test helpers - Removed backward compatibility collision check in system streams config
- Removed prefix-related tests (BW08-BW16, SD02)
- Removed deprecated
POST /register/create-userroute fromsystem.js - Removed backward-compatibility test
[ZG1L] passwordHashparameter kept (still used by standardPOST /system/create-user)
- Removed
streamIdproperty from event JSON schema (event.js) - Changed schema validation from
anyOf(streamId or streamIds) torequired: ['type', 'streamIds'] - Simplified
normalizeStreamIdAndStreamIdsinevents.js— removedBOTH_STREAMID_STREAMIDS_ERRORand allevent.streamId = event.streamIds[0]assignments - Deleted
SetSingleStreamIdStream.js(no longer needed to addstreamIdto output) - Removed
SetSingleStreamIdStreampipe fromeventsGetUtils.js - Removed
event.streamIdassignment fromSetFileReadTokenStream.js - Updated tests across api-server and webhooks components
- Deleted
backwardCompatibility.js,AddTagsStream.js - Removed
backwardCompatibility.tagsfrom all config files - Removed all tag conversion logic from
events.js(replaceTagsWithStreamIds, putOldTags, createStreamsForTagsIfNeeded, cleanupEventTags, migrateTagsToStreamQueries) - Removed tag permission methods from
AccessLogic.js - Simplified permission checks: removed WithTags variants, callers use stream-only methods
- Removed
tagsfrom event schema and events.get query params - Removed tag migration code from
1.7.0.js - Fixed
previews-server/event-previews.jsto usecanGetEventsOnStreaminstead of removedcanGetEventsOnStreamAndWithTags - Deleted tag backward compatibility tests, updated all test files
- Removed
permissions-tags.test.jsfrom test lists
- Removed deprecated
/service/infosroute duplicate - Cleaned stale deprecated JSDoc from Event typedef (removed streamId, tags)
- Fixed typo:
newSreamIds→newStreamIdsin events.js - Fixed double
awaitin repository.js - Updated stale TODO comments about system.createUser