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/core must not import any apps/*.
  • packages/api-contract contains 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.

PlatformRoleScopeOut of scope
Web (apps/web)Full productEvery feature, including Admin—
Desktop (apps/desktop)Installable web app. Runs standalone (local mode) or against a server, with one tab per serverEverything the web app does, plus tray, auto-update, OS keychain, OAuth loopback, a bundled local server, and per-server webview tabsNo business UI or business logic. The Tauri project contains only the Rust shell, windows, tray, updater, sidecar process management, and session commands.
Mobile (planned)ConsumptionChat, conversations, file upload, camera/voice, push notifications, account settingsAdmin, 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/core is 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 --local and --data-dir flags 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. BroadcastChannel is 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 the tabs_* commands; content tabs cannot call them.

Shared-Code Rules#

packages/core#

  • Zero platform dependencies. tsconfig.json sets lib: ["ES2022"] with no DOM and no ambient types, and biome.jsonc blocks react, react-dom, react-native, next, expo, and @tauri-apps/* through noRestrictedImports. A violation fails pnpm 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 test runs Node's built-in test runner; no browser or device is needed.
  • Grows on demand. Logic moves out of apps/web only 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/core only.

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's resolveApiBaseUrl. The web app reads the user-chosen server from the platform layer through registerRuntimeApiBaseURLResolver. The desktop shell persists the server per tab and returns it through the get_server command.
  • Credential delivery is decided by the server from a request header. A request that carries X-Client-Platform: desktop|mobile gets 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/version returns the product, version, commit, build time, and build ID, which clients use to show the running build. The provider auth bridge carries its own protocolVersion for the OAuth handoff.

Security Constraints#

These must not be relaxed when adding desktop or mobile clients:

  1. CORS stays an explicit allowlist. middleware/cors.go matches the request Origin against cors_allow_origin and sets Access-Control-Allow-Credentials: true. The Tauri webview origins tauri://localhost and http://tauri.localhost are in the defaults, and local mode narrows the list to the webview. Production rejects *.
  2. Access tokens live in memory only. Every platform sends Authorization: Bearer with an in-memory access token. No platform persists it.
  3. 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.dev in development) and the account is refresh-token:<origin>, or refresh-token:local in local mode. JavaScript has no read path: store_session is write-once, and refresh_session reads 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-Platform names 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 inside refresh_session). AuthHost.refreshSession returns {accessToken, sessionID}; the refresh token never passes through packages/core.
  4. 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.
  5. The desktop shell exposes no shell, filesystem, or HTTP plugin to the page. capabilities/main.json grants core: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.json grants the strip only the tabs_* commands and window dragging. The CSP keeps script-src at 'self' and blocks objects and frames; connect-src allows http(s) because the user chooses the server.
  6. 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 a refresh_token_reuse_detected auth event. Revocation commits inside the transaction and is reported outside it, so it cannot be rolled back.
  7. 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 VERSION file is the single source of the version number. scripts/sync-version.mjs lists every target: the root and package manifests, apps/web, apps/desktop (package.json, tauri.conf.json, Cargo.toml), and backend/package.json plus the Swagger @version annotation. Adding a client adds one target.
  • node scripts/sync-version.mjs --check runs in predev / prebuild and 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.Z is stable and vX.Y.Z-beta.N is beta. Both use the same signed updater key. desktop-channel.yml refreshes the rolling desktop-beta manifest 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 is apps/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.