This code is open source and was produced by Michiel de Jong, using Claude as a tool. Michiel de Jong has signed off on all the code in this repo line-by-line (except for the lockfiles, which were produced by npm and pnpm), and Michiel de Jong is the publishing author in terms of copyright. This work was funded by NLNet.
A local-first app that syncs your data across multiple systems of record. Reflector connects to a system of record — today the Google Calendar API — reads everything it has for your account into a local copy made of plain JSON files, and keeps that copy synced in the background. That local-first copy can itself live in another system of record you control: your own remoteStorage account, instead of files on this server. More systems of record can be added over time.
As a side feature, you can download the whole local copy as a ZIP at any time.
It is built on:
- localthought/syncables — the
sync engine. Reflector hands it an OpenAPI document and it discovers the
resources, walks pagination on a full read, keeps a local copy in a pluggable
storage adapter, and applies local-first
create/update/deletewrites that retry against the server in the background. - localthought/overlays — the
Google Calendar OpenAPI Overlays
from issue #140
(
pagination-overlay.yaml+crud-causality-overlay.yaml), which define how a client paginates and does CRUD against the Calendar API. They are vendored underspec/overlays/and applied to the vendored Calendar OpenAPI document at startup.
Scaffolded from node-typescript-boilerplate.
- Connect a system of record via OAuth 2.0 — currently your Google account.
- Sync — pull the full dataset (calendar list and every event on every
calendar) into a local JSON copy under
data/, and keep it synced:- while a sync is in flight, the header shows how many changes are in flight;
- on success it shows "synced";
- on failure a change is rolled back to its pre-sync value and a warning toast is shown.
- Choose where the local copy lives — on this server, or in your own remoteStorage account.
- Download ZIP (side feature) — a
.zipof the full local copy: one JSON file per record, grouped by calendar and resource.
Requires Node >= 22.11 < 23.
npm install # installs the prebuilt `syncables` dependency from npm
npm run build
npm start # http://localhost:3000Set these before starting (a .env is not loaded automatically — export them or
use your process manager):
| Variable | Required | Default | Purpose |
|---|---|---|---|
OAUTH_CLIENT_ID |
yes | — | OAuth client id |
OAUTH_CLIENT_SECRET |
yes | — | OAuth client secret |
OAUTH_REDIRECT_URI |
no | ${BASE_URL}/auth/callback |
Must match the redirect URI on the OAuth client |
BASE_URL |
no | http://localhost:${PORT} |
Public origin of this server |
PORT |
no | 3000 |
Listen port |
DATA_DIR |
no | ./data |
Where the local-first JSON copy is written (local-files storage) |
TOKEN_STORE_PATH |
no | ${DATA_DIR}/tokens.json |
Where the OAuth access + refresh tokens are stored |
REMOTESTORAGE_STORE_PATH |
no | ${DATA_DIR}/remotestorage.json |
Where a connected remoteStorage account is persisted |
REMOTESTORAGE_MODULE |
no | reflector |
remoteStorage module (top-level directory) data is written under |
REMOTESTORAGE_CLIENT_ID |
no | ${BASE_URL} |
OAuth client_id presented to the remoteStorage provider |
REMOTESTORAGE_REDIRECT_URI |
no | ${BASE_URL}/remotestorage/callback |
remoteStorage OAuth redirect URI |
OPENAPI_PATH |
no | spec/google-calendar-v3.openapi.yaml |
OpenAPI document the app is derived from — point it at another API to sync something other than Calendar |
OVERLAY_DIR |
no | spec/overlays |
Directory of overlays (auth/pagination/crud-causality) applied to that document |
OPENAPI_PATH / OVERLAY_DIR are what make Reflector API-agnostic: swap in a
different document + overlays and the whole flow (OAuth or token auth, resource
discovery, pagination, CRUD) follows it, with no code change. For pointing two
endpoints at two systems at once, see
Reflecting between two systems
below, which adds OPENAPI_PATH_A / OPENAPI_PATH_B (and their OVERLAY_DIR_*
counterparts).
The OAUTH_* names are generic; the historical GOOGLE_CALENDAR_CLIENT_ID /
GOOGLE_CALENDAR_CLIENT_SECRET / GOOGLE_REDIRECT_URI are still accepted as
fallbacks. Everything else about the API OAuth flow — endpoints, scopes, and the
API base — is read from the document's security scheme and servers, not from
env vars. (The REMOTESTORAGE_* values configure the separate OAuth flow to the
user's own remoteStorage provider, described below.)
In the Google Cloud console: create an
OAuth 2.0 Client (type “Web application”), enable the Google Calendar API,
and add http://localhost:3000/auth/callback as an authorized redirect URI.
The scopes requested are whatever the document's oauth security scheme
declares — for the vendored Calendar overlay that is calendar,
userinfo.email, and openid.
Beyond syncing one system into a local copy, Reflector can actively reflect records between two endpoints — A and B — mirroring changes each way. The motivating case is two GitHub issue trackers: issues created on one are copied to the other, open/closed state is kept in agreement, and comments are copied across, so the two trackers stay in step.
Status. The reflection engine is implemented and validated live against two real GitHub repositories — create, idempotent re-runs, open/closed state both ways, and comments — as well as in the test suite against a faithful in-memory GitHub. It covers the background loop, the hidden origin markers, the persisted id-map, and issue / open-closed-state / comment reflection (#23–#29), plus the GitHub-shape support in the sync engine (localthought/syncables#4–#6, released in
syncables0.17.1 on npm, which this app depends on).
Each endpoint is derived from its own document + overlays. A and B do not have to share a schema — you can point both at the GitHub Issues document, or at two different systems entirely. Where their record shapes differ, reconciling them is a later Devonian mapping step (out of scope for the base engine); the config already keeps the two documents separate so that work has somewhere to plug in.
| Variable | Required | Default | Purpose |
|---|---|---|---|
REFLECT_A_REPO |
for A↔B | — | What endpoint A targets, e.g. a GitHub owner/repo. Reflection is enabled only when both A and B are set |
REFLECT_B_REPO |
for A↔B | — | What endpoint B targets |
REFLECT_A_TOKEN |
for A↔B | — | Endpoint A's API token (e.g. a GitHub PAT). Secret; sent as a bearer, no OAuth flow |
REFLECT_B_TOKEN |
for A↔B | — | Endpoint B's API token |
OPENAPI_PATH_A |
no | OPENAPI_PATH |
OpenAPI document for endpoint A (falls back to the shared OPENAPI_PATH) |
OPENAPI_PATH_B |
no | OPENAPI_PATH |
OpenAPI document for endpoint B |
OVERLAY_DIR_A |
no | OVERLAY_DIR |
Overlays for endpoint A (falls back to the shared OVERLAY_DIR) |
OVERLAY_DIR_B |
no | OVERLAY_DIR |
Overlays for endpoint B |
REFLECT_DIRECTION |
no | bidirectional |
bidirectional (A↔B) or a-to-b (one-way) |
REFLECT_INTERVAL_MS |
no | 60000 |
How often the background reflect loop runs |
GitHub setup. Point both endpoints at the GitHub Issues document and overlays, and give each a token for its repo:
# The GitHub Issues document + overlays (vendored; see #23). The overlays
# themselves live in localthought/overlays under
# github.com/api.github.com/1.1.4 and are applied to the vendored document.
OPENAPI_PATH=spec/github-issues.openapi.yaml
OVERLAY_DIR=spec/overlays/github
REFLECT_A_REPO=octo/source REFLECT_A_TOKEN=ghp_...
REFLECT_B_REPO=octo/mirror REFLECT_B_TOKEN=ghp_...
REFLECT_DIRECTION=bidirectional
REFLECT_INTERVAL_MS=60000The GitHub token is a personal access token (or installation token) with
repo/issues scope on the repositories involved — a static bearer, so there
is no OAuth redirect and no OAUTH_* client for this mode. Because GitHub
issues and comments have no metadata side-channel, each reflected record carries
a hidden HTML comment (<!-- reflector:origin … -->) in its body linking it back
to the original; that marker is how Reflector tells an original from a copy and
avoids reflecting a copy back again.
Running it. When both REFLECT_*_REPO are set the app starts a background
loop that reflects every REFLECT_INTERVAL_MS. You can also trigger a pass on
demand with POST /api/reflect (it returns a summary of what it created and the
state changes it propagated), and inspect the loop with GET /api/reflect/status.
In this mode the interactive Google "Connect" flow is disabled — a token-auth
instance has no OAuth client.
Persistence. Reflection keeps a source↔target id-map (plus the two tokens)
that must survive restarts. On a durable-disk host it lives under DATA_DIR;
on an ephemeral host (a Heroku dyno, DO App Platform) set DATABASE_URL so it is
kept in Postgres — the same requirement as Multiple users.
After the OAuth flow the access token and refresh token are persisted (owner-only
permissions, 0600), so a connection survives a restart; the refresh token is
used to mint new access tokens automatically. The browser holds only an opaque,
httpOnly session cookie that is matched against the stored session. data/ is
git-ignored.
One hosted instance can serve many people at once. Each browser session is a
separate connected user, keyed by its opaque session cookie, with its own
OAuth tokens, its own local-first copy, and its own sync engine —
concurrent users never see or overwrite each other's data. A user's local-files
copy lives in a per-account directory (${DATA_DIR}/copies/<account>), and a
connected remoteStorage account is scoped to the single user who connected it.
Where those per-user records are persisted is pluggable:
- Files (default) — one owner-only JSON file per user under
${DATA_DIR}/users(override withUSERS_DIR). Needs a persistent disk. - Postgres — set
DATABASE_URLand users are stored in areflector_userstable instead (the table is created automatically). Use this on a host whose filesystem is ephemeral (a Heroku dyno, DO App Platform), where the file store would lose every connection on each restart/redeploy. On Heroku:heroku addons:create heroku-postgresql:essential-0setsDATABASE_URLfor you.
Upgrading an instance that already ran the earlier single-user build: its
existing tokens.json (and remotestorage.json) is migrated into the file
store on first start, so the connection keeps working.
The repo ships a Procfile (web: npm start) and an app.json. Heroku's Node
buildpack installs dependencies, runs npm run build (tsc → build/)
automatically, and starts the process from the Procfile; the Node version is
pinned by engines.node in package.json.
heroku git:remote -a reflector-prod # point this repo at your app
heroku config:set \
OAUTH_CLIENT_ID=... \
OAUTH_CLIENT_SECRET=... \
BASE_URL=https://reflector-prod.herokuapp.com # your app's real URL
git push heroku main # build + releaseIn the Google Cloud console add
${BASE_URL}/auth/callback as an authorized redirect URI on the OAuth client
(e.g. https://reflector-prod.herokuapp.com/auth/callback). PORT is injected
by Heroku and read automatically; OAUTH_REDIRECT_URI defaults to
${BASE_URL}/auth/callback, so setting BASE_URL is enough.
If BASE_URL isn't set, Reflector falls back to Heroku's
dyno metadata
(HEROKU_APP_DEFAULT_DOMAIN_NAME) when available, and to
http://localhost:$PORT otherwise — the latter is only useful for local dev,
so on Heroku either set BASE_URL explicitly (recommended, since the app's
real domain isn't always <app-name>.herokuapp.com) or enable dyno metadata:
heroku labs:enable runtime-dyno-metadata -a reflector-prod.
Heads up — ephemeral disk. A Heroku dyno's filesystem is wiped on every restart and deploy, so anything under
DATA_DIR(data/) does not persist. For durable connections setDATABASE_URLso users are stored in Postgres instead of on disk (heroku addons:create heroku-postgresql:essential-0); the local-files calendar copy is still on the ephemeral disk, so also connect a remoteStorage account, or expect to re-sync after a restart. See Storage and persistence across hosts.
App Platform (managed, git-push deploys) — the repo ships
.do/app.yaml:
doctl apps create --spec .do/app.yaml # or paste it into the App Platform UISet OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET as encrypted env vars in the
app. BASE_URL is bound to the app's public URL (${APP_URL}) automatically,
so the OAuth redirect resolves without hardcoding the hostname; add
${APP_URL}/auth/callback as an authorized redirect URI on the OAuth client.
App Platform's filesystem is ephemeral, so attach a Dev Database and set
DATABASE_URL (see Multiple users) to keep connections
across deploys.
Droplet (a plain VM) — the simplest durable option, because the disk
persists. Run the container (see below) with a mounted volume, or run the Node
process directly under systemd with DATA_DIR pointing at a real directory.
Users and the local copy then survive restarts with no database needed.
The Dockerfile is a multi-stage build (compile → production
image) that runs as a non-root user and exposes /app/data as a volume:
docker build -t reflector .
docker run -p 3000:3000 \
-e OAUTH_CLIENT_ID=... \
-e OAUTH_CLIENT_SECRET=... \
-e BASE_URL=https://reflector.example.com \
-v reflector-data:/app/data \
reflectorThe -v reflector-data:/app/data volume persists everything under data/
(per-user records and the local copy) across container restarts — the durable,
disk-based option. On a container host with no persistent volume, set
DATABASE_URL instead (see Multiple users). This image runs
on any container host (a Droplet, Fly.io, Render, Cloud Run, etc.).
There are two things to persist: the connected users (their OAuth tokens and session) and the local-first copy of the data. Where you deploy decides how each behaves:
| Host | Disk | Persistence without extra work |
|---|---|---|
| Droplet / Docker-with-volume | persistent | ✅ users + local copy survive restarts |
| Heroku dyno / DO App Platform | ephemeral | ❌ wiped each deploy/restart |
On an ephemeral host:
- Users → set
DATABASE_URLto persist them in Postgres instead of on disk (see Multiple users). Then connections survive restarts. - Local copy → connect a remoteStorage account so the
data lives off-host in the user's own storage; otherwise the on-disk copy is
lost on restart and a full read re-fetches it. (Moving the calendar copy
itself into Postgres would mean a new syncables
StorageAdapter; it isn't needed for durable connections and isn't done here.)
By default the local-first copy is written to DATA_DIR as one JSON file per
record. But because syncables only ever talks to a pluggable
StorageAdapter, Reflector lets
you swap that on-disk store for your own
remoteStorage account — your data then lives in
your storage, not on this server. This is what lets Reflector treat
remoteStorage as another system of record alongside Google Calendar.
Use the Storage button in the top bar and enter your user address
(you@storage.example). Reflector then:
- discovers your storage via WebFinger
(
src/remotestorage/webfinger.ts) — the storage root plus its OAuth endpoint; - sends you to your provider's consent screen (OAuth 2.0 implicit grant,
scope
reflector:rw) and receives a bearer token back at/remotestorage/callback(the token arrives in the URL fragment, so a tiny page reads it and posts it to the server); - stores every record as a document at
<storage-root>/reflector/<calendar>/<resource>/<id>viaRemoteStorageAdapter(src/remotestorage/adapter.ts), mirroring the on-disk layout but over the remoteStorage HTTP protocol.
Sync after connecting (or disconnecting) to repopulate the copy in its new home. The ZIP download works the same either way — it packages whatever the active backend holds. Choose Use local files in the Storage dialog to switch back. The bearer token is a secret and is persisted (owner-only) alongside the Google tokens, outside the data set the ZIP packages.
Browser SPA (public/)
│ REST /api/*
▼
Express server (src/server) ── SyncEngine (src/sync/engine.ts)
│
├── ResourceModel (src/sync/resources.ts)
│ · discovers collections, hierarchy,
│ and id rules from `crudResources`
│
├── syncables ApiClient (the sync engine)
│ · full read (pagination)
│ · local-first create/update/delete
│ · background retry
│
├── StorageBackend (src/sync/storage.ts)
│ · FileStorageAdapter → JSON files on disk, or
│ · RemoteStorageAdapter → the user's remoteStorage
│ · either one enumerated → the ZIP
│
└── TokenManager (src/oauth/authed-fetch.ts)
· injects the bearer token
· fills {calendarId} in the path
· retargets to the API base from the
document's `servers`
· refreshes on 401
Nothing in src/ is specific to Google or to the Calendar API. The whole
flow is driven by the OpenAPI document and its overlays:
- the OAuth flow (
src/oauth/) is derived from the document'soauth2security scheme — authorization/token/refresh URLs, scopes, the extra authorization-request parameters (x-authorization-params), and the userinfo endpoint (x-userinfo-url) — plus the API base fromservers(seederiveAuthProfile). Only the OAuth client id/secret are deployment config. - which resources exist, their URLs, how a nested collection's parent id is
resolved, and how new ids are minted all come from the CRUD-causality
overlay's
crudResources(seediscoverResourceModel). The engine walks that hierarchy generically; the calendar/event vocabulary lives only in the server layer that presents it.
The OpenAPI document is prepared once at startup (src/sync/document.ts):
- load the vendored
spec/google-calendar-v3.openapi.yaml; - apply the overlays in
spec/overlays/—auth-overlay.yaml(OAuth security scheme),pagination-overlay.yaml, andcrud-causality-overlay.yaml(src/sync/overlay.tssupports the overlays' bracketed$.paths['/…'].gettargets, which the parser bundled with syncables does not); - adapt the overlays'
incrementalSyncpagination schemes into thepageTokenshape the released syncables understands — keeping thepageToken/nextPageTokenmechanics that drive the full read; - pin each list response's item array to the field the overlay's
envelope.itemsFielddeclares (Google'sEventsschema also has adefaultRemindersarray, which would otherwise be mistaken for the items).
Each nested collection client is given the same document narrowed to just that collection's paths, and its own storage namespace (the parent context, e.g. the calendar id), so sibling parents don't overwrite each other's records.
npm run build # tsc to build/
npm test # vitest unit + integration tests
npm run lint # eslint
npm run prettier:check # formattingTests include an end-to-end SyncEngine test that runs the real syncables
engine and the real overlays against an in-memory stand-in for the Google
Calendar API, covering paginated full read, local-first create/update/delete,
sync-status tracking, and rollback on failure.
syncables is installed from the npm registry (a pinned version) and ships
prebuilt, so no build step runs on install. scripts/postinstall.mjs remains
only as a fallback that builds it from source if an installed copy ever lacks a
prebuilt build/ (e.g. a source checkout); against the published package it is
a no-op.
Reflector is developed collaboratively with Claude Code (Anthropic), an agentic coding assistant: a human directs the architecture and reviews, edits, and tests the changes it proposes before they're committed.
As an NLnet-funded project, this follows NLnet's Generative AI policy:
- Commits produced with AI assistance carry a
Claude-Session: <url>trailer identifying the session that produced them. docs/ai-logs/holds prompt/output disclosure logs for sessions going forward, redacted for secrets and personal information, per the policy's terms for a project that was already ongoing before the policy took effect (no retroactive backfill of every past session; known historical session links are indexed as pending indocs/ai-logs/pending-historical-sessions.md).- AI-drafted content is reviewed and edited by a human before being committed; it is not represented as unassisted human work.
Apache-2.0