The account service for openplate. Its first feature is end-to-end-encrypted sync between your devices.
What this server holds, in one paragraph. An email address, an opaque ciphertext blob per account, wrapped key records it cannot unwrap, and each account's recovery code sealed under a key in the environment. It cannot read the ciphertext, not as a policy, but as a consequence of never receiving a key: your passphrase never leaves your device, and what reaches the server is a derived value that authenticates you and decrypts nothing. The escrowed recovery code is the deliberate exception, and it is what makes "forgot password" restore the diary rather than only the login. It also means the operator of a hosted instance can open any account on it, not through an endpoint, there is none, but by reading that column with SERVER_SECRET in hand. A self-hosted instance is its own operator. The full argument, including what it costs and why it was taken, is ADR-0005.
And four places the zero-knowledge claim does not hold. All four are optional, all four are off until somebody turns them on, and they are not the same kind of thing.
The first is the AI proxy. If the operator configures a provider key, this service proxies the app's food-photo requests to that provider at POST /v1/chat/completions, so the photograph and the model's answer cross this process. Neither is written, cached or logged: not the body, not a prefix, not a decoded buffer. What a log line carries is an account id, an upstream status, byte counts and a duration. It SEES a photograph and keeps nothing. Leave UPSTREAM_API_KEY unset and the route does not exist.
The second is reported estimates. With SYNC_FEEDBACK on, a person who saw a wrong measurement can send that entry's figures and its photograph here, having agreed to it in plain words, and this service KEEPS what it is given: the photograph sits in the operator's database and the operator can look at it. That is a different undertaking from holding ciphertext nobody can read, and ADR-0006 states both holes side by side. Leave SYNC_FEEDBACK unset and the whole /v1/feedback subtree answers the ordinary unknown-path 404.
The third is the community pulse. A person who turns it on in the app sends three kinds of small delta to /v1/pulse: a meal as the count 1 with its calories rounded to 50 and its protein rounded to 5 g, a parsed photograph as the count 1, and a fasting heartbeat that carries nothing beyond their bearer token. The server keeps instance-wide day sums for 30 days, one row per contributing account per day beside them, and a presence row that expires 30 minutes after the last heartbeat, so GET /v1/pulse/today can tell a person how many others are here today. The routes log a status code and a byte count and never an account id. ADR-0007 states all three holes side by side, including the fact that a fasting heartbeat and M223's wake_at describe the same fast. Nothing leaves a device until the person turns the toggle on.
The fourth is push scheduling. With VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY and VAPID_SUBJECT set, a person can register a device at /v1/push and ask for two things: a morning catch-up at a minute of their own local day, and an alert when a fast reaches its target. The server stores one row per device: where to send, in which zone, at which minute, when it was last seen, and the instant it asked to be woken. What it never stores is a word of what the notification says. Every push carries a kind, {"kind":"catch-up"} or {"kind":"fast-target"}, and the device writes the sentence out of the diary only it can read. At most two pushes a day per device, nothing at all for somebody who has not opened the app in seven days, and a subscription the push service disowns is deleted. ADR-0008 states all four holes side by side, including the fact that a wake_at row and the pulse's presence row describe the same fast. Leave the three variables unset and the whole /v1/push subtree answers the ordinary unknown-path 404.
One opinion about a blob, and it is not a fifth hole. This service used to accept any correctly versioned blob without looking at it at all. Since M224 it refuses one shape: a push whose ciphertext is under half the size of the stored one, unless the request explicitly says the deletion is intended. A person lost her whole diary to a client that found its local store evicted, concluded she had deleted everything, and pushed a tombstone per entry, and a second device then pulled that blob and deleted its own rows. The guard compares two byte counts this service already stored for the storage figure it already reports, so it learns nothing new about anybody; what it gives up is the claim to be a store with no opinion. An operator can put an account back with pnpm sync-api accounts rollback, and docs/operations/restoring-a-wiped-diary.md is the procedure, including the step on the person's own devices that the rollback cannot do. ADR-0009 states what it costs when it is wrong.
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-core 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.
docs/ holds the architecture decision records (docs/adr/) and
the operator playbooks (docs/operations/); this README's Self-hosting, The AI proxy, Backup and restore,
and The admin API sections read like standalone guides and are candidates for splitting into
docs/ files later.
| Guide | What it covers |
|---|---|
| Protocol | The wire and key protocol, version 2 |
git clone https://github.com/LowCarbCheck/openplate-core.git
cd openplate-core
cp .env.example .env
# Generate the one secret you must not lose:
openssl rand -hex 32 # → paste into SERVER_SECRET in .env
# That is the only value you must set.
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.
An account is an email address plus a passphrase, and it is created by redeeming an invite you addressed to somebody. There is no open registration and no closed mode: the invite is the only door.
pnpm sync-api invites create --email anna@example.org --name "Anna"That prints a link (or, if you configured no CLIENT_BASE_URL, the raw token) once. It is not stored, only its digest is. One invite creates one account, at the address it names, and a failed attempt does not spend it.
The invitation is the address verification. POST /v1/auth/signup reads the address from the invite row, never from the request body, so the person who received the letter is the person who signs up. There is no confirmation link and nothing left to confirm afterwards.
Mail is optional. Set MAIL_API_* and this service sends the invitation and the password reset itself; leave it unset and both come back to you as links to paste. Nothing is silently dropped either way. SMTP_* and PIGEON_* are boot failures rather than no-ops: this service speaks pigeon's HTTP API and nothing else.
"Forgot password" works, and unlike the mailed reset this service used to have, it restores the diary rather than only the login.
It works this way: the client generates the recovery code at signup and sends it to the server, which seals it into accounts.recovery_code_escrow under a subkey of SERVER_SECRET. POST /v1/auth/reset/request mails a link; POST /v1/auth/reset/open spends it once and hands the code back; the client then runs the ordinary recovery ceremony with it: new passphrase, re-wrapped data key, new code, re-sealed escrow, one transaction. The reset endpoint writes nothing to the account. Without the key records, what it returns is a string.
The cost, stated plainly: you, as the operator, hold what it takes to open any account on your instance. Not through an endpoint (there is none, and no admin call ever prints a recovery code), but by reading that column with SERVER_SECRET in hand. If you run an instance for other people, they are trusting you and not only the cryptography, and they should be told so.
If you are your own operator, which is what self-hosting means, the older promise is intact: nobody but you can open your diary, and you already could.
openplate can name a plate from a photograph. The model that does it is not in this repository and not on your server: it is a provider you pay, and this service is the thing that stands between your users and your bill.
UPSTREAM_BASE_URL=https://openrouter.ai/api/v1
UPSTREAM_API_KEY=sk-... # both, or neither. One alone is a boot failure.
AI_ADVERTISED_MODEL=some/model # optional, advertising copy for the app
AI_RATE_LIMIT_PER_MINUTE=20 # per account, default 20
UPSTREAM_TIMEOUT_MS=120000 # per request, default two minutesWith both set, a signed-in account posts an ordinary OpenAI-compatible request
to POST /v1/chat/completions with its own access token. This service
spends one unit of that account's daily allowance, replaces the token with your
provider key, forwards the body untouched, and streams the answer back. The
account never learns your key. The provider never learns the account's token.
The allowance is per account, per UTC day, and it defaults to zero. A new invite hands out no AI at all unless you say otherwise, so an operator who mints an ordinary invitation has not given away their provider key by accident:
pnpm sync-api invites create --email anna@example.org --name Anna --daily-limit 200
pnpm sync-api accounts set-limit 42 200 # or change it later
pnpm sync-api accounts set-limit 42 0 # or turn it offEvery proxied answer carries X-Quota-Used and X-Quota-Limit. An account at
its limit gets a 429 naming the UTC midnight it resets at, with Retry-After
in seconds. An account with an allowance of zero gets 403 ai-not-allowed
before any request leaves your host.
A unit is reserved before the call and given back only when the provider cannot have billed you. A connection that never opened, a provider that refused the request outright, or a bound of yours that expired before any byte arrived: released. A provider that accepted the request and then failed while serving it: spent, because generation may have run and a released unit there is a free retry loop against exactly the provider that is flaking.
There is also a per-account limiter of twenty requests a minute, which is a different bound for a different failure: a stuck client that retries on every error would otherwise spend a whole day's allowance in ten seconds, and the first thing the person sees is that the feature stopped working.
The counters are kept for 90 days and then deleted. Spending is recorded as
one integer per account per UTC day, and nothing else: no prompt, no response,
no model, no time of day. An hourly sweep inside the service deletes every row
older than 90 days, on every instance, whether or not you have a provider key
configured today. You need no cron entry and no maintenance command. Deleting
an account takes its counters and its last-seen timestamp with it, in the same
statement as the rest of the erasure. Ninety days is also the longest activity
window /admin will show you for one person, so a strip you read is never
zeroes standing in for rows that expired.
Leave UPSTREAM_API_KEY unset and none of this exists. The route answers the
same 404 any unknown path does, and /health reports instance.ai: null so
the app knows not to offer a scan.
Setting any of the removed variables (SIGNUP_MODE, SIGNUPS_OPEN, EMAIL_FROM, SMTP_*, PIGEON_*, REQUIRE_EMAIL_VERIFICATION) is a boot failure, not a no-op. See .env.example for why refusing to start is the safer answer.
Off by default. Read this whole section before you change that.
openplate can name a plate from a photograph, and sometimes it is wrong. With
SYNC_FEEDBACK=true, a person looking at a wrong measurement can send you that
entry's figures and the photograph they came from:
SYNC_FEEDBACK=true
FEEDBACK_DAILY_LIMIT=5 # reports per account per UTC day, default 5
FEEDBACK_MAX_REQUEST_BYTES=8000000 # per report, default 8 MBThey agree to it first, in a separate step with plain wording that names who can see the photograph and how long it is kept, and what they agreed to travels with the report: the timestamp and the version of the wording they were shown are stored on the row. A flag on their own device would prove nothing to anybody looking at that image afterwards.
What is stored, exhaustively. The photograph, the figures from the entry being disputed, and the consent record. Not the diary, not a food name beyond what the reported entry itself carries, not an IP address, not a user agent, not a device identifier. An entry whose photograph the app had already evicted still reports, flagged as having no image, because the figures alone are still worth reading.
WHAT THIS COSTS YOU, PLAINLY. You hold photographs of your users' food, in your database, and you can look at them. Every other write path on this service stores something nobody can read. This one does not, and no amount of care in the code changes that: it is the point of the feature. If you run an instance for other people, this is a promise you are now making to them, it belongs in whatever you told them about this server, and it is a change you should make deliberately rather than because a flag was there.
The bounds. A report is capped in size and an account may store only so many a day, so a compromised client cannot drain your disk or your bandwidth. A retried report is one report: the client sends an idempotency key and a repeat stores nothing new, which is what lets the app queue a report durably and drain the queue when the phone finds a connection.
The images go in the Postgres you already run and already back up. There is no
S3 client here and no object-storage secret to hold: storage sits behind a
FeedbackImageStore interface with put, get and delete, so a later move is
one adapter and no change anywhere else.
Reading a report, and what that leaves behind. The queue is
GET /v1/admin/feedback behind your admin credential, one report is
GET /v1/admin/feedback/<id>, the photograph is
GET /v1/admin/feedback/<id>/image, and DELETE /v1/admin/feedback/<id> removes
a report and its image now. The openplate app renders all of this at /admin.
Every read of an IMAGE writes one line to this service's log naming who opened
which report and when. That line is for you as much as for the person whose meal
it is: it is what turns "the operator can see everything" into something that can
be checked afterwards. Opening the figures is not logged, because a line per row
scanned would bury the one that matters.
Thirty days, and it is not a setting. A report and its photograph are deleted
thirty days after they arrive, by a sweep inside the service, on every instance
with the feature on. No cron entry, no operator action, nothing to remember. The
number is FEEDBACK_RETENTION_DAYS, it is the same number the app shows a person
in the consent step, and there is deliberately no variable to raise it: that
would extend a promise somebody else made on your behalf. Deleting sooner is
always yours to do.
Erasing an account erases their reports and their photographs, in the same
statement as everything else. DELETE /v1/admin/accounts/<id> is the DSAR path,
and tests/integration/account-erasure-feedback.test.ts proves it by asking the
image store for the bytes afterwards rather than by trusting the cascade.
Leave SYNC_FEEDBACK unset and none of this exists. The whole /v1/feedback
subtree answers the same 404 any unknown path does, to everybody, with or
without a valid token.
An invitation and a password reset. Neither is a channel for anything else: there is no breach notification, no "this instance is moving", no "your account will be deleted on Friday". The bound is deliberate rather than unfinished. A service that can send arbitrary mail grows a notification system, and a notification system is a reason to keep reaching for the address column beside a diary the operator cannot read.
So if you need to reach your users about anything else, keep that list yourself, outside this service. You already know who they are: you addressed their invitations. A household has a chat, a clinic has a patient record, an employer has a directory.
The one thing the service does offer in between is a notice on the
handshake. Set
SYNC_NOTICE (and optionally SYNC_NOTICE_URL) and every client that connects
shows the message as a dismissible banner:
SYNC_NOTICE="This instance moves to sync.example.org on 1 March. Sign in there with the same address."
SYNC_NOTICE_URL="https://example.org/moving"Know exactly what that is and is not. It is pull, not push: the client reads
it from GET /health when it connects, so it reaches only the people who open
the app, it does not reach anybody who has stopped using it, and the server never
learns who read it. Changing it is a redeploy. The
text is capped at 280 characters because /health is also the container's
healthcheck path and is polled continuously. For anything that must actually
arrive, use your own contact list.
SERVER_SECRET: back it up with your database. Three subkeys are derived from it: the pepper mixed into every stored auth verifier, the key behind the anti-enumeration KDF responses, and the AES key that seals each account's escrowed recovery code. A restored database with a lost secret is a database nobody can log into, no recovery code gets anybody back in (the pepper keys both verifiers), and no password reset works either (the escrow cannot be opened). The same is true of a deliberate rotation. There is no path that repairs this from the server side, so treat the secret as part of the backup, not as a setting.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.
Your reverse proxy must also allow request bodies of about 2.75 MB. Blobs are capped at 2 MB, base64 inflates them by a third, and nginx's default client_max_body_size is 1 MB: left at the default it rejects legitimate maximum-size syncs before this service ever sees or logs them. In nginx that is client_max_body_size 3m;.
SYNC_FEEDBACK: off by default, and the one flag here that changes what kind of service this is. Turning it on means you hold photographs of your users' food that you can read. See Reported estimates above and ADR-0006.SYNC_RESEARCH: off by default. Turning it on opens the/v1/sync/contributionsand/v1/sync/studyendpoints, which is what brings the openplate client's/studyconsole to life, and makes this server hold a study graph of health-adjacent personal data. Read.env.examplebefore you set it; it is a different undertaking from holding ciphertext you cannot read.
Also worth knowing: ADMIN_TOKEN is the operator's break-glass credential, and it is optional. An account with role: "admin" reaches /v1/admin with its own access token, which is what puts the console in the app rather than in a shell. With neither configured nor existing, the whole /v1/admin tree answers the ordinary unknown-path 404, not a 401, which would announce that a credential exists here worth guessing.
SERVER_PUBLIC_URL and CLIENT_BASE_URL are both optional and are needed together: they build the link in an invitation and in a reset mail. With neither, the admin API returns the raw token and you paste it yourself.
Two things must survive together: the Postgres data and SERVER_SECRET. Either one alone
restores nothing usable.
# Back up
docker compose --project-directory . -f docker/compose.yml exec -T postgres \
pg_dump -U openplate openplate_sync > sync-backup.sql
# Restore, into a stopped-then-started stack, before users reconnect
docker compose --project-directory . -f docker/compose.yml exec -T postgres \
psql -U openplate openplate_sync < sync-backup.sqlThe database lives in the postgres-data volume declared by docker/compose.yml. Keep
SERVER_SECRET with the dump, in whatever holds your other secrets, not in the dump itself.
They sign in with the address their invitation arrived at, and a passphrase they choose. That is the whole of what they need to remember, which is the point: they will forget a username and they will forget a password, and they know their email.
If they forget the passphrase, "forgot password" mails them a link and their diary survives. Tell them the other half too: that works because you hold their recovery code in escrow, so they are trusting you as well as the mathematics. If that is not a trust you want to be given, do not run an instance for anybody but yourself.
There is an operator API at /v1/admin: list accounts and invitations, read
one account's metadata, read one account's activity over the last 90 days,
read the same activity for a whole page of accounts in one request,
aggregate storage counts, change what an account may do
(role, its AI allowance, its display name), suspend and reactivate it, send it
a password-reset letter, resend an invitation, and delete an account with
everything attached to it. That last one is why it exists at all: an erasure
request is an obligation, and a service whose only erasure mechanism is a
hand-written DELETE in a SQL client is a service that will eventually get it
wrong.
The activity view is bounded metadata, and it is metadata about a person. It answers when somebody last signed in and how many AI requests they made on each of the last 90 days, which is the question an operator running a study has to answer and today would answer by opening Postgres. It shows no diary content, because there is none to show: the blobs are encrypted and this service holds no key. Every day in the window is returned, so a day with no activity is a zero rather than a hole, and the window never runs past the 90 days of counters the service keeps.
The same view comes in bulk, because a list of people needs a strip each.
GET /v1/admin/activity returns one strip per account on a page of the account
list, in that list's order and paged with the same limit and offset, so a
console draws fifty rows with one request instead of fifty. Every account on
the page is in the answer, including one that has never used AI, whose strip is
zeroes: leaving it out would turn "this person did nothing" into "this person
was not in the answer", which are different facts and the whole reason the days
are zero-filled.
Suspending revokes every session in the same act. A suspended_at on its own
would leave the phone in somebody's pocket syncing for another quarter of an
hour, which is not what an operator means by the word. Reactivating restores no
session: the person signs in again.
An administrator cannot suspend, demote or delete their own account. An
organization with one administrator who does that has locked everybody out of
/v1/admin, and the remedy is a shell on the container. The static ADMIN_TOKEN
is exempt, because it has no self and it is the credential that exists for
exactly that situation.
Two credentials reach it: the static ADMIN_TOKEN, which is yours as the
operator and keeps working when every account is locked out, and an account
whose role is admin, using its own access token. The second is what puts the
console in the app at /admin, behind the same sign-in as everything else.
With neither (no ADMIN_TOKEN, and the caller not an admin account), the
whole /v1/admin tree answers the same 404 any unknown path does, to
everybody. An instance that never configured it is indistinguishable from one
built before the feature existed. A 401 there would announce that a
credential exists and is merely locked.
Under Compose, put the value in .env: docker/compose.yml already forwards
ADMIN_TOKEN into the container. Compose passes only the variables that file's
environment: block names, so a variable you add to .env and nowhere else
never reaches the service. INSTANCE_NAME, INSTANCE_LANGUAGE,
SERVER_PUBLIC_URL, CLIENT_BASE_URL, TRUST_PROXY, LOG_LEVEL,
SYNC_SHARING, SYNC_RESEARCH, DATABASE_SSL, SYNC_NOTICE,
SYNC_NOTICE_URL, MAIL_API_*, UPSTREAM_BASE_URL, UPSTREAM_API_KEY,
UPSTREAM_TIMEOUT_MS, AI_ADVERTISED_MODEL and AI_RATE_LIMIT_PER_MINUTE are
forwarded there too. If you run your own Compose file
rather than the one in docker/, name each variable you rely on in its
environment: block.
What it can never do, by design rather than by default:
- Read a blob. Ciphertext is not exported through the admin surface in any form. A blob is reported as a byte count and a timestamp.
- Return a verifier or a KDF descriptor. Neither has an operational use that justifies putting it where a screenshot or a paste can carry it.
- Set anyone's passphrase. It can send a reset letter, which starts the ceremony the client performs; it cannot choose the new passphrase. The passphrase wraps the data key on the client, so a server-side credential change would produce an account that logs in and decrypts nothing.
- Print a recovery code. The escrow is opened by the reset ceremony, at the request of the person holding the letter. No admin call returns one.
- Read a request to the AI proxy. No admin endpoint reports what was asked
or answered.
aiUsedTodayis a count.
The reasoning in full is in
docs/adr/0001-an-admin-api-for-a-zero-knowledge-service.md.
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-core 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 watchpnpm sync-api is a thin HTTP client over the admin API: it imports no
database code, so it runs from a machine with no Postgres:
ADMIN_TOKEN=... pnpm sync-api status
ADMIN_TOKEN=... pnpm sync-api accounts list --limit 20
ADMIN_TOKEN=... pnpm sync-api accounts get 42 --json
ADMIN_TOKEN=... pnpm sync-api accounts set-role 42 admin
ADMIN_TOKEN=... pnpm sync-api accounts set-limit 42 200
ADMIN_TOKEN=... pnpm sync-api accounts suspend 42
ADMIN_TOKEN=... pnpm sync-api accounts reset-mail 42
ADMIN_TOKEN=... pnpm sync-api accounts delete 42 --yes
ADMIN_TOKEN=... pnpm sync-api invites create --email anna@example.org --daily-limit 200
ADMIN_TOKEN=... pnpm sync-api invites resend 7The token comes from ADMIN_TOKEN and nowhere else: there is no --token
flag, because a credential in argv lands in shell history and is visible in
ps. The target is --url, then SYNC_SERVER_URL, then
http://localhost:3000. Deletion requires --yes. The CLI is not part of the
Docker image.
Two 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/admin/ |
The admin metadata read contract, deliberately not part of AccountStore. |
src/ai/ |
The completion proxy, its quota store, the minute limiter, the scrubber and the usage retention sweep. |
src/feedback/ |
Reported estimates: submit, the operator's read side, image storage, retention. |
src/pulse/ |
The community pulse: its store, the rounding, the per account limits, the cache and the retention sweep. |
src/mail/ |
The two letters, their strings, and the HTTP mailer that sends them. |
src/lib/ |
Pure primitives: verifier, tokens, KDF descriptors, throttle. |
scripts/sync-api/ |
The pnpm sync-api admin CLI. HTTP only: it imports no database code. |
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. - Four runtime dependencies:
express,pg,dotenvandundici. The last is the AI proxy's, and it is not a preference: Node's globalfetchapplies a 300-second header timeout that anAbortSignalcan only tighten, so an operator who setUPSTREAM_TIMEOUT_MS=600000would still be cut off at 300 with an error naming no knob. - 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.