zudo-text

検索したい単語を入力

いつでも検索バーを開ける

Account API

/api/v1/account/* (workers/sync-server/src/handlers/account-handlers.ts 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 /api/v1/account passes through requireAccountAccess() (workers/sync-server/src/scopes.ts) before anything else:

  • A session-authenticated request always passes.

  • A Personal Access Token passes only when it has the full scope and no workspace binding.

  • Anything else (a narrow-scope PAT, or a workspace-bound PAT even with full scope) gets 403 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

CapValueRejection
Files per user2,000 (ACCOUNT_MAX_FILES)409 account_file_limit
Bytes per blob256 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_keys row for this user) → 409 key_id_mismatch, with the current keyId/keyVersion in 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

RouteNotes
POST /ws-ticketMints 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

RouteNotes
PUT /files/:encPathUploads 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/:encPathDownloads the current blob. Response carries X-File-Version and, when present, X-Key-Id.

Sync

RouteNotes
POST /sync/pushDelete 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/pullChanges after cursor, oldest first, clampDocumentChangeLimit-bounded. Each row carries keyId so a puller can tell which account key encrypted it.
GET /sync/snapshotKeyset-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.

RouteNotes
POST /keysCreates 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-wrapsThe 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/:workspaceIdStores this workspace's wrap of the current key. 409 key_id_mismatch if the given keyId/keyVersion is not current.
POST /resetCompare-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/backend-bridge/src/account-core/account-files-api.ts implements AccountFilesAPI, the renderer-facing facade over an AccountSyncEngine. Its states:

StateMeaning
unavailableNo engine armed yet (enrollment hasn't resolved who is signed in, or resolved to unavailable).
readyArmed and connected.
keyMismatchThe engine detected a stale key (a 409 key_id_mismatch from a write) and needs re-enrollment.
offlineArmed, 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.