PowerSync, Account & Device Management
This is the reference for the sync schema and the account/device routes:
- PowerSync: synced tables, local dev, adding, removing and extending tables
- Devices: the
devicestable, device identity, and CLI/bridge rows - Backend API: token issuance, upload, account deletion, revoke and the encryption routes
The user-facing side of those routes — what the account’s other devices do when one is revoked or the account is deleted — is in delete-account-and-revoke-device.md. The sync pipeline itself (two worker paths, local-only tables, offline behavior) is in multi-device-sync.md.
1. PowerSync Overview
PowerSync provides offline-first sync between the backend (PostgreSQL) and clients (SQLite). Data is scoped by user_id from the JWT. The backend issues PowerSync JWTs and can apply client uploads (PUT/PATCH/DELETE) to Postgres. Production runs the self-hosted PowerSync service on Render (image: ghcr.io/thunderbird/thunderbolt/thunderbolt-powersync); local development uses the Docker stack in powersync-service/. The frontend never hard-codes the PowerSync URL — the backend returns it in /powersync/token, so URL changes are transparent to clients.
For the sync data transformation middleware and custom SharedWorker (E2E encryption pipeline), see powersync-sync-middleware.md.
2. Synced Tables
Requirements
- Every synced table must have a
user_idcolumn (sync rules and backend scope byuser_id). - Define the table in both:
- Frontend: src/db/tables.ts (SQLite)
- Backend: backend/src/db/powersync-schema.ts (PostgreSQL)
- Backend schema uses minimal indexes: Only primary keys and
user_idindexes (see Indexes and Foreign Keys below).
Current Tables
Defined in shared/powersync-tables.ts:
settings, chat_threads, chat_messages, tasks, models, prompts, skills, triggers, model_profiles, devices, agents, projects.
Indexes and Foreign Keys
The backend schema carries primary keys and a single user_id index per table, and nothing else — no composite foreign key constraints, no active (WHERE deletedAt IS NULL) indexes, no foreign-key indexes. The frontend SQLite schema is free to add whatever local queries need, because that is where queries actually run.
The rationale lives with the schema design it follows from: Index Strategy: user_id Only.
Adding a New Synced Table
- Create the table in both
src/db/tables.tsandbackend/src/db/powersync-schema.ts(includeuser_id). - Backend schema: Add only a
user_idindex:index('idx_[table]_user_id').on(table.userId). Do not add composite foreign keys or other indexes (see above). - Register in src/db/powersync/schema.ts (
drizzleSchema). - Add the table name and query keys in shared/powersync-tables.ts (
powersyncTableNamesandpowersyncTableToQueryKeys). The query-key entry is required by the map’s type but has no runtime consumer — reactivity comes from PowerSync itself; see the note on the map. - Update all three sync-rule configs so local, preview, prod, and enterprise-k8s stay in parity:
- powersync-service/config/config.yaml — local docker-compose.
- deploy/config/powersync-config.yaml — baked into the
ghcr.io/thunderbird/thunderbolt/thunderbolt-powersyncimage; used by preview stacks (Pulumi) and prod on Render. - deploy/k8s/templates/configmaps.yaml — Helm-rendered config for the enterprise k8s deploy path.
Add a line under
sync_rules.content→ the appropriate bucket in each:bucket_definitions.user_essentials.datafor latency-sensitive tables (loaded first, priority 1), orbucket_definitions.user_data.datafor the rest (priority 2). Example:- SELECT * FROM powersync.my_table WHERE user_id = bucket.user_id.
- Generate the backend migration with
bun db generateand check thatbackend/drizzle/meta/_journal.jsongained the new entry — Drizzle finds pending migrations through the journal, so an SQL file without a journal entry never runs.
PR Flow for Adding Tables
Split the work into two PRs to avoid sync rule mismatches:
-
PR 1 – Backend schemas, migrations, and sync rules
- Backend: table in
backend/src/db/powersync-schema.ts, migration (journal entry included — step 6 above),shared/powersync-tables.ts, and all three sync-rule configs (see step 5 above). - Merge this PR first, then run the migration. On merge,
.github/workflows/images-publish.ymlrebuildsghcr.io/thunderbird/thunderbolt/thunderbolt-powersyncwith the updated sync rules baked in. - Roll the Render
powersyncservice to the new image tag (dashboard → Manual Deploy → Deploy latest reference) before merging PR 2. Preview stacks pick up the new image on their next Pulumi apply.
- Backend: table in
-
PR 2 – Frontend and remaining changes
- Frontend: table in
src/db/tables.ts,src/db/powersync/schema.ts, plus DAL, defaults, reconciliation and UI. - Merge after PR 1’s image is live on Render. Shipping the frontend first causes silent sync failure — the table works locally but never replicates.
- Frontend: table in
Adding Columns to an Existing Synced Table
Every current sync rule is a SELECT *, so a new column needs no rule edit — but it only replicates once the backend migration has run against the database the powersync service replicates from. If the frontend schema ships first, the column stays null across devices while local tests pass. When backend and frontend land in the same PR (e.g. devices.node_id / node_id_attested_at), splitting is unnecessary if the feature tolerates a null value, but the deployer must still run the migration before relying on the column cross-device.
Removing a Synced Table
The add flow in reverse, with one rule that is easy to miss: move the name from powersyncTableNames to legacyPowerSyncTableNames in shared/powersync-tables.ts rather than deleting it, and never re-add it. backend/src/dal/powersync.ts builds a legacyTables set from that list and makes applyOperation accept-and-ignore its upload ops. Without the entry the table is simply unknown, PUT /powersync/upload answers 400 UPLOAD_OPERATION_FAILED, and PowerSync retries the batch forever — so any device that queued a write before the table was dropped wedges its CRUD queue permanently and stops uploading anything else.
Then drop the table’s line from all three sync-rule configs (step 5 above), remove it from src/db/tables.ts and src/db/powersync/schema.ts, and decide separately whether to drop the Postgres table (a retention question, not a sync one).
Two names are on the legacy list today: modes (THU-739) and project_files, which never reached production but was live long enough on preview stacks and local branches to leave queued writes behind.
3. Local Development (PowerSync Docker)
See powersync-service/README.md for full steps. Summary:
- From the repo root:
make up(or frompowersync-service/:docker compose up -d) - PowerSync API: http://localhost:8080
- Postgres: localhost:5433 (use this for the backend so PowerSync and app share one database)
- Backend
.env: setDATABASE_DRIVER=postgres,DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres, and PowerSync vars (see below) - Sync rules in
powersync-service/config/config.yamlmust match backend tables; when you add/change tables, update that file. The backend’s upload validator (validTablesinbackend/src/dal/powersync.ts) is derived frompowersyncTableNamesinshared/powersync-tables.tsautomatically — no manual sync needed there.
Backend PowerSync Env Vars (Local)
POWERSYNC_URL=http://localhost:8080POWERSYNC_JWT_SECRET=powersync-dev-secret-change-in-productionPOWERSYNC_JWT_KID=powersync-devPOWERSYNC_TOKEN_EXPIRY_SECONDS=3600The local config/config.yaml uses HS256 with the same secret (base64) and kid so backend-issued tokens are accepted.
4. Account Deletion
DELETE /v1/account hard-deletes the user row (backend/src/dal/users.ts); every synced table cascades on user_id. After that, any PowerSync token request that resolves to a session whose user is gone answers 410 Gone with code: 'ACCOUNT_DELETED' — which is how the account’s other devices find out.
Deletion is one of the sanctioned hard deletes (see the delete policy in AGENTS.md); the frontend only triggers it. For where the button is and what each device does afterwards, see Delete Account.
5. Device Management
Devices Table
Synced via PowerSync. Defined in backend/src/db/powersync-schema.ts and mirrored in the client’s local DB (src/db/tables.ts) with the booleans as SQLite integers and without app_version — that column is written server-side for operators and no client reads it back.
| Column | Meaning |
|---|---|
id, user_id |
device id and owner; the bridge- and cli- id namespaces are server-reserved |
name, app_version |
from X-Device-Name and X-App-Version on the token request, length-capped |
last_seen, created_at |
last_seen refreshed by upsertDevice on every token request; created_at only on insert |
trusted, approval_pending |
drive the approve/deny queue in Settings > Devices |
public_key, mlkem_public_key |
the device’s public keys for envelope wrapping; see e2e-encryption.md |
revoked_at |
soft revoke; set by the revoke route, never cleared |
device_type |
normal, bridge or cli — one shared revocation lifecycle for all three |
node_id, node_id_attested_at |
iroh P2P identity, written only by dedicated routes (section 6), never by a sync upload |
There is no status column. Device state is three independent fields, so every check has to spell out the combination it means:
| State | Condition |
|---|---|
| Pending approval | trusted = false AND approval_pending = true AND revoked_at IS NULL |
| Trusted | trusted = true AND revoked_at IS NULL |
| Denied | trusted = false AND approval_pending = false AND revoked_at IS NULL |
| Revoked | revoked_at IS NOT NULL |
device_typeputs the iroh bridge and the account-only CLI installation on the same row shape and the same revocation lifecycle, so the DAL guards carry the distinction instead:markDeviceTrusted,denyDevice, andsetDeviceNodeIdall excludeclirows, andgetPendingDevices(src/dal/devices.ts) only offersnormalones for approval (a nulldevice_type, from a row that synced before the column shipped, counts as normal).- Every trust-sensitive column is server-managed.
revoked_at,trusted,approval_pending,public_key,mlkem_public_key,app_version,device_type,node_idandnode_id_attested_atare inuploadDenyColumnsand are stripped from any PowerSyncPUT/PATCH, anddevicesis inuploadDenyDelete, so a syncDELETEfor the table is rejected outright — a row can only be removed through the API route in section 6 (backend/src/dal/powersync.ts, and powersync-upload-authorization.md for the whole gate). node_id/node_id_attested_athold the iroh P2P endpoint identity. Three routes write them — the canary-gatedPOST /devices/:deviceId/node-id, the session-pinnedPOST /devices/me/node-id, and bridge registration (registerBridgeDevice) — and both revoke and deny clear them so a removed device stops being dialable.- See e2e-encryption.md for how
trusted,approval_pending, andpublic_keyare used in the encryption setup and device approval flows.
Settings > Devices
The device list, the pending-approval queue, the revoke and remove buttons, and the pairing-identity dialog are described in Settings > Devices Page, together with the user-facing revoke flow. This document covers the routes those controls call (section 6).
CLI Devices
The CLI uses account-first onboarding and a stable cli-<uuid> installation.
See CLI Device Registration and Logout
for the registration, binding, logout, and revocation contract.
CLI devices are account/revocation records, not PowerSync clients. The backend
rejects cli- IDs from PowerSync token and upload flows. The cli- namespace
is server-reserved, so a device_type = 'cli' row cannot reach these flows under
another ID. CLI provider profiles, model selection, account tokens, and confidential
cache material stay in the CLI’s local state root and do not sync to the web app
or another CLI installation.
A trusted web device can revoke a CLI device through the regular device list.
The revoked CLI cannot continue using the bound session and must complete web
login again. Personal access tokens are separate: THUNDERBOLT_TOKEN supports
headless direct managed inference only, is not device-bound, and must be revoked
through the PAT lifecycle rather than CLI logout. Confidential models require a web
session unless the operator sets CONFIDENTIAL_API_KEYS_ENABLED=true; otherwise a
PAT request fails with WEB_LOGIN_REQUIRED without fallback or replay. See
backend/docs/pat-lifecycle.md for why that
gate is an authorization choice rather than a property of the confidential
transport.
Auth Token and Device ID
Both live in localStorage under fixed keys (src/lib/auth-token.ts).
- Auth token: a fixed key so
getAuthToken()can stay synchronous, as Better Auth requires. Not synced. Cleared byclearAuthToken()— on its own for a session expiry, or as part ofclearLocalData()for a full reset. Nothing callslocalStorage.clear(). - Device id: created on first read. Sent as
X-Device-ID, alongsideX-Device-Name, on PowerSync requests so the backend can register or update the row and enforce revocation; the backend falls back toUnknown devicewhen the name is absent.
6. Backend API
PowerSync Token (GET /powersync/token)
- Session path: the device is validated by
validateDeviceForSync, then the route issues a PowerSync JWT and upserts the device (id, user_id, name, last_seen, andapp_versionfromX-App-Version). - Bearer token only (e.g. credential refresh): the signed bearer token is verified and resolved to a persisted session. If the user row no longer exists (account deleted) → 410 Gone with
{ code: 'ACCOUNT_DELETED' }. Otherwise the same device validation and issuance run. X-Device-IDis required on both paths, so a revoked device cannot buy itself a token by omitting the header.- With
POWERSYNC_JWT_SECRETunset,createPowerSyncRoutesreturns an empty Elysia instance and neither route is mounted at all, so the request 404s. With the secret set butPOWERSYNC_URLempty,/tokenanswers 503 without issuing anything.
PowerSync Upload (PUT /powersync/upload)
- Requires an authenticated, non-anonymous user and an
X-Device-IDheader, and runs the samevalidateDeviceForSync— except that the device must already exist. Only the token route may create one: it passesallowNewDevice: true, soupsertDeviceinserts the row, and only while E2EE is off (with E2EE on, a device that has not been through the envelope flow is untrusted and rejected). - A rejected operation returns 400 with
{ code: 'UPLOAD_OPERATION_FAILED' }, deliberately, so the client does not calltransaction.complete()and PowerSync retries the batch. Ops for a table inlegacyPowerSyncTableNamesare accepted and ignored instead — see Removing a Synced Table for why.
Both routes reject a cross-origin request whose Origin is not in the allowed CORS set with 403 ORIGIN_NOT_ALLOWED; an absent Origin (non-browser clients) is allowed.
Summary for the client. Only the rows with a reason change app state — src/db/powersync/connector.ts maps status and code to a CredentialsInvalidReason; for everything else fetchCredentials returns null, so PowerSync simply retries on its own schedule. Section 7 covers what each reason does.
| Status | Code | Meaning | Client reason |
|---|---|---|---|
| 410 | ACCOUNT_DELETED |
the user row is gone | account_deleted — full reset |
| 403 | DEVICE_DISCONNECTED |
revoked_at is set on this device |
device_revoked — revoked-device modal |
| 403 | DEVICE_NOT_TRUSTED |
a cli- prefixed id, or (E2EE on) an unknown or untrusted device |
none — expected while approval pends |
| 403 | ANONYMOUS_SYNC_FORBIDDEN |
anonymous session | sync_not_permitted — sync disabled |
| 403 | ORIGIN_NOT_ALLOWED |
Origin outside the CORS allowlist |
none |
| 409 | DEVICE_ID_TAKEN |
the id is registered to another user | device_id_taken — full reset |
| 400 | DEVICE_ID_REQUIRED |
no X-Device-ID header |
device_id_required — full reset |
| 401 | — | missing, unsigned, or expired bearer token | session_expired — sign-in modal |
| 503 | — | /token only: the secret is set but POWERSYNC_URL is empty |
none |
Revoke Device (POST /v1/account/devices/:id/revoke)
- Requires an authenticated session and an
X-Device-IDheader naming the calling device — 400 without it. - When the account has encryption metadata, revocation additionally requires proof that the caller holds the Content Key, so a stolen session cannot evict the legitimate devices:
canarySecret(body,maxLength: 500) must be present and verify against the stored hash — 403 otherwise. The check keys on the presence of the metadata row rather than oncanarySecretHash, so a row with a null hash fails closed instead of skipping the gate.- The calling device must itself be trusted, non-revoked, and
device_type = 'normal'— 403 otherwise. An account with no encryption metadata skips both checks.
- Runs in a transaction under the per-account advisory lock:
deleteEnvelope→revokeDevice→revokeDeviceSessions(the last only ifrevokeDevicematched a row). - 204 on success, and idempotent — an already-revoked device matches nothing and its sessions are left alone.
Deleting the envelope is what makes revocation durable: the wrapped Content Key is gone, so the CK cannot be recovered later even if the device’s private key leaks. Nulling node_id also de-authorizes the device on the iroh P2P path — GET /v1/devices/allowlist returns only the trusted, non-revoked, non-CLI node_ids on the account, and a running bridge refreshes that list on its 45-second membership heartbeat and tears down open sessions whose peer has dropped off it (cli/src/iroh/bridge.ts, cli/src/iroh/account-allowlist.ts).
Remove Device (DELETE /v1/devices/:deviceId)
Removal is a hard delete and is deliberately narrow: only a revoked bridge device can be removed (409 otherwise, 404 for a device that isn’t the caller’s). It revokes the device’s sessions, then deletes the row. It exists because bridge registration refuses to resurrect a revoked bridge row with the same NodeId — the tombstone has to be cleared explicitly before pairing again (backend/src/api/encryption.ts).
CLI Device Registration and Logout
PUT /v1/account/devices/clirequires a valid non-anonymous persisted web session plus canonical CLI device, device-name, and app-version headers. It registers or touches the installation and binds that session to the device.POST /v1/account/devices/cli/logoutis remote-first: it revokes the bound CLI device and all of its sessions before returning 204.- Revoked devices return
DEVICE_DISCONNECTED; invalid or expired sessions return 401. Clients do not replay a failed inference request after login.
Managed Catalog Privacy
GET /v1/config publishes managed models through defaults.models: versioned
SharedModel rows without apiKey, plus defaultModelId. Price tables, quota
internals, credentials, and other deployment secrets remain backend-only.
For the mandatory old-client-safe rollout order, see CLI Device Rollout.
Encryption API Endpoints
These handle encryption setup, device approval, iroh pairing, and key recovery. They are mounted on the /v1 app without a sub-prefix (unlike the account routes above), so the full paths are /v1/devices/… and /v1/encryption/canary. All of them require an authenticated session; several also require canary proof-of-CK-possession. Defined in backend/src/api/encryption.ts; see e2e-encryption.md for the key hierarchy they operate on.
| Route | Purpose |
|---|---|
POST /devices |
Register a device with its public key (encryption setup) |
POST /devices/:deviceId/envelope |
Store a wrapped content key, which also marks the target device trusted |
GET /devices/me/envelope |
Fetch this device’s own wrapped content key |
GET /encryption/canary |
Fetch the canary used to verify a recovery key |
POST /devices/:deviceId/deny |
Deny a pending device — requires canarySecret |
POST /devices/me/cancel-pending |
Withdraw this device’s own pending request — X-Device-ID, no canary |
POST /devices/:deviceId/node-id |
Attest another device’s iroh node_id — requires canarySecret |
POST /devices/me/node-id |
Self-enroll this device’s own node_id — no canary, since it can only bind its own row |
GET /devices/allowlist |
The trusted, non-revoked node_ids on the caller’s account (the bridge’s peer allowlist) |
POST /devices/bridge |
Register an ACP/MCP bridge; the only way to create a device_type = 'bridge' row |
DELETE /devices/:deviceId |
Hard-delete a revoked bridge row — see Remove Device above |
7. Frontend: Credentials-Invalid and Reset
A rejected token request is not one outcome. getCredentialsInvalidReason (src/db/powersync/connector.ts) turns the status and code from the table above into one of six reasons, and usePowerSyncCredentialsInvalidListener (src/hooks/use-powersync-credentials-invalid-listener.ts) gives each a different consequence — only three of the six destroy local data, because a session expiry or a device revocation must not cost the user their offline database. A seventh path watches the current device’s synced devices row, so a revocation lands without waiting for the next token refresh.
The per-reason consequences, that watcher’s cold-start guard, what clearLocalData (src/lib/cleanup.ts) tears down, and a side-by-side summary of the two flows are in delete-account-and-revoke-device.md.