update
This commit is contained in:
+12
-1
@@ -43,8 +43,9 @@ Created idempotently at boot in `getDb()` (no migration framework; keep it so).
|
||||
|
||||
| Table | Purpose |
|
||||
|---|---|
|
||||
| `users` | `id, username UNIQUE, display_name, avatar_url, theme, key_hash, created_at`. The `admin` row is bootstrapped (its `key_hash` is unused — admin auth goes through `APP_PASSWORD`). |
|
||||
| `users` | `id, username UNIQUE, display_name, avatar_url, theme, email, key_hash, created_at`. The `admin` row is bootstrapped (its `key_hash` is unused — admin auth goes through `APP_PASSWORD`). |
|
||||
| `sessions` | `token_hash PK, user_id, created_at, expires_at`. Bearer tokens, 256-bit, hashed with sha256. Expired rows are purged lazily on access. |
|
||||
| `resets` | `token_hash PK, user_id, created_at, expires_at`. One-time key-recovery tokens (single use, consumed on POST). |
|
||||
| `conversations` | `id, user_id, title, topic, model, created_at, updated_at`. |
|
||||
| `messages` | `id AUTOINCREMENT, conv_id → conversations ON DELETE CASCADE, role, content, created_at`. |
|
||||
| `shares` | `conv_id PK → conversations ON DELETE CASCADE, public_id UNIQUE, created_at`. |
|
||||
@@ -80,6 +81,16 @@ exists: key = account id (`/cdn/avatar/<id>.png`), ownership enforced.
|
||||
- Served with detected `Content-Type`, `X-Content-Type-Options: nosniff`,
|
||||
`Cache-Control: public, max-age=3600`.
|
||||
|
||||
## Platform tools
|
||||
|
||||
`src/tools/`: `PlatformTool` interface + registry (today: `ocr` via the
|
||||
tesseract binary, availability probed at boot). `POST /api/tools/ocr` takes
|
||||
raw image bytes (5 MB, magic-verified, 20 jobs/hour/account) and returns
|
||||
`{ text, truncated, chars }` — transcription happens server-side with
|
||||
per-driver-style stdout logging; a missing binary yields a clean 501. Text
|
||||
attachments use the `text` CDN namespace (500 KB, UTF-8 validated). The model
|
||||
only ever sees reviewed text blocks, never raw files.
|
||||
|
||||
## Drivers`src/drivers/`: `LLMDriver` interface (`listModels`, `chat`, `chatStream`),
|
||||
`DriverRegistry` with global priority
|
||||
`ollama → mistral → puppeteer-openai (stub) → deepseek → anthropic → openai`.
|
||||
|
||||
+2
-1
@@ -37,7 +37,8 @@ read-only `SharePage` (no auth), everything else renders the authenticated app.
|
||||
chip (opens settings), Accounts button (admin), logout.
|
||||
- `Chat.tsx` — header (title, topic badge, driver-grouped model `Picker`,
|
||||
share button), message list (`Markdown` for assistant, emerald bubble for
|
||||
user), rounded composer (Enter to send, Shift+Enter for newline).
|
||||
user), rounded composer with paperclip attach menu, attachment chips
|
||||
(Enter to send, Shift+Enter for newline).
|
||||
- `dialogs.tsx` — `ConvEditDialog` (title + topic), `ShareModal` (public link,
|
||||
copy, unshare), `SettingsModal` (display name, avatar upload box with
|
||||
drag & drop, avatar URL, theme).
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
# Hosting KisAssistant on a NixOS server
|
||||
|
||||
End-to-end guide: build the flake, wire the module into `configuration.nix`,
|
||||
secure it with agenix/sops-nix, and put it behind nginx with automatic TLS.
|
||||
|
||||
Contents:
|
||||
|
||||
1. [Prerequisites](#1-prerequisites)
|
||||
2. [Add the flake to your system inputs](#2-add-the-flake-to-your-system-inputs)
|
||||
3. [Secrets (`APP_PASSWORD`, API keys)](#3-secrets-app_password-api-keys)
|
||||
4. [Enable the service](#4-enable-the-service)
|
||||
5. [TLS reverse proxy (`enableNginx`)](#5-tls-reverse-proxy-enablenginx)
|
||||
6. [First deploy & smoke test](#6-first-deploy--smoke-test)
|
||||
7. [Operations: logs, backups, updates](#7-operations-logs-backups-updates)
|
||||
8. [Module option reference](#8-module-option-reference)
|
||||
|
||||
---
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
- A NixOS machine (unstable channel or a flake lock close to it) with
|
||||
` networking` + `firewall` under your control.
|
||||
- Bun is **not** installed on the server — the flake wraps the runtime.
|
||||
- DNS: an A/AAAA record for your domain (e.g. `chat.kisakay.com`) pointing at
|
||||
the server. ACME (Let's Encrypt) needs port 80/443 reachable during
|
||||
issuance.
|
||||
- Ollama reachable from the server (e.g. `http://10.66.66.4:11434` on your
|
||||
LAN/tailscale).
|
||||
|
||||
## 2. Add the flake to your system inputs
|
||||
|
||||
In your system flake (or `flake.nix` of your NixOS config):
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs.kisassistant.url = "http://git.kisakay.com/k/chat"; # adjust to real repo path
|
||||
|
||||
outputs = { self, nixpkgs, kisassistant, ... }: {
|
||||
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
modules = [
|
||||
./configuration.nix
|
||||
kisassistant.nixosModules.default
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
If you don't use flakes for your system config, import the module by path:
|
||||
|
||||
```nix
|
||||
imports = [ /etc/nixos/kisassistant/module.nix ];
|
||||
# and point the package at a build:
|
||||
services.kisassistant.package =
|
||||
(builtins.getFlake "git+http://git.kisakay.com/k/chat").packages.x86_64-linux.default;
|
||||
```
|
||||
|
||||
> The frontend is compiled **offline inside the Nix build** with Bun from
|
||||
> `frontend/bun.lock` (fixed-output deps fetch). If `nix build` ever fails with
|
||||
> a `bunDepsHash` mismatch, paste the `got: sha256-…` value from the error into
|
||||
> `bunDepsHash` in `flake.nix` and rebuild. This is normal after dependency
|
||||
> bumps.
|
||||
|
||||
## 3. Secrets (`APP_PASSWORD`, API keys)
|
||||
|
||||
Never put secrets in the Nix store. Use [agenix](https://github.com/ryantm/agenix)
|
||||
or [sops-nix](https://github.com/Mic92/sops-nix). Minimal agenix setup:
|
||||
|
||||
```bash
|
||||
# on your machine, in the secrets repo of the host
|
||||
npx agenix generate kisassistant-password -a $(readlink -f /etc/ssh/ssh_host_ed25519_key.pub)
|
||||
# write the admin key into the file, e.g.:
|
||||
echo "$(openssl rand -base64 24)" | agenix -e kisassistant-password.age # keep a copy!
|
||||
```
|
||||
|
||||
Then on the host:
|
||||
|
||||
```nix
|
||||
age.secrets.kisassistant-password.file = /etc/nixos/secrets/kisassistant-password.age;
|
||||
```
|
||||
|
||||
The module consumes it as `passwordFile` and injects it into the service via
|
||||
systemd `LoadCredential` — the file is read at runtime only.
|
||||
|
||||
## 4. Enable the service
|
||||
|
||||
```nix
|
||||
services.kisassistant = {
|
||||
enable = true;
|
||||
|
||||
# REQUIRED — the module refuses to evaluate without it.
|
||||
package = kisassistant.packages.${pkgs.system}.default;
|
||||
|
||||
# admin key file (agenix/sops-nix path on the host)
|
||||
passwordFile = config.age.secrets.kisassistant-password.path;
|
||||
|
||||
# storage (created by systemd StateDirectory, owned by the service user)
|
||||
# SQLite: /var/lib/kisassistant/data
|
||||
# CDN: /var/lib/kisassistant/cdn
|
||||
dataDir = "/var/lib/kisassistant";
|
||||
|
||||
# LLM backends
|
||||
ollamaHost = "http://10.66.66.4:11434";
|
||||
extraEnv = {
|
||||
MISTRAL_ENABLED = "true"; # non-secret toggles only — see the note below
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
The module automatically sets for the systemd unit:
|
||||
|
||||
| Env | Value |
|
||||
|---|---|
|
||||
| `NODE_ENV` | `production` |
|
||||
| `DATA_DIR` | `/var/lib/kisassistant/data` |
|
||||
| `CDN_DIR` | `/var/lib/kisassistant/cdn` |
|
||||
| `DRIVER_DEBUG` | `false` |
|
||||
| `PORT` / `HOST` | `127.0.0.1:3000` (configurable) |
|
||||
|
||||
The module also puts `tesseract` on the service `PATH`, so the OCR platform
|
||||
tool (`POST /api/tools/ocr`, image attachments) works out of the box.
|
||||
`OCR_LANG` / `OCR_MAX_CHARS` can be overridden via `extraEnv`.
|
||||
|
||||
> **API driver keys**: `extraEnv` values land in the unit's `Environment=`,
|
||||
> which is world-readable in the systemd unit file. For driver API keys prefer
|
||||
> `EnvironmentFile=` via a drop-in (or pass them from agenix with
|
||||
> `systemd.services.kisassistant.serviceConfig.EnvironmentFile`), e.g.
|
||||
> `services.kisassistant.extraEnv = { MISTRAL_ENABLED = "true"; };` plus a
|
||||
> drop-in containing the key.
|
||||
|
||||
## 5. TLS reverse proxy (`enableNginx`)
|
||||
|
||||
The conventional switch is `enableNginx` — a tri-state:
|
||||
|
||||
| Value | Behaviour |
|
||||
|---|---|
|
||||
| `true` | nginx vhost + ACME configured (requires `domain`) |
|
||||
| `false` | nginx never touched, even if `domain` is set |
|
||||
| `null` (default) | legacy: nginx configured iff `domain` is set |
|
||||
|
||||
```nix
|
||||
services.kisassistant = {
|
||||
enableNginx = true;
|
||||
domain = "chat.kisakay.com"; # vhost name + ACME cert name
|
||||
# host/port default to 127.0.0.1:3000
|
||||
};
|
||||
|
||||
# Required once per host for ACME (Let's Encrypt ToS):
|
||||
security.acme.acceptTerms = true;
|
||||
security.acme.defaults.email = "you@kisakay.com";
|
||||
|
||||
# Keep the backend loopback-only and let nginx own 80/443:
|
||||
networking.firewall.allowedTCPPorts = [ 80 443 ];
|
||||
```
|
||||
|
||||
The generated vhost: `forceSSL`, `enableACME`, `recommendedProxySettings`, and
|
||||
`proxy_buffering off` on `/` so SSE token streaming (`/api/chat`) is never
|
||||
buffered.
|
||||
|
||||
**Own nginx instead?** Set `enableNginx = false` (or omit `domain`) and write
|
||||
your own vhost; proxy to `http://127.0.0.1:3000` and remember
|
||||
`proxy_buffering off;` for `/` (or at least `/api/chat`).
|
||||
|
||||
## 6. First deploy & smoke test
|
||||
|
||||
```bash
|
||||
sudo nixos-rebuild switch --flake .#myhost
|
||||
```
|
||||
|
||||
Check the unit and the proxy:
|
||||
|
||||
```bash
|
||||
systemctl status kisassistant
|
||||
journalctl -u kisassistant -f # boot log (APP_PASSWORD errors are fatal)
|
||||
|
||||
curl -I https://chat.kisakay.com/ # 200 via nginx
|
||||
curl -s https://chat.kisakay.com/api/models -H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
Log in through the web UI as `admin` with the `APP_PASSWORD` from your secret
|
||||
file, then create user accounts from the **Accounts** panel (each gets a
|
||||
one-time `ka_…` key). See `docs/OPERATIONS.md` for account management.
|
||||
|
||||
If port 3000 is used by something else on the host, change
|
||||
`services.kisassistant.port` and the proxy target follows automatically.
|
||||
|
||||
## 7. Operations: logs, backups, updates
|
||||
|
||||
**Logs** — per-driver debug lines are off in production
|
||||
(`DRIVER_DEBUG=false`); flip temporarily with:
|
||||
|
||||
```bash
|
||||
sudo systemctl edit kisassistant # [Service] Environment=DRIVER_DEBUG=true
|
||||
sudo systemctl restart kisassistant
|
||||
```
|
||||
|
||||
**Backups** — everything is in `/var/lib/kisassistant`:
|
||||
|
||||
```bash
|
||||
# SQLite is in WAL mode; prefer the backup API over raw copies:
|
||||
sqlite3 /var/lib/kisassistant/data/kisassistant.db ".backup '/backup/kisassistant.db'"
|
||||
systemctl stop kisassistant && tar -C /var/lib -czf /backup/kisassistant-$(date +%F).tgz kisassistant && systemctl start kisassistant
|
||||
```
|
||||
|
||||
**Updates** — the usual flake flow:
|
||||
|
||||
```bash
|
||||
nix flake update kisassistant # or update the rev you pin
|
||||
sudo nixos-rebuild switch --flake .#myhost
|
||||
sudo nix store gc # keep the previous generation around as needed
|
||||
```
|
||||
|
||||
After dependency bumps the first build may fail with a `bunDepsHash` mismatch
|
||||
— paste the reported hash into `flake.nix` (see section 2).
|
||||
|
||||
## 8. Module option reference
|
||||
|
||||
| Option | Type / default | Description |
|
||||
|---|---|---|
|
||||
| `enable` | bool | the switch |
|
||||
| `package` | null **(required)** | flake package, e.g. `kisassistant.packages.${pkgs.system}.default` |
|
||||
| `port` | port, `3000` | backend listen port |
|
||||
| `host` | str, `127.0.0.1` | backend bind address (keep loopback behind nginx) |
|
||||
| `dataDir` | str, `/var/lib/kisassistant` | state root (`data/` + `cdn/` live inside) |
|
||||
| `user` | str, `kisassistant` | system user/group for the service |
|
||||
| `passwordFile` | null \| path | file containing `APP_PASSWORD` (agenix/sops-nix) |
|
||||
| `ollamaHost` | str, `http://localhost:11434` | Ollama endpoint |
|
||||
| `domain` | null \| str | nginx vhost name, required when `enableNginx = true` |
|
||||
| `enableNginx` | null \| bool, `null` | `true`/`false` force the bundled reverse proxy on/off; `null` = nginx iff `domain` set (legacy) |
|
||||
| `extraEnv` | attrs of str, `{}` | extra environment (non-secret values only) |
|
||||
+53
-9
@@ -6,12 +6,22 @@ Copy `.env.example` to `.env`. Full list:
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `NODE_ENV` | `development` | `production` under the NixOS module (and CI); gates the `DRIVER_DEBUG` default |
|
||||
| `PORT` / `HOST` | `3000` / `127.0.0.1` | listen address (keep localhost behind nginx) |
|
||||
| `APP_PASSWORD` | *(required)* | **admin** access key — generate with `openssl rand -base64 24` |
|
||||
| `SESSION_TTL_HOURS` | `720` | Bearer session lifetime (30 days) |
|
||||
| `DATA_DIR` | `./data` | holds `kisassistant.db` (SQLite) |
|
||||
| `CDN_DIR` | `./cdn` | local file CDN storage (avatars; NixOS: under state dir) |
|
||||
| `OCR_LANG` | `eng` | tesseract language(s) for the OCR tool |
|
||||
| `OCR_MAX_CHARS` | `100000` | transcription cap (longer results are truncated + flagged) |
|
||||
| `TESSERACT_BIN` | `tesseract` | path to the tesseract binary (NixOS module puts it on PATH; missing binary cleanly disables OCR with 501) |
|
||||
| `DRIVER_DEBUG` | `true` dev / `false` prod | per-driver debug logging to stdout |
|
||||
| `WIKI_URL` | `https://git.kisakay.com/k/chat/wiki` | remote docs wiki — `GET /wiki` redirects (302) there |
|
||||
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_SECURE` | *(empty)* / `587` / `false` | key-recovery mailer — empty host disables it (`false` = STARTTLS, `true` = implicit TLS/465) |
|
||||
| `SMTP_USER` / `SMTP_PASS` | *(empty)* | SMTP auth (omit both for open relays / local catchers) |
|
||||
| `SMTP_FROM` | `KisAssistant <[email protected]>` | sender address |
|
||||
| `APP_URL` | `http://localhost:3000` | public base URL used in reset links — **must** be the real origin in prod |
|
||||
| `RESET_TTL_MIN` | `60` | reset-link lifetime (single use) |
|
||||
| `RATE_LIMIT_MAX` / `RATE_LIMIT_WINDOW_MIN` | `5` / `10` | login rate limit per IP |
|
||||
| `OLLAMA_HOST` / `OLLAMA_ENABLED` | `http://10.66.66.4:11434` / `true` | local-model driver |
|
||||
| `MISTRAL_*`, `DEEPSEEK_*`, `ANTHROPIC_*`, `OPENAI_*` | disabled | API drivers: set `*_ENABLED=true` **and** `*_API_KEY` |
|
||||
@@ -41,8 +51,21 @@ unshare. See `AGENTS.md`.
|
||||
5. Rotate a compromised key with the regenerate button (old sessions die);
|
||||
delete removes the account with all its chats and shares.
|
||||
|
||||
## Local file CDN (avatars)
|
||||
## Key recovery via email (optional)
|
||||
|
||||
Set the `SMTP_*` vars (and the real public `APP_URL`) to enable it; otherwise
|
||||
the login page hides the "Forgot your access key?" link.
|
||||
|
||||
1. Admin sets a recovery email at account creation (or the user sets their own
|
||||
in profile settings).
|
||||
2. User clicks the link on the login page and enters their username. The API
|
||||
always answers `{ok:true}` (no account enumeration) and, if the account has
|
||||
an email, sends a one-time `/reset/<token>` link (60 min, single use).
|
||||
3. Opening the link shows a confirm page; confirming **rotates the key
|
||||
immediately** — the new `ka_…` key is displayed once, old sessions die.
|
||||
(`admin` itself is excluded: its key lives in `.env`.)
|
||||
|
||||
## Local file CDN (avatars)
|
||||
`PUT /cdn/<ns>/<key>` (Bearer) / `GET /cdn/<ns>/<key>.<ext>` (public), e.g.
|
||||
`/cdn/avatar/<account-id>.png`. Namespaces live in `CDN_NAMESPACES`
|
||||
(`src/cdn.ts`) — add one to extend (future usage).
|
||||
@@ -55,26 +78,47 @@ unshare. See `AGENTS.md`.
|
||||
- Served with fixed `Content-Type`, `nosniff`, `Cache-Control: public,
|
||||
max-age=3600`. No listing, no traversal (strict charsets + SPA fallback).
|
||||
|
||||
## Platform tools & attachments
|
||||
|
||||
Uploads always go through platform tools (`GET /api/tools` lists them with
|
||||
availability). Today: **OCR** (`POST /api/tools/ocr`, tesseract).
|
||||
|
||||
- Images (jpg/png/webp, 5 MB max, magic-verified, 20 jobs/hour/account) are
|
||||
transcribed **on the backend to TXT** — the model never receives image
|
||||
bytes. Text files ride the `text` CDN namespace (UTF-8 validated, 500 KB
|
||||
max, keys prefixed with the account id).
|
||||
- The UI (paperclip menu in the composer) explains this and always shows a
|
||||
**pre-transcription preview**: the user reviews/edits the text, then
|
||||
attaches it. Attachments travel as labeled text blocks inside the message,
|
||||
so they persist in history and work with every model.
|
||||
|
||||
## NixOS deployment (`chat.kisakay.com`)
|
||||
|
||||
`flake.nix` exposes `packages.<system>.default` (backend + prebuilt frontend)
|
||||
and `nixosModules.default`. The frontend is compiled offline with
|
||||
`buildNpmPackage` from `frontend/package-lock.json` — commit that file, and on
|
||||
a hash mismatch paste the hash Nix reports into `npmDepsHash`.
|
||||
Full step-by-step guide: **[NIXOS-HOSTING.md](./NIXOS-HOSTING.md)**.
|
||||
|
||||
`flake.nix` exposes `packages.<system>.default` (backend + prebuilt frontend,
|
||||
frontend compiled offline from `frontend/bun.lock` via a fixed-output
|
||||
`bun install`) and `nixosModules.default`.
|
||||
|
||||
```nix
|
||||
imports = [ kisassistant.nixosModules.default ];
|
||||
services.kisassistant = {
|
||||
enable = true;
|
||||
package = kisassistant.packages.${pkgs.system}.default;
|
||||
domain = "chat.kisakay.com"; # nginx + ACME
|
||||
package = kisassistant.packages.${pkgs.system}.default; # required
|
||||
domain = "chat.kisakay.com"; # legacy switch: implies nginx + ACME
|
||||
enableNginx = true; # explicit; requires domain
|
||||
passwordFile = "/run/secrets/kisassistant-password"; # agenix/sops-nix
|
||||
ollamaHost = "http://10.66.66.4:11434";
|
||||
extraEnv = { MISTRAL_ENABLED = "true"; }; # optional
|
||||
# secrets for API drivers: pass via environment files / extraEnv, never the store
|
||||
extraEnv = { MISTRAL_ENABLED = "true"; }; # optional
|
||||
};
|
||||
security.acme.acceptTerms = true; # required for the ACME/TLS vhost
|
||||
```
|
||||
|
||||
The module sets `NODE_ENV=production`, `CDN_DIR=/var/lib/kisassistant/cdn`
|
||||
and `DRIVER_DEBUG=false` for the systemd service. `enableNginx` is a
|
||||
tri-state: `true`/`false` force the bundled reverse proxy on/off, `null`
|
||||
(the default) keeps the legacy behaviour (nginx iff `domain` is set).
|
||||
|
||||
Secrets (`passwordFile`, API keys) must come from files (agenix/sops-nix), never
|
||||
baked into the Nix store. The service stores SQLite under
|
||||
`/var/lib/kisassistant/data`; nginx terminates TLS and proxies to
|
||||
|
||||
Reference in New Issue
Block a user