# Shipping Ohiyo to real people The family distribution path is **web-first**: open `https://app.ohiyo.gg` in a browser, with an optional browser-managed home-screen/Dock installation. The web client and backend must be deployed from the same tested revision. Native Mac downloads remain experimental and are not a recommended family installation path. See [current readiness gates](LAUNCH-STATUS.md) before changing production. Ohiyo needs a **server in the cloud** whether people use the browser or desktop client. The native packaging instructions below are for a future signed release; Apple Developer enrollment is not required to use the web app. ``` ┌─────────────────────────┐ ┌──────────────────────────────────┐ │ Ohiyo.app / .msi │ HTTPS │ Railway or Fly.io │ │ (Tauri desktop client) │ ──────▶ │ • axum server (Docker) │ │ React bundle inside │ WSS │ • SQLite + uploads (volume) │ │ a native window │ ◀─────▶ │ • call relay (TURN, optional) │ └─────────────────────────┘ └──────────────────────────────────┘ ``` - **Backend host:** any host that runs a Docker image with one persistent volume. ohiyo.gg runs on Railway ([§1](#1-deploy-the-backend)); the repo also ships a `fly.toml` for Fly.io. Either way it is a single machine with a persistent volume. - **Self-hosting:** the [single-host Compose baseline](docs/self-hosting.md) keeps AI off by default and includes whole-volume backup, restore and upgrade procedures. - **Signing:** the published v0.3.1 Mac installers fail Gatekeeper. New native releases require Developer ID signing and notarization; see [§5](#5-code-signing--auto-update-required-for-release-downloads). The updater's separate verification key does not substitute for Apple signing. --- ## 1. Deploy the backend Ohiyo needs exactly **one** running server and **one** persistent volume mounted at `/data` (the SQLite database and uploads). Any host that runs a Docker image with a volume works; both paths below build `server/Dockerfile`. Both Dockerfiles require the **repository root as their build context**: the client imports `brand/kikka-chinchilla-clean.png`, and the server embeds `.agents/skills/`. For local builds, run `docker build -f server/Dockerfile .` or `docker build -f client/Dockerfile .` from the repo root. The root `.dockerignore` and `.railwayignore` allow only build inputs, excluding local secrets, databases, uploads and build artifacts. Keep their allowlists in sync when adding build inputs; `client/.env.production` is deliberately included and must contain only public config. ### Railway (what ohiyo.gg runs on) From the repo root, with the Railway CLI logged in. Create a project, a service for the server and a volume mounted at `/data` (`railway volume add`, or the dashboard). In each service's **Settings**, leave **Root Directory** at `/` (not `server/` or `client/`) and explicitly configure these build/deploy fields: | Service | Dockerfile path | Health check | Timeout | | --- | --- | --- | --- | | `ohiyo-server` | `server/Dockerfile` | `/healthz` | 120 seconds | | `ohiyo-web` | `client/Dockerfile` | `/` | 60 seconds | Set one replica and an `ALWAYS` restart policy; the API must not overlap two instances on its SQLite volume. On the current Railway API, `railwayConfigFile` is deprecated and rejects updates. The builder enum also no longer accepts `DOCKERFILE`: use the direct `dockerfilePath` setting (with builder `RAILPACK`) and verify the build logs actually select that Dockerfile. The nested `server/railway.json` and `client/railway.json` retain the intended settings as references, but do not assume the platform applies them automatically. Clear any old `RAILWAY_DOCKERFILE_PATH` override pointing to `Dockerfile`. If using Git-triggered deploys with watch paths, include `.agents/skills/**` for the server and `brand/**` for the web app, as well as each service's own directory and the root ignore files. These service settings are required even for CLI uploads; Railway does not select a Dockerfile from the `--service` name. Read back the effective settings before uploading. Then: ```bash # Required: the server REFUSES to start in release without JWT_SECRET and PUBLIC_BASE_URL. railway variable set --service ohiyo-server \ JWT_SECRET="$(openssl rand -base64 48)" \ PUBLIC_BASE_URL="https://.up.railway.app" \ OHIYO_OPERATOR_USER_IDS="" \ RAILWAY_RUN_UID=0 PORT=3000 BIND_ADDR=0.0.0.0:3000 TRUSTED_PROXY_HOPS=2 # Build server/Dockerfile on Railway and wait for the health check (/healthz). railway up . --path-as-root --service ohiyo-server --ci curl https://.up.railway.app/healthz # -> ok ``` `OHIYO_OPERATOR_USER_IDS` is a comma-separated list of user ids. Only those accounts can run Discord imports or change an Instant Server's tier; unset means nobody can. Why the other variables are set the way they are is under [Railway settings and gotchas](#railway-settings-and-gotchas). ### Optional cohosted school-practice worker (Railway API service only) The server image includes **Node.js 26** and the actual **Pi 1.3.0** worker, installed from `worker/package-lock.json` with `npm ci --ignore-scripts --omit=dev`. Packaging does not enable it. With `KIKKA_EMBEDDED_WORKER` unset (or anything other than exactly `1`), startup and Litestream restore/replication remain unchanged. There is no separate public worker service or port: only the existing API port 3000 is exposed. Include `worker/**` in the API service's Git deploy watch paths. The image includes reviewed, nonsecret configurations from `worker/config/practice-routing.json` and `worker/config/practice-worker.json`, at the immutable paths `/opt/ohiyo-worker/config/practice-routing.json` and `/opt/ohiyo-worker/config/practice-worker.json`. The school/no-browse worker configuration is exactly: ```json {"approvedUrls":[]} ``` Do not add `computer` configuration or `KIKKA_TRAVEL_URL`: this embedded school profile refuses them, so computer, coding and travel are not enabled. Empty `approvedUrls` is accepted only with routed model access; the non-routed profile still requires 1–20 pages, and both profiles retain the 20-page maximum. The reviewed route selects Groq `openai/gpt-oss-120b` for all four roles, with a 131,072-token context, 4,096-token output maximum and budget rates of **$0.15 input / $0.60 output per million tokens**. Verify provider pricing before a later rollout; these are budget rates, not a free-tier promise. The connection uses `groq/*` mapping and reads `KIKKA_DEFAULT_API_KEY` **only from the API environment**. A missing/invalid route is not permission to fall back to another provider. No key belongs in these JSON files or public Vite variables. Supply a stable `KIKKA_CONNECTION_KEY` (at least 32 characters) to encrypt saved personal connections; preserve it across restarts. Setting `KIKKA_USER_ID='*'` makes the feature available to all authenticated users, not unauthenticated visitors; omitting it disables Kikka. Per-operation provider/data/budget consent remains required. To restrict rollout, use a specific authorized user id instead. After those files and authorized routes exist, set these **on `ohiyo-server`, not `ohiyo-web`**, in addition to all existing production variables: ```bash railway variable set --service ohiyo-server \ KIKKA_EMBEDDED_WORKER=1 \ KIKKA_RUNTIME=pi \ KIKKA_USER_ID='*' \ KIKKA_WORKER_TOKEN="$(openssl rand -hex 32)" \ KIKKA_WORKER_CONFIG=/opt/ohiyo-worker/config/practice-worker.json \ KIKKA_WORKER_DIRECTORY=/data/kikka \ KIKKA_WORKER_PORT=3102 \ KIKKA_WORKER_URL=http://127.0.0.1:3102/command \ KIKKA_MODEL_ROUTING=1 \ KIKKA_ROUTING_CONFIG=/opt/ohiyo-worker/config/practice-routing.json \ KIKKA_ROUTING_ACCESS_URL=http://127.0.0.1:3000/api/v1/burrow/internal/model-access ``` Use a stable private service token of **at least 32 characters** shared by the API and worker; the example generates 64 hex characters. Preserve the value after initial provisioning: it also protects saved worker subscription accounts. Never put it in public Vite variables. The directory defaults to `/data/kikka` if unset; any different directory is rejected in embedded mode. Ensure `/data` is writable by the service user (`RAILWAY_RUN_UID=0` as above), and preserve/back up the whole volume, including worker journals. Litestream backs up the API SQLite database, **not** the worker's other databases/files. If changing either port, keep the loopback URLs consistent with `KIKKA_WORKER_PORT` and the API's `BIND_ADDR` port. The API retains its complete environment. The worker receives **only** `KIKKA_WORKER_CONFIG`, `KIKKA_WORKER_DIRECTORY`, `KIKKA_WORKER_TOKEN`, `KIKKA_WORKER_PORT`, `KIKKA_MODEL_ROUTING` and `KIKKA_ROUTING_ACCESS_URL`. It resolves authorized credentials through the loopback API at dispatch time; JWT, object-store, connection-encryption and ambient model-provider keys are not inherited. This is environment isolation, not a separate OS/filesystem sandbox. Opt-in starts a subreaped supervisor, the worker and the unchanged API/Litestream startup path. Either peer exiting, even successfully, terminates the other and fails the container. SIGTERM/SIGINT stop both process groups; after five seconds, unresponsive processes are killed. The supervisor never restarts a worker or resubmits an uncertain operation. Hosting-level restart policy is separate; reconcile saved outcomes before authorizing another attempt. Startup/readiness does not initiate model inference. This packaging does not bypass user approval, turn on a practice request, or certify model output for school use. To return to legacy API-only startup, unset `KIKKA_EMBEDDED_WORKER` and restart the service; keep `/data/kikka` and stable secrets for reconciliation. No deployment, configuration provisioning or live model smoke test is implied by these steps. ### Fly.io (alternative) The repo also ships `server/fly.toml`. Management commands below run from `server/`; the deploy command switches to the repo root for the shared build context. ```bash cd server # Install the Fly CLI once, then log in. brew install flyctl # or: curl -L https://fly.io/install.sh | sh fly auth login # App names are GLOBALLY unique. Pick yours and set it as `app = "..."` in # fly.toml (it ships as "ohiyo"). Create the app without deploying yet: fly apps create ohiyo- # Create the persistent volume that fly.toml mounts at /data (DB + uploads). # Keep it in the same region as primary_region in fly.toml (default: iad). fly volumes create ohiyo_data --region iad --size 3 # 3 GB # Required config — the server REFUSES to start in release without both. # JWT_SECRET stable session-signing key (else every restart logs everyone out) # PUBLIC_BASE_URL this app's public URL — it prefixes stored avatar/banner URLs, # so a wrong/unset value bakes dead localhost links into the DB. fly secrets set JWT_SECRET="$(openssl rand -base64 48)" fly secrets set PUBLIC_BASE_URL="https://ohiyo-.fly.dev" # Recommended: the key two-factor sign-in secrets are encrypted under. Without it they're # encrypted under a key derived from JWT_SECRET, and rotating JWT_SECRET breaks everyone's # authenticator codes (recovery codes keep working). fly secrets set OHIYO_TOTP_KEY="$(openssl rand -base64 48)" # Operator accounts: a comma-separated list of user ids. Only these accounts can run # Discord imports or change an Instant Server's tier. Unset means nobody can. fly secrets set OHIYO_OPERATOR_USER_IDS="" # Ship it with the repository root as context, preserving the service config. (cd .. && fly deploy --config server/fly.toml) # Verify. curl https://ohiyo-.fly.dev/healthz # → ok ``` Notes baked into the config: - **`Dockerfile`** is a multi-stage Rust build → a slim Debian runtime with CA certificates, Litestream, Discrawl, Node 26 and the opt-in Pi worker. SQLx bundles SQLite and reqwest uses rustls; no system OpenSSL is needed. - **`fly.toml`** keeps **one machine always running** (`auto_stop_machines = off`, `min_machines_running = 1`). A chat/voice server holds live WebSocket connections and presence — it must not sleep. - **WebSockets** (`/gateway`) work over Fly's HTTP service automatically. - The volume at `/data` holds both `ohiyo.db` and the `uploads/` directory, so messages and files survive deploys and restarts. - Because they share it, uploads are capped twice: each account may store 1 GiB in total (`MAX_UPLOAD_BYTES_PER_USER`), and once the volume is down to 5 GiB free (`MIN_FREE_DISK_BYTES`, `0` turns this off) uploads are refused with 507 so the database always has room to write. Size both to your volume. > **Scaling caveat:** SQLite on a local volume means exactly **one** machine — do > not `fly scale count 2`. When you outgrow a single node, migrate to > [LiteFS](https://fly.io/docs/litefs/) (SQLite replication) or Postgres. ### Rate limits and client addresses Login, registration and other limits are keyed on the client's address. - **On Fly** nothing is needed: the server trusts `Fly-Client-IP` because `FLY_APP_NAME` is set. - **Behind any other reverse proxy** set `TRUSTED_PROXY_HOPS` to the number of proxies in front of the server (usually `1`). The server then takes that entry from the right of `X-Forwarded-For`. Without it every client appears to come from the proxy and shares one bucket, including the registration limit. - **Registration** is limited to 10 new accounts per hour per address (`OHIYO_REGISTER_LIMIT_PER_HOUR`, `0` turns it off). People registering from one shared address, such as a classroom, will hit it. - **Login** is also limited to 10 attempts per minute per username (`OHIYO_LOGIN_LIMIT_PER_USERNAME_PER_MINUTE`, `0` turns it off). A side effect: someone who keeps sending attempts for a known username can keep that account from logging in for as long as they continue. Existing sessions are not affected. If that is used against your users, raise the limit or turn it off; the per-address limit still applies. - **Local Discrawl import** reads media only from `OHIYO_DISCRAWL_MEDIA_ROOT`; the request can no longer choose the folder. ### Railway settings and gotchas The hosted service runs on Railway from `server/Dockerfile`, with `/healthz` as its health check. Configure those direct service settings as described above; the nested JSON is a reference, not proof of the platform's effective config. Settings that differ from Fly: - A volume mounted at `/data`, and `RAILWAY_RUN_UID=0`: Railway mounts volumes as root and the image otherwise runs as an unprivileged user that cannot write there. - `PORT=3000` (and `BIND_ADDR=0.0.0.0:3000`) so Railway routes to the server's port. - `TRUSTED_PROXY_HOPS=2`. Railway sends `X-Forwarded-For: , ` and discards any value the client sent, so the client is the second entry from the right. With `1` every request is keyed on an edge address and the limits never trigger. - Check the volume's write speed once after creating it. One volume we were given took 0.3 to 15 seconds per `fsync`, which stalls any request that hits a SQLite checkpoint; a second volume in the same region took about 25 ms. From `railway ssh`: `dd if=/dev/zero of=/data/.probe bs=4k count=8 conv=fsync` a few times, then delete the file. If it is slow, attach a new volume. - Instant Servers are provisioned through Fly Machines. Without `FLY_API_TOKEN` a release build refuses to create them, and the paid plan isn't sold either (Stripe settings: `GO-LIVE.md` §3b). **The web app** runs as a second Railway service from `client/Dockerfile`: it builds the Vite bundle (the backend address comes from `client/.env.production`) and serves it with Caddy, which sets the same security headers as `client/public/_headers` does on Cloudflare Pages. To ship an app update: ```bash scripts/deploy-web.sh # uploads repo root, builds client/Dockerfile, updates OHIYO_EXPECTED_APP_BUNDLE ``` `app.ohiyo.gg` is a custom domain on that service; its DNS records live at the registrar (a CNAME to the target Railway shows, plus a `_railway-verify` TXT record). Custom domains have to be added in the Railway dashboard; the CLI's login cannot. ### Rolling back The launch-hardening release adds migration 40 (indexes only). An older server image refuses to start against a database that has a migration it does not know. Before rolling back to an image from before that release, run this once on the database (the indexes themselves can stay): ```sql DELETE FROM _sqlx_migrations WHERE version = 40; ``` ### Backups — set this up before you have real users For SQLite **plus uploads and Pi Durable journals/state**, use a coordinated whole-volume backup. The [self-hosting recovery guide](docs/self-hosting.md#whole-volume-backup-planned-outage) includes an offline restore drill. Litestream alone does not cover these files. The `/data` volume holds the SQLite DB: **all** messages (ciphertext for encrypted DMs and group chats, readable text for everything else) *and* the encrypted `key_backups` blobs. Losing it is unrecoverable, so back it up. - **Railway volumes.** Nothing here is set up for you. Check what your Railway plan offers for volume backups before relying on them, and use the Litestream option below, which works on any host. - **Fly volume snapshots (Fly only; baseline, automatic).** Fly snapshots every volume daily and keeps them ~5 days by default. Extend retention and *practice a restore* before you need one: ```bash fly volumes snapshots list fly volumes update --snapshot-retention 30 # restore into a fresh volume: fly volumes create ohiyo_data --snapshot-id --region iad ``` - **Continuous backup (recommended — already wired in).** The runtime image bundles [Litestream](https://litestream.io). Set `LITESTREAM_REPLICA_URL` (+ store credentials, and `LITESTREAM_ENDPOINT` for any store other than AWS S3, such as Cloudflare R2) and the SQLite WAL streams to object storage for point-in-time recovery, and the DB is auto-restored on a fresh volume. Setup: [`infra/litestream/README.md`](infra/litestream/README.md). Unset = off (no-op). > A daily snapshot can lose up to 24h of messages; Litestream closes that gap. ### Signed file URLs (`OHIYO_REQUIRE_SIGNED_FILES`) — optional The `/files/{id}` route is unauthenticated (avatars, icons, and attachments load via plain ``, which can't send an auth header). As defense-in-depth, every file URL the server emits now carries an HMAC capability signature — `/files/{id}?s=`, where `sig` is derived from `JWT_SECRET` and the file id. **Enforcement is OFF by default**, so this changes nothing unless you opt in: ```bash fly secrets set OHIYO_REQUIRE_SIGNED_FILES=true # Fly railway variable set --service ohiyo-server OHIYO_REQUIRE_SIGNED_FILES=true # Railway # "1" also works; unset/anything else = off ``` When on, `serve_file` rejects any request whose `?s=` signature doesn't match (returning 404 so it never reveals whether an id exists). When off, the `s` param is ignored and serving is byte-for-byte unchanged. > **Caveat — re-save existing assets before enabling.** Signing is store-time, so URLs > are signed *as they are written*. Message attachments and new uploads are signed going > forward automatically. But avatars, server icons, and banners set **before** you flip > the flag hold a bare `/files/{id}` URL with no signature — they will 404 under > enforcement until re-saved (re-upload / re-pick the avatar, icon, or banner). Only > turn this on once you're prepared for that, or accept that pre-upgrade profile/icon > images need a one-time re-save. --- ## 2. Voice (TURN) — for calls across the internet STUN alone works on a LAN. Real calls between people behind home routers need a **TURN relay** for the calls that can't connect directly. The server supports two; set up either (or both). The server hands out relay credentials at `GET /api/v1/ice-servers`; the client fetches them before each call. The STUN servers come first, so a call still connects directly whenever it can and only uses the relay when that fails. ### Cloudflare Realtime TURN (what ohiyo.gg uses) 1. In the Cloudflare dashboard, open **Realtime → TURN Server** and create a TURN key. Copy its **Turn Token ID** and **API Token**. 2. Set both on the server: ```bash railway variables --service ohiyo-server \ --set CLOUDFLARE_TURN_KEY_ID="" \ --set CLOUDFLARE_TURN_API_TOKEN="" ``` On each call the server asks Cloudflare for credentials that last `TURN_TTL` seconds (a day by default); the API token never leaves the server. If Cloudflare fails or takes more than 3 seconds, the call gets a direct-only answer instead of waiting. One account can make the server ask at most 20 times a minute. Pricing (October 2026): TURN traffic is $0.05 per GB sent from Cloudflare to callers, with the first 1,000 GB a month free, shared with Cloudflare's SFU. Only the calls that can't connect directly use it. ### Self-hosted coturn The coturn config already lives in `infra/coturn/`. 1. Run coturn on a public host (a small VPS, or a second Fly app) using `infra/coturn/docker-compose.yml`. Set its `static-auth-secret`. 2. Point the Ohiyo server at it with secrets matching that value: ```bash cd server # On Railway, set the same three values with `railway variable set`. fly secrets set \ TURN_SECRET="" \ TURN_URLS="turn:turn.ohiyo-.com:3478?transport=udp" \ TURN_TTL=86400 ``` The server mints short-lived coturn credentials from that secret on each call. ### GIF search (KLIPY, optional) The composer's GIF tab searches [KLIPY](https://docs.klipy.com) through your server (Tenor's API, which the app used to call, was shut down on 30 June 2026). Create an API key on KLIPY's partner site and set it on the server: ```bash KLIPY_API_KEY=your-klipy-key ``` Without it the app shows no GIFs tab. With it, searches go from your server to KLIPY (not from people's browsers), and the GIF images load from KLIPY's media hosts in the browser: KLIPY's integration rules don't allow re-hosting or caching their media. The search box says "Search KLIPY", as their attribution rules require. --- ## 3. Build the desktop app Everything here runs from `client/`. ```bash cd client # Point the packaged app at YOUR backend (this is baked in at build time). # Edit client/.env.production: # VITE_SERVER_URL=https:// bun install bun run tauri build ``` Installers land in `client/src-tauri/target/release/bundle/`: | Platform | Output | |----------|--------| | macOS | `dmg/Ohiyo__aarch64.dmg` and `macos/Ohiyo.app` | | Windows | `msi/Ohiyo__x64_en-US.msi`, `nsis/...-setup.exe` | | Linux | `appimage/Ohiyo__amd64.AppImage`, `deb/`, `rpm/` | > You can only build a platform's installer **on that platform** (or in CI). On > your Mac you get the `.dmg`; use GitHub Actions runners for Windows/Linux — > see [§6](#6-cicd). ### What's already native (Discord-like) - **Brand icon & window** — coral Kikka (chinchilla) icon, "Ohiyo" titled 1180×760 window with a sensible minimum size, generated from `src-tauri/app-icon.svg`. - **Single instance** — launching again focuses the running window instead of opening a second one. - **Deep links** — `ohiyo://invite/` opens the app straight to the join screen (the client falls back to `?invite=` web links in a browser). On macOS the scheme is registered via the bundle; on Linux/Windows the installer registers it. - **Native OS notifications** — under Tauri, new-message pings use the system notification center; in a browser they fall back to Web Notifications. - **Hardened CSP** — `object-src 'none'`, `base-uri 'self'`, `frame-ancestors 'none'`, scoped `connect-src`/`img-src`. See the follow-ups below. --- ## 4. Discord import live proof Before showing Discord import to customers, follow the beginner checklist in [`docs/discord-import-live-smoke-test.md`](docs/discord-import-live-smoke-test.md). The customer-facing app flow must stay simple: **Add Ohiyo to Discord → Find my servers → Pick server → Clone selected server**. Keep all bot tokens and Discrawl paths private on the server/deployment only. --- ## 5. Code signing & auto-update (required for release downloads) Release jobs fail before building if the update signing key is missing. Mac jobs also require all six Apple signing/notarization secrets; there is no ad-hoc release fallback. Local `bun run tauri build` still permits ad-hoc development builds, but those are not family-ready downloads and must not be represented as Apple verified. The published v0.3.1 Mac beta predates this gate. Its ARM download was checked on a Mac on 2026-10-23: signature integrity passed, but Gatekeeper rejected it and no notarization ticket was present. This workflow change does not repair that existing download. A new signed, notarized version must be built and tested before publication. ### macOS notarization 1. Join the Apple Developer Program ($99/yr), create a **Developer ID Application** certificate. 2. Set all six GitHub Actions secrets: - `APPLE_CERTIFICATE` — base64 `.p12` Developer ID Application certificate - `APPLE_CERTIFICATE_PASSWORD` — password for that `.p12` - `APPLE_SIGNING_IDENTITY` — exact Developer ID identity name - `APPLE_ID` — Apple ID email - `APPLE_PASSWORD` — app-specific password - `APPLE_TEAM_ID` — Apple Developer Team ID 3. Once release publication is authorized, push a `v*` tag or run the Release workflow manually. Missing credentials stop the corresponding release job. 4. Before claiming the Mac download is Apple verified, download the `.dmg` on a clean Mac and confirm it opens without the "Apple could not verify" warning. ### Windows signing Obtain a code-signing certificate (OV/EV) and set the `tauri.conf.json` `bundle.windows.certificateThumbprint` (or use Azure Trusted Signing). Windows installers remain disabled. The new secure-memory backend and native test matrix do not establish app-level readiness; complete the [desktop hardening gates](docs/desktop-hardening.md) before enabling releases. ### Auto-update (on since 0.3.0) The desktop app asks the newest **published** GitHub release for `latest.json` and offers the update; the person chooses when to install it. A draft release is invisible to it. Updates are signed with a key made for this purpose (it is not a code-signing certificate): | GitHub secret | What it is | |---|---| | `TAURI_SIGNING_PRIVATE_KEY` | The private update key | | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Its password | The public half is `plugins.updater.pubkey` in `client/src-tauri/tauri.conf.json`. Keep a copy of the private key and its password outside GitHub: without them, installed copies can no longer be updated. Update bundles are made only by the release workflow, which switches `bundle.createUpdaterArtifacts` on when the key is present. A plain `bun run tauri build` needs no key and makes no update bundle. Use native Tauri 2 update artifacts (`createUpdaterArtifacts: true`), never `"v1Compatible"`. The updater's optional legacy ZIP feature is disabled to remove vulnerable ZIP dependencies: Linux updates must use raw `.AppImage`, `.deb` or `.rpm` files, and future Windows updates must use raw `.exe` or `.msi` installers. Do not point Linux updates at `.AppImage.tar.gz`; that legacy wrapper is not supported. macOS `.app.tar.gz` updates remain supported. The published v0.3.1 manifest and the release workflow already use these native formats. **A copy you build yourself never looks for updates.** Only the release workflow switches that on, by setting `VITE_DESKTOP_UPDATES=1` for its builds. This matters because the config in this repository names the official key and release address: a hand-built copy that looked for updates would be offered the official build, install it over yours, and end up talking to the official server. Want your own fork to update itself? Make your own pair with `bun run tauri signer generate -- -w `, put its public key and your release address in `plugins.updater`, and set the two secrets; your release workflow then turns updates on for your builds. Do not set `VITE_DESKTOP_UPDATES` while the config still names this repository's key and address. On Linux the AppImage replaces itself in place. A deb or rpm install updates through the system's package tool, which asks for the administrator password; if that is not possible, download the new package instead. --- ## 6. CI/CD `.github/workflows/release.yml` builds the macOS (Apple Silicon + Intel) and Linux installers on every `v*` tag (or via manual dispatch) and attaches them to a **draft** GitHub Release. Missing updater-signing credentials fail the release; macOS additionally requires Developer ID signing and notarization credentials. There is no ad-hoc Mac release fallback. The separate `.github/workflows/desktop-security.yml` tests the vault on Linux, macOS and Windows without building or publishing installers: | Secret | Purpose | |--------|---------| | `VITE_SERVER_URL` | Backend URL baked into the bundle (`https://`) | | `APPLE_CERTIFICATE`, `APPLE_CERTIFICATE_PASSWORD`, `APPLE_SIGNING_IDENTITY`, `APPLE_ID`, `APPLE_PASSWORD`, `APPLE_TEAM_ID` | macOS Developer ID signing + notarization (§4) | | `TAURI_SIGNING_PRIVATE_KEY`, `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Tauri auto-updater signing (§4) | Cut a release with `git tag v0.1.1 && git push --tags`. The end-to-end suite runs separately (`.github/workflows/e2e.yml`, manual + weekly) — it is not yet a merge gate (a couple of crypto suites are timing-sensitive; stabilise on a runner first). --- ## 7. Pre-launch hardening checklist These are tracked follow-ups, not blockers for a first test build: - [ ] **Plugin sandbox.** The CSP now blocks remote-URL plugin scripts (they were executing arbitrary JS in the app's context). Built-in plugins still work. Before re-enabling remote plugins, sandbox them in an iframe/Worker. - [ ] **Tighten CSP `connect-src`** from `https: wss:` to your exact backend host once it's fixed (`https:// wss://`). - [ ] **Token storage.** The web client keeps the session token in `localStorage`. Acceptable in the packaged app; revisit if you ship a pure web build to untrusted origins. - [x] **Proxy IPs.** On Fly the server keys rate limits on `Fly-Client-IP`. Behind any other reverse proxy set `TRUSTED_PROXY_HOPS` (see "Rate limits and client addresses"), or every client shares one bucket. - [x] **Privacy policy + Terms of Service.** Landing pages live in `site/privacy.html` and `site/terms.html`, with footer links and sitemap coverage. - [ ] **Load test** to replace the dev-machine benchmark numbers on the comparison page with production figures.