Welcome, agent. This file is a fast-orientation guide so you don't have to rediscover the repo the hard way. It complements README.md, INSTALL.md, README-DBs.md, CHANGELOG-v2.md, and SINGLE-TO-MULTIPLE.md; read those for depth; read this one first for shape.
open-pryv.io is the v2 codebase of Pryv.io, a personal-data REST/WebSocket platform. It is a single Node.js codebase that produces a single binary (bin/master.js) and a single Docker image (pryvio/open-pryv.io). Registration, MFA, API, high-frequency series, previews, and the email renderer all run inside that binary as cluster workers.
Some sibling projects live in separate repos and are consumed as dependencies or downloaded as artifacts at deploy time; they are not bundled here:
pryv/lib-js: thepryvnpm package (JS client lib).pryv/pryv-datastore: the datastore interface that custom datastore plugins implement.pryv/app-web-user-account: the React auth/register/password-reset web app (current reference; replaces the deprecatedapp-web-auth3).
The v1 line (pre-single-binary) is preserved on the release/1.9.3 branch and is no longer updated.
bin/ Entry points and admin CLIs
master.js Supervisor, forks cluster workers, runs rqlited, AcmeOrchestrator
bootstrap.js Multi-core onboarding (issue/consume sealed bundle)
backup.js Engine-agnostic backup + restore (JSONL + gzip)
migrate.js Schema migration runner (status / up)
dns-records.js Persistent DNS record admin
migrate-platform.js Move platform data between rqlite and postgresql
check-config.js / config-to-env.js Validate a config / convert it to an env file
integrity-check.js Per-user integrity verification + platform DB check (--platform)
mail.js Mail template admin (in-process email)
observability.js Optional APM admin (enable / disable / set-license-key)
components/ Source code split by domain
api-server/ HTTP REST API (Express), in-process dispatchers (hosted sites, HFS)
hfs-server/ High-frequency series ingest
previews-server/ Image preview renderer
dns-server/ Embedded DNS server for multi-core DNS-based topology
mail/ In-process email renderer (Pug templates in PlatformDB)
templates/ bundled Pug set seeded on first boot
middleware/ Express middleware (auth, wrong-core, regSubdomainPathMap, ...)
mall/ Data access layer (events, streams, engine-agnostic)
cache/ Caching layer
platform/ PlatformDB interface + config-snapshot hash drift
storage/ Storage facade, uses the engines selected in config
audit/ Audit logging (uses SQLite directly)
business/ Cross-domain logic:
accesses/ acme/ auth/ backup/ bootstrap/ integrity/
emails/ multi-email container, verification-mail policy, registration email challenge
mfa/ notifications/ observability/ series/ system-streams/ users/ webhooks/
webhooks/ Outbound event delivery (optional named `scopes` filter)
cmc/ Cross-account messaging & consent (namespace plugin, `:_cmc:`)
shared-secrets/ One-time secret hand-off by key (namespace plugin, `:_shared-secrets:`)
errors/ messages/ test-helpers/ tracing/ utils/ externals/
storages/ Plugin tree for storage engines (npm workspace)
interfaces/
baseStorage/ dataStore/ platformStorage/ fileStorage/
seriesStorage/ auditStorage/ backup/ migrations/
engines/
postgresql/ baseStorage, dataStore, seriesStorage, auditStorage, platformStorage, fileStorage (low-volume attachments)
sqlite/ per-user SQLite (baseStorage, dataStore, seriesStorage, auditStorage)
rqlite/ distributed SQLite (platformStorage default; required multi-core)
filesystem/ attachments + previews on disk (fileStorage)
s3/ attachments on S3-compatible object stores (fileStorage)
influxdb/ high-frequency series (seriesStorage)
datastores/ Custom datastore plugins (e.g. `account` for system streams)
manifest-schema.js Schema for engine manifests
pluginLoader.js Reads `storages.<type>.engine` + loads the chosen engine
config/ default-config.yml + plugins
Dockerfile Canonical container (bundles rqlited, sharp)
test/ Integration test entry, see `just test …` in justfile
Prerequisites: Node.js 24.x below 24.19.0 (see INSTALL.md: later 24.x releases abort on SQLite), PostgreSQL 14+ (or SQLite, bundled), just.
just setup-dev-env # prepares var-pryv/ layout + launches PG/rqlite binaries
just install # npm install across workspaces
just start-master # boot the single-binary clusterTests (from repo root, via justfile):
just test all # full suite, PostgreSQL baseStorage (default)
just test <component> # single component, PostgreSQL baseStorage
just test-sqlite all # full suite, SQLite baseStorage
just test-sqlite <component> # single component, SQLite baseStorage
just test-pg-influx <component> # PostgreSQL base + InfluxDB series (needs a running influxd)
just test-sqlite-influx <component> # SQLite base + InfluxDB series (needs a running influxd)
just test-backup-roundtrip # bin/backup.js across engines, PG -> SQLite -> PG -> SQLite
just clean-test-data # reset test DBs + per-user dirsThe series engine defaults to the base engine; storages__series__engine=<engine> overrides it
in any recipe (the influx recipes set it). test-backup-roundtrip seeds a fixture covering every
backed-up collection (events, streams, accesses, profile, webhook, attachment, HF series), then
compares the four bundles on counts and content; run it before a release and after any change to
components/business/src/backup/ or to an engine's exportAll / importAll /
exportDatabase / importDatabase. It cleans the local test data on every leg.
Production-ish single node:
NODE_ENV=production node bin/master.js --config /path/to/your/override-config.ymlNODE_ENV=test short-circuits optional integrations (observability providers, strict startup checks). Always honour it in new code; tests must stay hermetic.
-
bin/master.jsowns the lifecycle. It:- Runs a minimal
@pryv/boilerinit to read config, - Spawns and supervises an embedded
rqlitedin both single- and multi-core mode (no separate DB process to manage; skipped entirely whenstorages.platform.engine: postgresql, the single-core diskless shape), - Forks cluster workers:
cluster.apiWorkers(default 2) API workers,cluster.hfsWorkersHFS workers,cluster.previewsWorkerpreviews worker, - On the CA-holder core only, runs the AcmeOrchestrator that polls PlatformDB for cert state (other cores poll + materialize),
- Handles the
--bootstrapmode used to add new cores (decrypts a sealed bundle, writesoverride-config.yml+ TLS files, acks the issuer, falls through into normal startup).
Don't add a PM2 / systemd / Docker-compose-style process supervisor around it. master.js is the supervisor.
- Runs a minimal
-
TLS termination is native. api-server workers call
https.createServer(buildHttpsOptions(config), requestHandler)andsetSecureContext()for zero-downtime hot-swap on cert renewal; a certificate outside the local clock's validity window is neither materialized (business/src/acme/FileMaterializer.ts) nor hot-swapped (Server.reloadTls()), tolerancecluster.clockSkewSeconds. TherequestHandleris a chain of in-process dispatchers,sites -> hfs -> express:components/api-server/src/hostedSitesIngress.tsanswers the configuredhostedSites(Host<name>.<dns.domain>, or the/<name>/prefix in dnsLess) without touching the API middleware; thencomponents/api-server/src/hfsIngress.tsroutes^/<user>/events/<id>/seriesand^/<user>/series/batchto the HFS worker onlocalhost:4000; everything else falls through toapp.expressApp. This is the quick / out-of-the-box ingress; for high-throughput installs, front with nginx (docs/nginx-ingress-sample.conf) and let it do the routing instead; the in-process dispatcher stays present but is bypassed because external traffic doesn't reach it.If you must front-proxy, wire the front's reload into
letsEncrypt.onRotateScriptin config so the front picks up new certs within the same renewal cycle. -
Wildcard certs are first-class.
components/business/src/acme/deriveHostnames.jsreturns{ commonName, altNames, challenge: 'dns-01' | 'http-01' }from existing topology config:Config Hosts Challenge dnsLess.isActive: true+dnsLess.publicUrlthe URL's hostname http-01dns.active: true+dns.domain: XX+*.Xdns-01core.url: https://Y/(DNSless multi-core)Yhttp-01The embedded DNS server answers
_acme-challenge.Xtransiently during DNS-01; you don't need to integrate certbot or a third-party DNS API. It serves UDP and TCP ondns.portand answers RFC 2308 negative responses (the apex SOA in AUTHORITY on NXDOMAIN / NODATA; REFUSED for names outsidedns.domain). -
Storage engines are pluggable at runtime. Engine choice is per-core. The config keys in
config/default-config.ymlare:storages: base: { engine: postgresql } # baseStorage + dataStore, or sqlite platform: { engine: rqlite } # platformStorage, or postgresql (single-core dnsLess only) series: { engine: postgresql } # seriesStorage, or sqlite, or influxdb file: { engine: filesystem } # fileStorage, or s3, or postgresql (low-volume attachments) audit: { engine: sqlite } # auditStorage, or postgresql engines: postgresql: { host: 127.0.0.1, port: 5432, database: pryv-node, user: pryv, password: '', max: 20 } # sqlite, rqlite, influxdb, filesystem, s3 also configurable here
The
pluginLoaderreadsstorages/engines/<name>/manifest.jsonto see whichstorageTypeseach engine provides. From code:const engine = pluginLoader.getEngineModule(pluginLoader.getEngineFor('platformStorage'));
Adding an engine = new directory under
storages/engines/with amanifest.json+src/index.js. Don't reinvent; the plugin pattern exists.PostgreSQL is a first-class production engine for every
storageType.platformdefaults to rqlite (Raft consensus is what makes multi-core work); single-core dnsLess deployments in full PG mode may select postgresql instead, the diskless shape, withstorages.file.engine: s3for attachments andbin/migrate-platform.jsto move platform data when the topology changes. SeeREADME-DBs.mdfor the human-readable DB layout. -
Cluster CA lifecycle. The first
master.jsboot on a fresh box mints a self-signed cluster CA under/etc/pryv/ca/. Back that directory up immediately; the private key never leaves the host, and losing it means you can't add or rotate cores.bin/bootstrap.js new-core --id <name> --ip <ip>issues a sealed AES-256-GCM-encrypted bundle + one-time join token to onboard additional cores. Bundle and passphrase travel on different channels. SeeSINGLE-TO-MULTIPLE.md. -
components/tracing/is a permanent no-op shim: keep the architectural slot, plug aDummyTracinginstance. Jaeger / OpenTracing / cls-hooked were removed during the v2 dependency cleanup. The 8 hot-path consumers (api-serverapplication.js/Result.js/socket-io/Manager.js, businessMethodContext.js, middlewaresetMethodId.js/setMinimalMethodContext.js, storagestorage/index.js+storages/index.js) still import fromtracing; every call collapses to aDummyTracingno-op. The hfs-server side (components/hfs-server/src/tracing/) follows the same pattern:cls.jsand the trace middleware are no-op pass-throughs. The opt-in observability layer (components/business/src/observability/) is the active telemetry path and runs in parallel, not through this component. If a future tracer is wanted, replace the body ofcomponents/tracing/src/Tracing.jswith the real impl; the 8 consumers do not need to change.
-
Don't assume a single engine. The engine plugin tree lets operators choose between PostgreSQL (default) and SQLite for
baseStorage/dataStore/auditStorage; contributions that hard-code either inside business logic will get rejected. UsepluginLoader.getEngineModule(pluginLoader.getEngineFor('<storageType>')). -
Don't add an APM agent at
require()time unconditionally. Observability (APM) is opt-in via the pluggable provider façade; the agent is bootstrapped bybin/_observability-boot.jsonly when a provider is explicitly enabled (admin CLI:bin/observability.js). Always honourNODE_ENV=testas a no-op. -
Don't introduce a second TLS terminator. See truth #2.
-
Don't hot-wire cert rotation with
fs.watchFileor cron. Use the existingAcmeOrchestrator→acme:rotateIPC → workerreloadTls()path. -
Don't ship multi-process orchestration shims (PM2, runit, supervisord configs). master.js replaces those. If you need to restart a worker, master.js already does it (see
cluster.on('exit', ...)). -
Server-side event writes keep the tracking properties. An event the server writes outside the events API (a plugin, a tool) gets
created/modified/createdBy/modifiedByfilled bymall.events.create(systemas the author); an UPDATE does not: setmodified(write time) andmodifiedBy('system', or the acting access) yourself, ormodifiedSincesync clients never see the change. Remediation tools keep the authors they read (the scrubs and restore keep all four stamps; the HF duration repair refreshesmodifiedonly).bin/events-stamp-backfill.jsrepairs rows written without them. -
Don't write PlatformDB directly. Go through
components/platform/: it enforces config-snapshot hashes and cluster-wide semantics. Bypassing it silently desyncs cores. -
Don't trust silence from the logs when debugging a test. Under
NODE_ENV=testthe console is off (logs:console:active: false) and the async file transport loses whatever is still buffered when mocha force-exits (exit: true), so a server-side error can leave no trace at all: "no log line" does NOT mean "did not happen". Readcomponents/<component>/var-pryv/logs/test-sync.log:logs:fileSync(seecomponents/boiler/src/logging.ts) appends synchronously atwarn+ and survives force-exit. Keep it atwarn/error: sync writes cost an fs call each, and extra I/O can mask the timing bug you are chasing (LOGS=infois especially good at hiding races). If you add a new swallow site (catch { return null }, a sanitised 500), record the real error on the server trail, never toward the client. -
CMC access-state-mutating triggers are gated: two distinct gate shapes.
consent/accept-cmc(mint) +consent/scope-update-cmc(widen) are personal-token-only atevents.create(components/cmc/src/cmcAcceptAccessGate.ts) AND chain-checked inhandleAccept/handleSystemScopeUpdate.consent/revoke-cmc(delete) is NOT in the personal-token gate:handleRevokerunsAccessLogic.canDeleteAccess(target)per delete (honours theselfRevokefeature permission so the relationship's data-grant access can self-revoke). Apps without a personal token usepryv.cmc.requestAccept(...)(lib-js@pryv/cmc≥ 3.9) orrequestScopeUpdate(...)to hand off toapp-web-user-account's/cmc-accept//cmc-scope-updatepage. There is norequestRevokehand-off; revoke goes through the standard access-permission rule. Don't relax the mint/widen gate to make a test pass; it exists to enforce user-presence at the moment access is created or widened. The plugin-managed access exemption (clientData.cmc.kind === 'capability'/role === 'counterparty') is how cross-platform delivery still works; don't widen it. Seecomponents/cmc/INTERNALS.md"Access-state-mutating triggers: token-class + access-permission gates" for the full story. A relationship'sfeaturesare resolved server-side from the offer (components/cmc/src/features.ts; the accept may only narrow them) and decide provisioning: no chat stream or chat permission without chat, and a counterparty's direct chat / alert / ack write is refused with 403 bycreateCounterpartyFeatureGateHook. Don't takefeaturesfrom the accept as is. -
Account delegation guards: don't relax them. A delegate token (and any access created with one) acts on the controlled account, so the delegation plugin (
components/delegation/src/hooks.ts) keeps a server-owned lineage:accesses.createstampsclientData.delegation = { kind: 'delegated-child', relId, delegate, viaAccessId }from the authenticated access only; a client-supplieddelegationkey is refused on create and update, and the marker is preserved acrossclientDataupdates. Detach deletes the delegated-child accesses of the relationship. A delegation-derived access may not create a durable grant through a path that records no lineage: OAuth2 consent accept (routes/oauth2.ts,403 access_denied), CMCconsent/scope-update-cmc/consent/request-cmc(400,delegation-grant-requires-owner) and the/reg/accessshared-secret hand-off (routes/reg/credentialHandoff.ts, falls back to inline delivery). The CMCconsent/accept-cmcis the one storage-direct grant path that records lineage, so a delegate may write it:handleAcceptcopiesdelegation.lineageOf(triggerAccess)onto the data grant and checks the relationship before and after the mint,content.approvedByis server-stamped and, withownerConfirmedAt/withdrawal, server-owned (components/cmc/src/acceptServerOwnedFieldsHook.ts;withdrawalis written on every teardown path,accesses.delete,consent/revoke-cmcby either side and detach, bycomponents/cmc/src/acceptWithdrawal.tsand the detach, first writer wins), and detach deletes such grants (or, on the owner's review, keeps them without the marker) and notifies the requester. Lifecycle guards exempt exactlykind === 'delegated-child'; don't widen that exemption, and stamp lineage on a new grant path before letting a delegate token use it (seecomponents/cmc/INTERNALS.md"Accept by a delegate"). Account deletion tears down what the account's relationships left on other accounts before erasing it: plugins register a step withbusiness/src/auth/deletionTeardown.ts(delegation:releaseRelationshipon this core,controlled-side/releaseto another; CMC: the revoke to each counterparty). A new relationship type that leaves a credential on another account needs such a step. -
A managed
sharedaccess never outlives its managingappaccess. The expiry chain treats a missing expiry as "never": create gives such a shared the app'sexpires, clearing it or narrowing the app over a never-expiring child is refused (components/api-server/src/methods/accesses.ts), andMethodContext.checkManagingAccessExpiryrefuses a never-expiring shared once its app expired; webhooks apply the same rule before firing (business/src/webhooks/repository.tsaccessIsUsable). Resolve the managing access withmanagingAccessBase(createdBy)(business/src/accesses/refs.ts): an app authenticated with a caller id recordscreatedByas<id> <callerId>. -
Feature permissions (
{ feature, setting: 'forbidden' | 'allowed' }, default allow):selfRevoke(cannot delete itself),secretSharing(cannot create one-time shared secrets),webhooks(cannot create / update / test webhooks; the webhooks it owns stop firing, checked inaccessIsUsable). The last two are handed down to every access the restricted one creates byAccessLogic.inheritRestrictions(list inpermissionSet.tsINHERITED_RESTRICTION_FEATURES);selfRevokeonly refuses a child asking forallowed. -
:_shared-secrets:items are ordinary events with server-owned semantics; don't reach them through the plain events/streams API. The shared-secrets plugin (components/shared-secrets/) stores each one-time secret as an event under:_shared-secrets:<accessId>; the only supported entry points arePOST /shared-secrets,/shared-secrets/retrieve(unauthenticated, key in the body, NEVER the URL), and/shared-secrets/status. Guards refuse creating/updating/moving ashared-secret/itemor hand-editing the namespace streams;events.deleteon a pending item discards it, on a terminal item purges it. The consume is a real compare-and-set (mall.events.update({ onlyIfNotTrashed })); don't replace it with a read-then-write, one-shot depends on it. The secret + any signature passphrase are scrubbed on transition and kept out of event history ({ skipVersioning }); don't add a code path that persists them pastpending. The namespace is deny-by-default for non-personal accesses acrosscanGetEventsOnStream/canCreateEventsOnStream/canListStreamincl. the root; don't add a predicate that lets a*grant resolve onto it. Seecomponents/shared-secrets/{README,INTERNALS,IMPLEMENTERS-GUIDE}.md.
Naming: a bare Xxx is a canonical type defined once and imported (import type); XxxLike is a local structural model of just the members a file actually uses; XxxRow is a storage-engine row shape (snake_case columns); Wire/Raw prefixes mark unparsed wire shapes before normalization. Use the most precise real type available: unknown only for genuinely-opaque pass-through and runtime-validator inputs, never as a shortcut when a real shape exists. Explicit any fails lint (just lint-ts-any, part of just lint); the few legitimate escape hatches carry a justified eslint-disable comment in place; read them before adding a new one. Don't add field initializers (= '', ?? 0) just to satisfy the compiler: they silently change undefined-by-default runtime semantics; use field!: T, field?: T, or an explicit union instead. Gates: just typecheck (tsc, strict: true), just type-coverage (floor 81%), and the open-index-signature ratchet (scripts/open-type-ratchet, part of just lint): [k: string]: unknown disables typo detection on reads, so the site count may only go down; enumerate a new shape's fields unless the openness is load-bearing (dynamic key access, passthrough, the open event data model).
| You need | Import from |
|---|---|
Event, Stream, Access, Permission(Level), StreamPermission, FeaturePermission, AccessType, Webhook, UserId, HttpHeaders, ApiResult, StreamQuery (API/wire shapes) |
business/src/types/public.ts |
StoredEvent, StoredStream, StoredAccess, StoredPermission, SessionData (storage-side shapes) |
storages/interfaces/_shared/domain.ts |
Callback, UserOrId, StoredItem, Query, UpdateData, FindOptions, EventsQueryState (storage plumbing) |
storages/interfaces/_shared/types.ts |
Mall, MallEvents, MallStreams, MallTransactionLike, DataStore, StoreSupports |
components/mall/src/types.ts |
Logger, LogFn, ConfigLike |
@pryv/boiler (components and engines; storages/interfaces/** use the mirror pair in _shared/types.ts, contract files stay boiler-free) |
SqliteDb, SqliteStmt<Row>, SqlParam |
storages/engines/sqlite/src/types.ts |
CMC views: MallLike groups, CmcAccessLike, CmcClientData, OutboundDeps, FetchLike |
components/cmc/src/_types.ts |
Storage contracts (UserStorage<T>, Sessions, UserAccountStorage, UserAuditDatabase+AuditEvent, BackupReader+BackupManifest, …) |
the storages/interfaces/<kind>/ file that defines them |
Three layers, never merged: wire (Event, what the API returns), stored (StoredEvent, what flows through interfaces and the mall, carries headId/deleted/endTime), engine row (XxxRow, per-engine, converted at the toDB/fromDB boundary). If your shape genuinely differs from all three, it's probably a narrow view; name it XxxLike and keep it local.
Lint-enforced, one type name, one meaning: every noun in this table is guarded by no-restricted-syntax in eslint.ts-any.config.js (part of just lint): declaring a local type/interface with a canonical name fails lint with a pointer to the canonical home. A local structural view gets its own name (XxxLike), an engine row XxxRow, a domain-distinct concept a real name of its own (e.g. SeriesQuery, not Query). When several shapes compete for a bare noun, the API-facing (wire) shape owns it. When adding a row to this table, add the noun to the CANONICAL_NOUNS block in the config.
- Method-context scratch fields: api-server method chains refine the context as
type MethodContext = BaseMethodContext & { myField?: T }with named, typed fields, never[key: string]: any. A field written by one component and read by another belongs on the baseMethodContextclass. Mid-chain reads of step-populated fields use one capture with an invariant comment:const x = context.x!; // Invariant: <populating step> ran earlier in this chain. - DI-seam narrow views: modules that receive dependencies for fake-based unit testing (CMC handlers) type them with the shared narrow views (
cmc/src/_types.ts), not the full interfaces. - Typed require handles: when a
require()d module erases a useful signature (e.g. a: neverthrow helper), re-bind it:const m: typeof import('./m.ts') = require('./m.ts');. This applies to class extends too:class X extends require('./Base.ts').Basemakes the parentanyand silences ALL override-compatibility checking; re-bind the require (const { Base } = require('./Base.ts') as typeof import('./Base.ts');) so the inheritance seam is checked. - Generic storage bases:
BaseStoragePG<T>/BaseStorageSQLite<TItem>are generic over the stored item shape and declareimplements UserStorage<T>. Collection subclasses bind their item type (extends BaseStoragePG<StoredAccess>); free-form collections (profile) stay on the defaultStoredItem. Override callbacks carryT | nullto match the base contracts; don't strip the| null.
@pryv/boiler layers configs (lowest → highest):
config/default-config.yml(committed defaults)config/plugins/*(derived values like system streams)${NODE_ENV}-config.ymlor the file passed via--config <path>override-config.ymlat the baseConfigDir (written bymaster.js --bootstrapon core join)- Environment variables: boiler uses
__(double underscore) as the nested-key separator (e.g.auth__adminAccessKey=…setsauth.adminAccessKey). See@pryv/boilerfor the exact mapping rules.
Understand this before debugging why a setting "isn't taking effect".
In this repo:
README.md: project overview + quick-start.INSTALL.md: operator install steps.README-DBs.md: storage-by-storage DB layout and engine selection.SINGLE-TO-MULTIPLE.md: multi-core onboarding, cluster CA, sealed bundle flow.CHANGELOG-v2.md: API-facing changes;CHANGELOG-v2-back.mdfor internal changes.components/business/src/acme/: ACME orchestrator + cert renewer internals.components/platform/: PlatformDB interface + rqlite specifics.storages/manifest-schema.js+storages/pluginLoader.js: how engines are loaded.storages/engines/<name>/manifest.json: what each engine provides.
External docs (pryv.github.io):
- API reference: canonical REST/WebSocket reference (full, light, admin, system variants).
- Concepts: streams, events, accesses, permissions.
- Data in Pryv: data model deep-dive.
- Event types: the curated type catalogue.
- System streams: how account fields map to streams.
- Getting started: first API calls.
- Guides: app guidelines, audit logs, consent, custom auth, data modelling, webhooks.
- FAQ API and FAQ Infra.
Operator-facing setup guides (pryv.github.io/customer-resources/):
- Infrastructure procurement: topology + sizing.
- Pryv.io setup: single-node topology.
- Single node to cluster: multi-core upgrade.
- SSL certificate: built-in ACME / Let's Encrypt.
- Backup:
bin/backup.js. - Core migration: moving a core to a new host.
- MFA: pluggable two-factor via
mfa.*methods. An in-process authenticator-app factor (TOTP, RFC 6238) is enabled by default and works out of the box (no external service); SMS (HTTP provider) stays off until configured. Nothing is forced (login only challenges enrolled users). Config:services.mfa(activedefault true /defaultMethod/methods.{totp,sms}; legacymodetakes precedence when set, for byte-identical upgrades).service.info().features.mfa.methodsadvertises the active methods. Code incomponents/business/src/mfa/(MfaMethodregistry +TotpService+normalizeMfaConfig), wired atcomponents/api-server/src/methods/mfa.ts. - Emails setup: in-process vs microservice. Templates for welcome, password-reset, verify-email and registration-code mails ship with the server and seed into PlatformDB on first boot.
services.email.enabled.verifyEmailis on by default: an address added to an account gets a verification link. Soft landing — whenauth.emailVerificationPageURLor the mail setup is missing AND the flag was never set explicitly, the core boots, warns once per start and keeps the feature off; setting the flag explicitly makes the page URL required at boot.account.emailVerification.requireAtRegistration(default false) additionally requires a code-proved address beforePOST /userswill create an account, and DOES refuse the boot when mail is incomplete.service.info().features.emailVerificationadvertises both as{ atRegistration, onAccount }. Code incomponents/business/src/emails/. - Observability: opt-in telemetry over OTLP.
- Healthchecks and platform validation.
- Change log.
A few operator pages on pryv.github.io (notably dns-config, audit-setup, and system-streams) still mix v1 and v2 wording; when in doubt, trust this repo's config/default-config.yml, README-DBs.md, and the bin/* admin CLIs over the rendered doc site.
- Bugs + feature requests:
pryv/open-pryv.ioGitHub Issues. - Pull requests against
master. For anything touching the cluster CA, ACME orchestrator, PlatformDB interface, or the storage plugin tree, open a draft PR early and tag a maintainer; those areas have subtle invariants that aren't obvious from a local diff.
- Read
bin/master.jstop-to-bottom. It's the single entry point and its comments explain more than this file can. - If a config key feels like it should exist but you can't find it: check
config/default-config.yml; if it's not there, it probably isn't a thing. - Test changes against both engines before assuming engine-agnostic behaviour:
just test all(PostgreSQL default) andjust test-sqlite all.
Happy hacking.