Account API
/ (workers/ and account-key-handlers.ts) is the server side of the account store — a per-user, end-to-end-encrypted file store independent of any single workspace. See that page for the crypto model (key generation, wrapping, enrollment); this page covers the HTTP contract.
Access gate
Every route under / passes through requireAccountAccess() (workers/) before anything else:
A session-authenticated request always passes.
A Personal Access Token passes only when it has the
fullscope and no workspace binding.Anything else (a narrow-scope PAT, or a workspace-bound PAT even with
fullscope) gets403 Token is not authorized for the account store.
After that gate, a second middleware requires an active subscription (hasActiveSub()) — without one, every route returns 402 Subscription required.
Caps
| Cap | Value | Rejection |
|---|---|---|
| Files per user | 2,000 (ACCOUNT_MAX_FILES) | 409 account_file_limit |
| Bytes per blob | 256 KiB (ACCOUNT_MAX_BLOB_BYTES) | 413 account_blob_too_large |
Both are per-user caps (not the workspace asset quota) and are also reported by GET /sync/status.
Key gate on writes
PUT /files/:encPath requires an X-Key-Id header naming the account's current key:
Missing or malformed (doesn't match
ACCOUNT_KEY_ID_PATTERN,^[A-Za-z0-9_-]{1,128}$) →400.Present but stale (doesn't match the
account_keysrow for this user) →409key_id_mismatch, with the currentkeyId/keyVersionin the body so the client can re-enroll.
The file-count cap and the current-key check are both enforced inside the same upsert statement, so neither a concurrent create nor a concurrent reset can slip past them.
Routes
Realtime
| Route | Notes |
|---|---|
POST /ws-ticket | Mints a short-TTL, single-use ticket for the per-user realtime channel. |
GET /ws?token=<ticket> | Consumes the ticket and upgrades into the SyncRoom named user:<userId>. Registered before the bearer-auth middleware, like the workspace WS route — a browser socket cannot send an Authorization header. |
Files
| Route | Notes |
|---|---|
PUT /files/:encPath | Uploads an encrypted blob. Body is the raw ciphertext. Requires X-Key-Id (see above); X-Content-Hash names the R2 key (account/<userId>/blobs/<hash>); X-Device-Id is recorded on the version row. Response includes cursor, the change-log id this upload produced — the caller's next sync/pull baseline. Deduplicates by content hash: an existing blob at the same key is not re-uploaded. Prunes now-unreferenced older versions in the same batch and enqueues their R2 keys onto r2_gc_queue (owner_kind: "account") only when no surviving version still references them. |
GET /files/:encPath | Downloads the current blob. Response carries X-File-Version and, when present, X-Key-Id. |
Sync
| Route | Notes |
|---|---|
POST /sync/push | Delete tombstones only — content uploads go through PUT /files/:encPath, not push. Same conflict rule as the workspace push: any change to the path after baseCursor is a conflict unless it is this device's identical retry (same deviceId and contentHash). |
POST /sync/pull | Changes after cursor, oldest first, clampDocumentChangeLimit-bounded. Each row carries keyId so a puller can tell which account key encrypted it. |
GET /sync/snapshot | Keyset-paginated live file listing (limit, pageCursor). The head cursor is read before the files; only the first page's cursor is a safe resume point for a follow-up pull. |
GET /sync/status | { cursor, fileCount, keyId, keyVersion, limits } — the caps table above, plus the account's current key identity. |
Keys and wraps
See Account Store for the enrollment state machine these routes back.
| Route | Notes |
|---|---|
POST /keys | Creates the account key, first-writer-wins (ON CONFLICT(user_id) DO NOTHING). Body { keyId?, wrap? } — an optional client-proposed keyId plus a first wrap, written in the same D1 batch as the key row, so a crash between "key created" and "wrap stored" can never leave an unwrappable key. Returns { keyId, keyVersion, created } — created: false means another device won the race; the caller should re-list wraps. |
GET /key-wraps | The account's current keyId/keyVersion plus every wrap of it. keyId/keyVersion are null when no key exists yet. |
GET /key-wraps/last-wrap-check/:workspaceId | { isLastWrap } — whether this workspace holds the only wrap of the current key. |
PUT /key-wraps/:workspaceId | Stores this workspace's wrap of the current key. 409 key_id_mismatch if the given keyId/keyVersion is not current. |
POST /reset | Compare-and-swap: replaces the key (expectedKeyId must match the current row, else 409), bumps key_version, and purges every row (files, versions, changes, wraps) not under the new key — scoped so a retried reset cannot touch files written after the first attempt already succeeded. All-or-nothing with the new key's first wrap: the body must be { expectedKeyId, keyId, wrap } (missing keyId or wrap, or wrap.keyId ≠ keyId → 400). wrap.keyVersion must be the current version + 1 (else 409 key_id_mismatch) and its workspace must be the caller's (else 404); both are checked before the swap, and the wrap insert also runs inside the swap's D1 batch, so a wrap that cannot be stored rolls the swap back. The account never holds a key no workspace can open. Returns { keyId, keyVersion }. |
key_version starts at 1 when a key is created and increases by exactly 1 on each reset.
bridge.accountFiles
packages/ implements AccountFilesAPI, the renderer-facing facade over an AccountSyncEngine. Its states:
| State | Meaning |
|---|---|
unavailable | No engine armed yet (enrollment hasn't resolved who is signed in, or resolved to unavailable). |
ready | Armed and connected. |
keyMismatch | The engine detected a stale key (a 409 key_id_mismatch from a write) and needs re-enrollment. |
offline | Armed, but the account WS is not connected. isReady() is still true here — reads/writes continue and queue in the outbox. |
Singleton wiring: AccountSyncEngine needs a signed-in userId at construction, so — unlike the workspace's CloudSyncClient, armed at adapter bootstrap — it cannot exist until the renderer lifecycle resolves who is signed in. TauriAdapter and RestAdapter both call the zero-arg createAccountFilesApi() at bootstrap; the lifecycle module calls setAccountSyncEngine() once it has armed an engine, after which the same facade instance starts serving real content with no re-wiring. ensureAccountSyncClient() constructs the one AccountSyncClient for the app instance, shared between the WS-ticket mint and the engine so both present the same device identity.
The mock adapter's accountFiles is a fixed in-memory fixture, not backed by this state machine — see the note on the Account Store page.