Cloud-first onboarding
The first time you open zudo-text — in the browser or on the desktop — a short onboarding flow creates your encrypted workspace, or connects the app to a workspace you already have. This page explains what happens, why it is designed the way it is, and the tradeoffs you should know about.
The flow is the same on both platforms. It began as the web editor's own path and became the shared one in the cloud-primary pivot (epic #4204, decision D2): the workspace is now the only place your content lives, so there is nothing a desktop app can usefully show you before it is unlocked.
The flow
1. Sign in
Authentication is Better Auth. Clicking Sign in opens the sync-server handoff — a same-tab redirect in the browser, or the system browser plus this app's zudotext[- deep link on desktop. The handoff page covers sign-in, creating a new account, resetting a forgotten password, and — where the deploy has them configured — signing in with Google or X; see Better Auth for how each of those works. The handoff returns a single-use OTT; the client exchanges it for a sliding session and mints a short-lived service JWT.
2. Where does this app point?
Each app instance — the ROOT zudotext.app, every generated text app, every browser origin — is bound to exactly one workspace. On the desktop that binding lives in ~/. Boot reads it, then lists the workspaces your account owns, and the pair decides what you see:
| Binding | Workspaces on the account | What you see |
|---|---|---|
| bound or unbound | 0 | Welcome; a stale binding is cleared |
| bound, and the workspace is still there | 1 or more | Loading → cold-start re-arm → editor |
| bound, but the workspace is not on the account | 1 | Stale binding is cleared; that workspace is connected automatically |
| bound, but the workspace is not on the account | 2 or more | Stale binding is cleared; workspace picker |
| not bound yet | 1 | That workspace is connected automatically |
| not bound yet | 2 or more | Workspace picker |
| explicitly generated to create a new workspace | 1 or more | Workspace picker, with the option to create or attach |
Owning several workspaces is normal — one per text app is the expected shape. Earlier versions treated it as a fault and refused to start; the picker replaced that.
The workspace list is fetched on every boot even when the app is already bound, because it is the only way to confirm that the bound workspace still belongs to the signed-in account before trying to re-arm it. When a successful list does not contain the binding, the binding is stale — the workspace may have been deleted, or you may have signed in with a different account — so it is cleared on a best-effort basis and normal unbound routing takes over: one workspace is connected automatically, while two or more go to the picker. If the list itself fails, the binding is left alone and the arm result determines the recovery screen; an offline local mirror may still re-arm it. A transient network failure never clears the binding.
3. Welcome
If the signed-in account owns no workspaces, the app opens a short Welcome screen instead of dropping you straight into a form. It explains that zudo-text is a writing app and that the next step creates your first encrypted workspace. Choose Create your first workspace when you are ready; this opens the genesis wizard. Sign out is always available in case you used the wrong account.
This route depends on the successful empty workspace list, not on the app's existing binding. If an app is still bound to a workspace from another account but the current account owns none, the stale binding is cleared on a best-effort basis and Welcome still appears. Reloading stays on Welcome — it never turns this normal first-run state into a workspace error.
4. Genesis: creating a workspace
The wizard collects three things:
Workspace name — a human label.
Master password — used to derive your encryption key. See Key-at-rest tradeoff.
Starter content preset — Standard (recommended), Minimal, or Full.
No data leaves your device until you click Create workspace. That runs createCloudWorkspace, a single async operation which:
Creates a workspace on the sync server (
POST /). This requires an active subscription; new users receive a free 30-day trial automatically (see Auto-trial policy).api/ v1/ workspaces Derives encryption keys from your master password and a fresh random salt (PBKDF2, 600 000 iterations).
Claims the workspace's encryption on the server (
POST /) so future unlock attempts can be verified without transmitting the key. The claim is atomic: if another device somehow claimed the same workspace first, you get "already claimed — try again" rather than silently overwriting the key material that device is holding.api/ v1/ workspaces/ : id/ setup- encryption Encrypts the starter content and pushes the ciphertexts to the sync server.
Seeds the in-memory workspace model directly from the just-pushed ciphertexts — no round-trip snapshot pull needed.
If any step fails, genesis rolls back (the partially-created workspace is deleted) and you land back at the wizard with a "Try again" button. A reload at this point is safe — you see the wizard again, because nothing was persisted.
The wizard's progress step advances from "Creating…" to "Pushing…" as the operation runs. A double-submission guard prevents an impatient double-click (or React StrictMode) from creating two workspaces.
5. Attaching a workspace you already have
Pick a workspace from the list and the app tries to open it: re-arm from a key already stored on this device, or prompt for the master password. Only once the password has decrypted a snapshot does the app write its binding.
That ordering is the point, and it is worth stating plainly: the binding is persisted only after a successful unlock. Writing it earlier would let you end up bound to a workspace you cannot open, with no way out from inside the app. Every failure short of a decrypted snapshot leaves nothing behind, so backing out or reloading returns you to the same screen rather than to a broken state:
| What went wrong | What you see |
|---|---|
| Wrong master password | Inline error, retry in place |
| The workspace disappeared between listing and arming | "No longer available" → pick another, or create one |
| You signed in with a different account | Stale binding is cleared; one workspace connects automatically, otherwise the picker appears |
| A stale key from another workspace is on this device | The key is discarded and you are asked for the password |
| The network is down mid-unlock | A retry — your stored key is kept, never discarded |
6. Editor mounts
On completion the boot gate mounts the editor — no page reload. The encryption keys are already armed and the workspace model is seeded, so sync activates immediately.
Returning users: cold-start re-arm
Every fresh start — a browser reload, a desktop launch — begins with empty in-memory state. The cold-start re-arm reconstructs it:
Checks for a persisted workspace key on this device.
Validates the persisted seven-day sliding Better Auth session and mints a fresh 15-minute service JWT. There is no refresh-token grant.
Re-arms the cloud-sync client.
Re-derives the encryption keys from the persisted key material.
Fetches the current workspace snapshot (keyset-paginated), walking every page and decrypting all files into the in-memory model.
Advances the device cursor to the first page's head cursor so subsequent real-time pushes from other devices land correctly.
If the persisted key is absent, corrupt, or belongs to a different workspace, you get the unlock screen instead.
Unlock screen
Shown to a returning user whose workspace key is not on this device — a new browser or machine, a private-mode session, or cleared storage. Entering the correct master password:
Fetches the workspace's encryption salt from the server.
Derives a verification hash from the supplied password and the salt.
Sends the hash to
POST /— no key material leaves the device.api/ v1/ workspaces/ : id/ verify- password On success, arms the keys, persists them locally, and seeds the workspace model from the snapshot.
An incorrect password shows an inline error and re-focuses the input for a retry.
The unlock screen is not dismissible. On desktop it briefly was, back when the app was local-first and a locked workspace still left local files editable. Cloud-primary removed that: dismissing would drop you into an editor with nothing to edit and nowhere to save.
Your settings follow you
.zudotext.settings.json is a document inside the workspace, so your colors, editor options, shortcuts, framesets, and pins are the same on every device (decision D3). Before unlock the app runs on defaults; nothing is written to a local settings file any more.
One consequence to know about: the settings document is synced whole. If two devices change different settings at the same moment, the one that saves last wins and the other device's change is lost. In practice settings are written rarely — essentially only while a settings dialog is open — and anything that genuinely should differ per machine belongs in a device override instead.
A few things stay machine-local by necessity, because they are what the app needs before it can open the workspace: the sync server address (build configuration — VITE_SYNC_SERVER_URL — not a setting any more), the publish server address (likewise, VITE_PUBLISH_SERVER_URL), and the Better Auth authority (VITE_BETTER_AUTH_URL).
Key-at-rest tradeoff
The derived encryption key is persisted locally so relaunching does not require the master password every time. Storage differs by platform:
Desktop Tauri on macOS: the key is stored per workspace in your login Keychain, not in WebView
localStorage. Automatic startup reads run with Keychain interaction disabled, so after an app update or ad-hoc-signed rebuild an item that requires renewed permission is treated as absent and the app asks for the workspace password. A later explicit save or update may still show the macOS prompt; choosing Always Allow prevents repeat prompts until the next update or rebuild. If you deny access, no plaintext fallback is created.iOS and web: the key remains in per-workspace
localStorage. It is origin-sandboxed and receives the device or browser profile's disk protections, but remains plaintext at rest and readable by code running in that realm (#2335). Native iOS Keychain / Secure Enclave hardening is still deferred pending the paid Apple Developer membership (#4458).
A macOS prompt during an explicit save or update after a local rebuild is expected for this project's ad-hoc-signed apps (there is no Developer ID signature), not an indication that the stored key was lost. Automatic startup reads skip prompt-requiring items instead.
If you are using a shared or untrusted device, sign out after each session — that clears the persisted key.
Self-device filter and seeding rationale
The real-time sync pull endpoint applies a self-device filter: changes pushed by your device are excluded from the pull response so you do not redundantly re-apply your own writes. The snapshot endpoint intentionally has no such filter — it returns all current files regardless of origin.
The cold-start re-arm uses the snapshot, not the pull, because:
On a fresh start the device cursor is zero and the full file set is needed.
A pull from cursor=0 would include the device's own genesis writes AND writes from other devices — the self-filter would incorrectly exclude exactly the files the current device wants to see.
Using the snapshot ensures the workspace creator always sees their own starter content immediately after reload.
Genesis seeding is the one canonical entry point for the initial file set: createCloudWorkspace encrypts, pushes, and seeds atomically. Any code path that bypasses it (writing to the in-memory model without pushing) would leave the client's view inconsistent with the server after reload — the next snapshot would overwrite those local-only writes.
Auto-trial policy
Application access is fail-closed: a Better Auth identity must have a matching users.sub row (better_auth|<user.id>). Provisioned user rows receive the subscription state that gates workspace creation, file push, snapshot pull, and the sync WebSocket; the temporary dev-login path keeps its existing test-only provisioning behavior.
Trial expiry is not checked server-side at sync time; the client handles post-trial downgrade via the subscription endpoint. After 30 days, users who have not subscribed see a subscription prompt; their encrypted data remains on the server and accessible once they subscribe.
Storybook story
A Storybook story for the genesis wizard is at:
tauri-
It renders the wizard in isolation with controllable resolveToken and createWorkspace props, so you can manually verify the form → progress → done step transitions without a live backend.
Human verification checklist
The following require a real browser or desktop session and cannot be automated in CI:
Open the app in a fresh browser profile (or launch a freshly generated text app).
Click Sign in and complete the Better Auth handoff.
Confirm Welcome appears when the account owns no workspace. Reload and confirm Welcome appears again without a workspace error.
Click Create your first workspace and confirm the genesis wizard appears.
Fill in a workspace name and master password, select a preset, click Create workspace.
Confirm the editor mounts with no console errors (open DevTools → Console before step 5).
Confirm the real-time sync WebSocket connects (DevTools → Network → WS frames).
Reload. Confirm the editor remounts without the unlock screen (stored key re-arms).
In a new private window, sign in again. Confirm the unlock screen appears, and that the master password opens the workspace with the previously-created notes visible.
With two or more workspaces on the account, launch a second app instance and confirm the workspace picker appears and connects the chosen workspace.
Delete a bound workspace from another client, then relaunch the app bound to it — confirm the "no longer available" screen offers the account's other workspaces.
See the verification checklist comments on epics #2374 and #4204 for current status.