13 KiB
Operations
Environment
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_*, GLM_*, DEEPSEEK_*, ANTHROPIC_*, OPENAI_*, GEMINI_* |
disabled | API drivers: set *_ENABLED=true and *_API_KEY |
GLM_BASE_URL |
https://api.z.ai/api/paas/v4 |
Zhipu platform override (mainland China: https://open.bigmodel.cn/api/paas/v4) |
Local development
bun install
bun run dev # backend watch mode (restarts on edit), :3000
bun run dev:frontend # Vite HMR + /api proxy, :5173 (use this for UI work)
bun run dev:all # both at once
bun run build:frontend # production build into public/
Smoke tests: start the backend on a free non-3000 port with a throwaway
DATA_DIR (port 3000 is the owner's dev server — do not touch it), then drive
the API with curl/python: login → models → conversations → chat → share →
unshare. See AGENTS.md.
Managing accounts
- Log in as
adminwith theAPP_PASSWORDkey. - Open Accounts → New account → username (+ display name).
- Copy the one-time
ka_…key and hand it to the owner (chat, QR code, …). - Users set their display name, avatar (upload a picture in the profile settings — hosted on the built-in CDN), theme, accent color and feature flags in the settings.
- Rotate a compromised key with the regenerate button (old sessions die); delete removes the account with all its chats and shares.
Admin Center (/admin) & self-registration
The sidebar Admin Center button (admin only) opens the /admin page with
four tabs: Accounts (same manager as before, incl. the Ollama model
catalog), Access (access-request wishlist triage, see below),
Features (server-side switches), Mail (SMTP credentials viewer +
connectivity tester: GET /api/admin/mail, POST /api/admin/mail/verify,
POST /api/admin/mail/test, admin Bearer only).
registration_enabled(default off): when on, the login page shows a working Register button — anyone can create an account (key shown once) viaPOST /api/auth/register. When off, the button is greyed with the tooltip "Registration currently disabled on this platform" and the endpoint answers 403. Flipped live, no restart. Turning it on retires the access-request wishlist automatically.registration_request_enabled(default on): shows Request access on the login page (wishlist). Only switchable on while public registration is off — the two are mutually exclusive (API answers 409 otherwise).tools_ocr_enabled(default on): admin kill-switch for the OCR tool.reports_enabled(default on): admin kill-switch for message reports — the chat flag button turns grey with a "Report disabled" tooltip andPOST /api/reportsanswers 403. Surfaced to clients viaGET /api/tools.- Flags live in the
settingsSQLite table, edited viaGET/PATCH /api/admin/settings(admin Bearer only).
Model access policy (kill-switch + rate limits)
The Admin Center Models tab lists every model across enabled drivers
(grouped, Ollama library moved there too). Per model id ("driver:model"):
an Enabled switch and hourly / daily per-user request caps
(0 = unlimited, admins always bypass). Policy lives in the settings table
(model_policy JSON blob, default entries pruned); usage counters in
model_usage (fixed UTC hour/day windows, old buckets pruned on write).
- Disabled models vanish from
GET /api/modelsfor users (admins still see everything) andPOST /api/chatrejects them (403) — attempts count toward limits even when generation later fails; over-limit answers 429. - Routes:
GET /api/admin/model-policy[?refresh=1],PATCH /api/admin/model-policy({model, enabled?, hourly?, daily?}). Ollama library:POST /api/admin/ollama/pull(NDJSON progress),DELETE /api/admin/ollama/models/:name, and the connectivity probeGET /api/admin/ollama/status(reachability, version, on-disk models — surfaced in Admin Center → Models).
Personal providers (BYOK)
Users can connect their own OpenAI / Anthropic / DeepSeek / Gemini
API keys in Settings → Providers (per account, no admin involved).
Connected providers add their models to that user's model menu at the top
(no page refresh needed); usage is billed to the user's own provider
account, and the admin model policy (kill-switch + rate limits) still
applies.
- Keys are stored server-side in
user_provider_keysand never returned to any client (GET /api/me/providersexposes presence + last 4 chars only; never logged). A stored key greys out its field (Replace to rotate, Remove to disconnect). Deleting the account wipes its keys. - Routes:
GET /api/me/providers,PUT /api/me/providers/:id({apiKey}, 8–256 chars),DELETE /api/me/providers/:id(all Bearer, owner-scoped).
Access-request wishlist (registration approval queue)
When public registration is off, visitors can still reserve a username via
Request access on the login page: username + email (required) + a
motivation message (10+ chars). This creates a ticket (access_requests +
access_messages tables) with a personal page at /review/<id> (unguessable
UUID, same pattern as share links).
- Requester: follows the decision, argues their case and replies on the
/reviewpage. Closed tickets (accepted/refused) are read-only. - Admin: Access tab in the Admin Center — filter by status, open a ticket,
reply (emailed to the requester), and set
pending → reviewing → accepted / refusedwith a reason. Accepting creates the account and returns the access key once (also emailed). Terminal tickets can't be reopened. - Email (needs
SMTP_*): received-confirmation, reviewing/accepted/refused notices, and every admin reply — all HTML, all stating email is notification-only and replies belong on the/reviewpage. - Routes:
POST /api/access/request(public, rate-limited),GET /api/access/ticket/:id+POST .../message(public, ticket bearer),GET /api/admin/access,GET/PATCH /api/admin/access/:id,POST /api/admin/access/:id/message(admin). - Live updates ride WebSockets (no polling):
GET /api/access/ws/:id(ticket bearer — pushesaccess_message+access_statusfor that ticket) andGET /api/admin/ws?token=…(admin Bearer in query string, browsers can't set WS headers — pushesaccess_message+access_status+access_createdfor every ticket, driving the AdminCenter badge and the triage list). Clients ping every 25 s ({"type":"ping"}→{"type":"pong"}) and resync over HTTP on every (re)connect.
Content reports (flagged AI responses)
Every assistant message carries three actions: Copy, Search the web
(DuckDuckGo, new tab) and Report. Reporting opens a modal with a reason
picker (copyright, gore, falseinfo, bug) plus optional details, and
creates a row in the reports table with a content snapshot (survives later
edits/deletion of the chat), the triggering user prompt, the model, and the
reporter.
- Reporter: capped at 20 open (
open+reviewing) reports (429 beyond). Reporting the same message twice returns the existing report (409already reported). - Admin: Reports tab in the Admin Center (badge counts open reports,
excluding shadow-banned reporters) — filter by status, expand for the full
snapshot + reporter details, and set
open → reviewing → resolved / dismissedwith an internal note. An admin can also shadow-ban a noisy reporter (toggle on the report detail, with confirm): their reports are still accepted but flagged unreliable and hidden from the badge. - Routes:
POST /api/reports(Bearer),GET /api/admin/reports,PATCH /api/admin/reports/:id,POST /api/admin/users/:id/shadowban(admin).
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.
- Admin sets a recovery email at account creation (or the user sets their own in profile settings).
- 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). - Opening the link shows a confirm page; confirming rotates the key
immediately — the new
ka_…key is displayed once, old sessions die. (adminitself 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).
- Allowed types only
jpg/png/webp, verified by magic bytes, never by extension (client filename is ignored; the stored extension comes from detection). Everything else →415, unknown namespace/ext →404. avatarrules: 5 MB max (413above), 5 uploads max per 2 h per account (429+Retry-After), ownership enforced (own id, or admin).- 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
textCDN 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)
Full step-by-step guide: 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.
imports = [ kisassistant.nixosModules.default ];
services.kisassistant = {
enable = true;
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
};
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
127.0.0.1:3000.