Skip to content

Latest commit

 

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

openplate-sync

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.


Self-hosting

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/health

That 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.

Mail is optional

With no mail configured, verification and reset links are printed to the service log:

docker compose --project-directory . -f docker/compose.yml logs -f sync

That 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.

Two settings that matter more than the rest

  • 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 (1 behind a single nginx or Traefik). Left at false behind 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 to true with nothing in front, anyone can spoof X-Forwarded-For and skip the throttle entirely.

Also worth knowing: SIGNUPS_OPEN=false closes registration on a family instance while leaving existing accounts working.

What your users should understand

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.


How it works

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.


License

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.


Development

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 watch

Two optional conveniences:

  • nix develop gives 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 -d starts 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.

Layout

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.

Invariants

  • 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.ts is a hand-maintained duplicate of openplate/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.

About

Self-hostable account + E2EE blob-sync service for openplate — Bitwarden-model auth over CAS blob push/pull, opaque ciphertext only.

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages