This page defines the repository layout, platform boundaries, shared-code rules, and security constraints. Every client follows it. An implementation that conflicts with it is an architecture violation.
Repository Layout#
DEEIX Chat is a pnpm + Turborepo monorepo with one backend and one directory per client platform:
text
Dependencies only point downward:
text
apps/*must not import each other.packages/coremust not import anyapps/*.packages/api-contractcontains generated output only; no hand-written logic.
Platform Boundaries#
Each client has a fixed scope. The goal is one implementation of each rule, not the maximum number of features per platform. Per-feature support is in the Platform matrix.
| Platform | Role | Scope | Out of scope |
|---|---|---|---|
Web (apps/web) | Full product | Every feature, including Admin | — |
Desktop (apps/desktop) | Installable web app. Runs standalone (local mode) or against a server, with one tab per server | Everything the web app does, plus tray, auto-update, OS keychain, OAuth loopback, a bundled local server, and per-server webview tabs | No business UI or business logic. The Tauri project contains only the Rust shell, windows, tray, updater, sidecar process management, and session commands. |
| Mobile (planned) | Consumption | Chat, conversations, file upload, camera/voice, push notifications, account settings | Admin, billing back office, knowledge-base management, MCP/Skill configuration |
Two signals point to a missing abstraction:
- Chat logic is about to be written inside the desktop project. The web app is missing an abstraction.
- Mobile and web each implement the sign-out rule.
packages/coreis missing a module.
Local Mode#
The desktop client bundles the Go backend so the app runs with nothing deployed. Design constraints:
- Same backend code, same security policy. Local mode is the SQLite profile plus per-install secrets (mode
0600) and the full production validation. The backend adds only the--localand--data-dirflags and one grant-exchange endpoint (POST /api/v1/auth/local/exchange) mounted only in local mode. There is no relaxed-local branch. - Loopback only, random port. The parent process reads the origin and a one-time login grant from a single JSON line on the sidecar's stdout. Nothing else is written to stdout; logs go to stderr.
- Single-use grant, two-minute lifetime, held only by Rust. The shell exchanges the grant for a session and stores the refresh token in the keychain. The webview never sees the grant. A new grant requires restarting the sidecar, so grants cannot be minted over the network.
- No password for the local user.
PasswordEnabled=false. Password login is unavailable and no first-login onboarding runs. - Switching servers signs out. Each server has its own keychain entry. Leaving or closing a server deletes it, so a token is never replayed to a different operator.
Multi-Server Tabs#
One window, one 40px tab-strip webview, and one content webview per tab. Isolation is at the webview level, not in frontend state: each tab is a complete web app instance with its own DOM, caches, SSE connections, and in-memory session. The frontend never models "several servers at once". Constraints:
- A tab is bound to at most one server. Two tabs never target the same server; opening one that is already open activates the existing tab.
- Session commands resolve their server from the calling webview's label, so a tab can only touch its own credential.
BroadcastChannelis disabled on desktop so a token cannot sync from one server to another. - Closing a tab forgets that server: its refresh token is deleted, and the sidecar stops when the last local tab closes.
- The tab strip (
/desktop/tabs) is shell UI on the same design system. It is granted only thetabs_*commands; content tabs cannot call them.
Shared-Code Rules#
packages/core#
- Zero platform dependencies.
tsconfig.jsonsetslib: ["ES2022"]with no DOM and no ambient types, andbiome.jsoncblocksreact,react-dom,react-native,next,expo, and@tauri-apps/*throughnoRestrictedImports. A violation failspnpm check. - No I/O. It does not call
fetch,localStorage,SecureStore, or Tauri commands. Hosts inject all I/O through interfaces. - Testable in Node.
pnpm --filter @deeix/core testruns Node's built-in test runner; no browser or device is needed. - Grows on demand. Logic moves out of
apps/webonly when a second client needs it. No empty scaffolding.
UI Is Not Shared; Logic Must Be#
- Web uses React DOM and mobile uses React Native, so the UI layers are independent.
- Authentication, token refresh, server discovery, SSE parsing, and message reducers belong in
packages/coreonly.
Client-Server Contract#
Every client connects to a server the user deploys, so:
- The API address is runtime configuration. Priority is runtime override → build-time variable → page origin, implemented in
packages/core'sresolveApiBaseUrl. The web app reads the user-chosen server from the platform layer throughregisterRuntimeApiBaseURLResolver. The desktop shell persists the server per tab and returns it through theget_servercommand. - Credential delivery is decided by the server from a request header. A request that carries
X-Client-Platform: desktop|mobilegets the refresh token in the response body; without the header the server uses an HttpOnly cookie. Both paths share the same rotation and revocation logic. - The backend has no per-client special cases. Desktop and mobile reuse the same path: server discovery → login → refresh → session.
- The backend reports its build.
/api/v1/versionreturns the product, version, commit, build time, and build ID, which clients use to show the running build. The provider auth bridge carries its ownprotocolVersionfor the OAuth handoff.
Security Constraints#
These must not be relaxed when adding desktop or mobile clients:
- CORS stays an explicit allowlist.
middleware/cors.gomatches the request Origin againstcors_allow_originand setsAccess-Control-Allow-Credentials: true. The Tauri webview originstauri://localhostandhttp://tauri.localhostare in the defaults, and local mode narrows the list to the webview. Production rejects*. - Access tokens live in memory only. Every platform sends
Authorization: Bearerwith an in-memory access token. No platform persists it. - Refresh tokens are stored per platform, and the read paths do not fall back to each other.
- Web: HttpOnly cookie, lifecycle managed by the server.
- Desktop: OS keychain. The service is the bundle identifier (
com.deeix.chat.desktop,com.deeix.chat.desktop.devin development) and the account isrefresh-token:<origin>, orrefresh-token:localin local mode. JavaScript has no read path:store_sessionis write-once, andrefresh_sessionreads the token in Rust, refreshes against the pinned origin, rotates the stored token, and returns only{accessToken, sessionID}. Page XSS cannot reach the long-lived credential. - Mobile (planned):
SecureStore/ Keystore. - When
X-Client-Platformnames a native client, the backend reads the refresh token only from the request body and returns it only in the response body; the browser path only reads the cookie. There is no cross-fallback. - Rotation write-back is done by whoever holds the token (browser: server
Set-Cookie; desktop: Rust insiderefresh_session).AuthHost.refreshSessionreturns{accessToken, sessionID}; the refresh token never passes throughpackages/core.
- Refresh and logout order exists once.
createAuthClient(host)implements in-flight de-duplication, the session revision guard, and 401 → refresh → single retry → clear on a terminal error. Each platform injects storage, networking, and an optional cross-context lock. - The desktop shell exposes no shell, filesystem, or HTTP plugin to the page.
capabilities/main.jsongrantscore:default, window focus, the updater and process plugins, URL opening for http(s) in the system browser, and the session and OAuth-loopback commands.capabilities/chrome.jsongrants the strip only thetabs_*commands and window dragging. The CSP keepsscript-srcat'self'and blocks objects and frames;connect-srcallows http(s) because the user chooses the server. - Refresh-token reuse detection. After rotation, the previous token hash stays valid for 15 seconds (
refreshTokenPreviousHashGrace) to tolerate a lost rotation response. Reuse after that window revokes the whole session (revoke_reason = refresh_token_reuse) and records arefresh_token_reuse_detectedauth event. Revocation commits inside the transaction and is reported outside it, so it cannot be rolled back. - Keys and certificates live only in CI secrets. Apple Developer ID, notarization, Windows code-signing certificates, the updater key, and Android keystores are never committed.
Versioning and Release#
- The root
VERSIONfile is the single source of the version number.scripts/sync-version.mjslists every target: the root and package manifests,apps/web,apps/desktop(package.json,tauri.conf.json,Cargo.toml), andbackend/package.jsonplus the Swagger@versionannotation. Adding a client adds one target. node scripts/sync-version.mjs --checkruns inpredev/prebuildand in CI. A version mismatch fails the build.- One tag (
v*.*.*) builds every artifact: Docker images for amd64 and arm64 on GHCR and Docker Hub, and desktop bundles for four targets. The desktop workflow creates a draft GitHub Release; publishing it is what ships the update. - Desktop channels:
vX.Y.Zis stable andvX.Y.Z-beta.Nis beta. Both use the same signed updater key.desktop-channel.ymlrefreshes the rollingdesktop-betamanifest when a release is published. Mobile EAS Build is not wired up yet. - The Docker image serves the static build from
/app/frontend/out(FRONTEND_DIST_DIR). The in-repo build path isapps/web/out.
CI Gates#
text
turbo.json declares per-package dependencies, so a new apps/* or packages/* directory joins pnpm check and pnpm test automatically. apps/desktop's check script runs cargo check, so Rust compile errors surface on a PR instead of at release time.