The account service for openplate. Its first feature is end-to-end-encrypted sync between your devices.
It holds two things: an email address, and opaque ciphertext. It cannot read what it stores — not as a policy, but as a consequence of never receiving a key. Your passphrase never leaves your device; what reaches the server is a derived value that authenticates you and decrypts nothing.
Start with PROTOCOL.md. It is the normative specification of the wire protocol, written so a third party can implement either side of it without reading this code — an alternative client against this service, or an alternative server that an openplate client can be pointed at with SYNC_SERVER_URL.
This service is optional. openplate is a complete, fully functional tracker without it: your diary lives in the browser, exports to JSON, and imports again on another device. Sync removes the manual step; it does not unlock anything.
Open source. openplate-sync is licensed under the MIT License (SPDX:
MIT), the same license as the openplate app. Self-hosting is explicitly one of the things it supports. See License.
git clone https://github.com/LowCarbCheck/openplate-sync.git
cd openplate-sync
cp .env.example .env
# Generate the one secret you must not lose:
openssl rand -hex 32 # → paste into SERVER_SECRET in .env
# Set CLIENT_BASE_URL to wherever your openplate client is served.
docker compose --project-directory . -f docker/compose.yml up -d
curl http://localhost:3000/healthThat is the whole install. Postgres comes up alongside the service, the schema migrates itself on boot, and there is nothing else to run.
--project-directory . is what keeps the repository root as the project root, so .env is read from where you created it and the image builds from the checkout rather than from docker/. If you would rather run the published image than build from source, copy docker/compose.yml out on its own, uncomment the image: line, and plain docker compose up -d beside it works.
Then point your openplate app at it by setting SYNC_SERVER_URL to this service's public URL — the one a browser can reach, since the sync client runs in the page. If you want both halves in one file, openplate ships a combined docker/topologies/compose.sync.yml that brings up the app, this service and a shared Postgres together.
With no mail configured, verification and reset links are printed to the service log:
docker compose --project-directory . -f docker/compose.yml logs -f syncThat is a supported way to run a personal or family instance, not a degraded one. Set SMTP_HOST and friends when you want real delivery. Every setting is documented in .env.example.
SERVER_SECRET— back it up with your database. Two subkeys are derived from it: the pepper mixed into every stored auth verifier, and the key behind the anti-enumeration KDF responses. A restored database with a lost secret is a database nobody can log into, and every account would need a passphrase reset.TRUST_PROXY— set it to the number of reverse proxies in front of the service (1behind a single nginx or Traefik). Left atfalsebehind a proxy, every request appears to come from the proxy's address and the per-IP throttle becomes one global bucket a single attacker can lock for all your users. Set totruewith nothing in front, anyone can spoofX-Forwarded-Forand skip the throttle entirely.
Also worth knowing: SIGNUPS_OPEN=false closes registration on a family instance while leaving existing accounts working.
Losing the passphrase without the recovery code means losing the data. Permanently, and to you as the operator too. The email reset flow restores login and cannot restore data — the reset email says so in those words before anyone clicks. This is the direct cost of the server not being able to read anything, and it is not a bug you can fix from the server side.
The client encrypts your data before it ever leaves the device, using a key derived from your passphrase; the server only ever sees and stores opaque ciphertext blobs and wrapped key records, never a passphrase or a key that could decrypt them. This is a zero-knowledge design: authentication and sync both work without the server holding anything that unwraps your data.
Full detail, including the exact protocol, HKDF labels, and token lifetimes: PROTOCOL.md.
openplate-sync is open source under the MIT License (SPDX: MIT), matching the openplate app. MIT is one of the most permissive licenses available: run it, read it, change it, fork it, redistribute it, host it for others — commercially or not — with no restrictions beyond keeping the copyright and license notice attached to any copy you distribute. Self-hosting this service is a first-class use, and so is running it as a hosted product for others.
pnpm install
pnpm run typecheck
pnpm run test:unit # node:test — handler cores, auth policy, protocol drift guard. No DB.
pnpm run test:integration # boots the real app against a real Postgres
pnpm run lint # oxlint, zero warnings
pnpm run build # esbuild → dist/server.js
pnpm run dev # tsx watchTwo optional conveniences:
nix developgives you a shell with the expected Node 22 and pnpm, if you have Nix with flakes enabled.docker compose -f docker/compose.dev.yml up -dstarts the contributor test database on port 5433, for the integration suite. Skip it if something already answers on that port.
Linting is oxlint plus a vendored anti-slop plugin under
tools/oxlint/anti-slop/ (MIT, © Dillon Mulroy — its own LICENSE ships beside
it). The gate is zero warnings, and pnpm lint runs first in the pre-push
hook. The rule that shapes this codebase most is the one against unparsed
input: request bodies enter as JsonValue and are decoded through
src/lib/json.ts, which is the only module that inspects a JSON primitive at
runtime.
The integration suite targets a local Postgres at localhost:5433 (user postgres, password postgres) and creates openplate_sync_test on first run. Override with TEST_DATABASE_URL. It deliberately does not use the self-hosting database in docker/compose.yml — that one is for self-hosters. If you have no Postgres on 5433, docker/compose.dev.yml is a one-service file that provides exactly that and nothing else.
| Path | What lives there |
|---|---|
src/protocol.ts |
The wire contract: versions, limits, request/response types, handshake check. |
src/server/ |
Express glue, the sync handler cores, CORS, bearer auth, error handling. |
src/accounts/ |
Account policy as pure handlers over an injected AccountStore. |
src/db/ |
Drizzle schema and the two store implementations. |
src/lib/ |
Pure primitives: verifier, tokens, KDF descriptors, throttle. |
src/mail/ |
Console / SMTP / pigeon transports and the two messages this service sends. |
drizzle/migrations/ |
Generated migrations. Never hand-written — see src/db/schema.ts. |
- No
@sprqvntrs/*or private-registry dependencies. This repo must be buildable by anyone. The pigeon mail transport is a hand-written HTTP client for exactly that reason. - Handler cores stay pure and dependency-injected. The shell owns Express, the database and the environment; the cores take a store, a clock and a token minter. That is why the auth suite tests rotation, reuse detection and revocation without a database.
src/protocol.tsis a hand-maintained duplicate ofopenplate/app/lib/sync/engine/protocol.ts. There is no shared package and no shared CI, so both repos carry a unit test asserting the constants against transcribed literals. Changing the protocol means editing four places — two sources and two tests — starting with PROTOCOL.md.- Migrations are generated, never written. And journal timestamps are never hand-edited: the migrator applies only migrations newer than the last applied one, so an out-of-order value causes a later migration to be silently skipped at boot.