Now can be powered by postgres sql

This commit is contained in:
k
2026-09-22 09:00:19 +02:00
parent c9e53193b9
commit a57f5f0951
19 changed files with 1026 additions and 553 deletions
+3
View File
@@ -11,6 +11,9 @@ APP_PASSWORD=change-me
SESSION_TTL_HOURS=720
# Data dir: holds kisassistant.db (SQLite: users, sessions, chats, shares)
DATA_DIR=./data
# Postgres fallback (typically a docker container). Empty = local SQLite.
# Example: POSTGRESQL_URL=postgres://kisassistant:secret@localhost:5432/kisassistant
POSTGRESQL_URL=
# Local file CDN (avatars). NixOS module points it under the state dir.
CDN_DIR=./cdn
# Local file CDN (avatars today, more namespaces tomorrow).
+3 -2
View File
@@ -6,7 +6,8 @@ Read this before touching the code. Docs live in `docs/` (English).
KisAssistant — private ChatGPT-like assistant. Bun backend (single process,
serves API + static frontend), React 18 + Vite + Tailwind 3 + lucide-react
frontend, SQLite persistence via Bun's native `bun:sqlite` driver.
frontend, SQLite persistence by default via Bun's native `bun:sqlite` driver
(Postgres fallback via native `bun:sql` when `POSTGRESQL_URL` is set).
## Commands (run from repo root unless noted)
@@ -45,7 +46,7 @@ flake.nix/module.nix NixOS packaging + service module
- Frontend styling: Tailwind only, emerald accent, `rounded-2xl/3xl` everywhere, `dark:` variants on every surface. Inter Variable font (self-hosted, no CDN).
- Icons: lucide-react only. **Never use native `<select>`** — use the `Picker` component in `frontend/src/components/ui.tsx` (also `Button`, `Modal`, `ConfirmDialog`, `ContextMenu`, `Avatar`, `CopyButton` live there).
- Backend responses: `json()` helper, `{ error }` shape on failure, correct HTTP codes (400/401/403/404/409/429/502).
- DB: synchronous `bun:sqlite` queries, prepared statements, `PRAGMA journal_mode=WAL`. Schema is created idempotently at boot — keep it that way (no migration framework).
- DB: async store fns through the `DbClient` interface (`src/db/client.ts`) — SQLite default (`bun:sqlite`, `PRAGMA journal_mode=WAL`), Postgres when `POSTGRESQL_URL` is set (Bun native `bun:sql`). Write queries once with `?` placeholders (auto-rewritten to `$n` for pg), keep SQL portable (no bare `GROUP BY pk` — pg rejects it; quote `"window"`). Schema is created idempotently at boot — keep it that way (no migration framework).
- Drivers: new LLM backends implement `LLMDriver` and register in `DriverRegistry` priority order. The arcaic browser driver is user-facing as "Arcaic-Technology" — never expose its scraping internals (no "puppeteer"/"crawler"/upstream-site names) in API responses, error strings, or UI labels.
- Docs are in English. Update `docs/` + README when adding routes, tables, or env vars.
+5 -4
View File
@@ -2,7 +2,7 @@
Private ChatGPT/Claude-like chat. React + Tailwind + lucide frontend (rounded,
Signal-style, Inter font, light/dark/auto themes), Bun backend with
class-oriented LLM **drivers** and a **SQLite** database (Bun native driver) for
class-oriented LLM **drivers** and a **SQLite/Postgres** database (Bun native drivers) for
accounts, server-side chats and public shares. No cookies — Bearer keys in
`localStorage`.
@@ -89,9 +89,10 @@ from the **Accounts** panel (each gets a one-time access key to hand over).
`POST /api/chat` with `conversationId` persists the user message + assistant
reply into that conversation (ownership enforced) and auto-titles new chats.
## Database (SQLite, Bun native)
## Database (SQLite default, Postgres fallback — both Bun native)
`$DATA_DIR/kisassistant.db` (WAL mode). Tables: `users` (id, username,
`$DATA_DIR/kisassistant.db` (WAL mode), or Postgres when `POSTGRESQL_URL` is
set (e.g. a docker container — see `docs/OPERATIONS.md`). Tables: `users` (id, username,
display_name, avatar_url, theme, email, key_hash), `sessions` (hashed Bearer tokens),
`conversations` (id, user, title, topic, model, archived_at), `messages`, `shares`,
`resets` (recovery tokens), `settings` (feature flags),
@@ -109,7 +110,7 @@ src/drivers/browser.ts ArcaicBrowserEngine + site configs (openai/qwen/gemini)
src/drivers/arcaic.ts ArcaicSubDriver ×3 — arcaic-openai / arcaic-gemini / arcaic-qwen (ARCAIC_ENABLED)
src/drivers/registry.ts DriverRegistry — priority order + "driver:model" routing
src/userProviders.ts personal providers (BYOK): per-user keys, own-key driver factory
src/db.ts SQLite store (users, sessions, convs, messages, shares)
src/db.ts async store (users, sessions, convs, messages, shares) — SQLite/Postgres via DbClient
src/auth.ts login/sessions/rate-limit (admin key = APP_PASSWORD)
```
+18 -4
View File
@@ -1,16 +1,27 @@
# Architecture (backend)
Single Bun process: `Bun.serve` handles the JSON/SSE API **and** the static
frontend (same origin, no CORS). Persistence is SQLite via Bun's native
`bun:sqlite` driver (`$DATA_DIR/kisassistant.db`, WAL mode).
frontend (same origin, no CORS). Persistence is SQLite by default via Bun's
native `bun:sqlite` driver (`$DATA_DIR/kisassistant.db`, WAL mode), or
Postgres when `POSTGRESQL_URL` is set (Bun's native `bun:sql` driver,
typically a docker container — see `docs/OPERATIONS.md`).
```
browser ──► Bun.serve (src/index.ts)
├─ /api/* ──► auth gate ──► routes ──► db.ts ──► kisassistant.db
├─ /api/* ──► auth gate ──► routes ──► db.ts ──► sqlite file | postgres
│ └─────► DriverRegistry ──► ollama / APIs
└─ /* ──► static files + SPA fallback (index.html, covers /share/:id and /chat/:id)
```
All store functions (`db.ts`, `access.ts`, `reports.ts`, `totp.ts`,
`modelPolicy.ts`, `userProviders.ts`) are `async` and talk through the
`DbClient` interface (`src/db/client.ts`): `get` / `all` / `run` / `exec` /
`insertReturningId` / `transaction`. Queries are written once with `?`
placeholders and translated to `$1, $2, …` for Postgres (quote-aware);
Postgres `BIGINT` cells arrive as `bigint` and are normalized to `number`.
One portable-SQL rule this forces: no bare `GROUP BY pk` with unaggregated
columns (rejected by Postgres) — use correlated subqueries instead.
## Request flow: chat
`POST /api/chat { model: "driver:model", messages, conversationId?, stream? }`
@@ -40,7 +51,10 @@ Streaming guarantees (ChatGPT-style, no buffering):
## Database schema
Created idempotently at boot in `getDb()` (no migration framework; keep it so).
Created idempotently at boot in `initDb()` (no migration framework; keep it
so). On Postgres, timestamps are `BIGINT`, auto ids `BIGSERIAL`, later
columns arrive via `ADD COLUMN IF NOT EXISTS`, and `"window"` (model_usage)
is quoted — `WINDOW` is reserved on Postgres.
| Table | Purpose |
|---|---|
+30 -1
View File
@@ -10,7 +10,8 @@ Copy `.env.example` to `.env`. Full list:
| `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) |
| `DATA_DIR` | `./data` | holds `kisassistant.db` (SQLite — unused when `POSTGRESQL_URL` is set) |
| `POSTGRESQL_URL` | *(empty)* | Postgres fallback, e.g. `postgres://kisassistant:secret@localhost:5432/kisassistant` (typically a docker container). Empty = local SQLite. Boot fails fast when unreachable. |
| `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) |
@@ -42,6 +43,34 @@ Smoke tests: start the backend on a **free non-3000 port** with a throwaway
the API with curl/python: login → models → conversations → chat → share →
unshare. See `AGENTS.md`.
## Postgres fallback (docker)
SQLite is the default. For Postgres, run a container and point the app at it:
```bash
docker run -d --name kisassistant-pg --restart unless-stopped \
-e POSTGRES_USER=kisassistant -e POSTGRES_PASSWORD=secret \
-e POSTGRES_DB=kisassistant -p 127.0.0.1:5432:5432 \
-v kisassistant-pgdata:/var/lib/postgresql/data \
postgres:17-alpine
POSTGRESQL_URL=postgres://kisassistant:[email protected]:5432/kisassistant \
DATA_DIR=/tmp/ka-pg-throwaway \
bun run src/index.ts
```
Notes:
- The app uses Bun's native `bun:sql` driver — no extra dependency.
- Schema is created idempotently at boot (same tables as SQLite; timestamps
are `BIGINT`, auto ids `BIGSERIAL`). A fresh database starts empty except
the bootstrapped `admin` row — **there is no SQLite→Postgres data
migration** (re-create accounts or keep SQLite).
- Switching back is just unsetting `POSTGRESQL_URL` (the SQLite file is
untouched while Postgres is active).
- Back up with `pg_dump` (SQLite: copy `kisassistant.db`, or
`.backup` while running under WAL).
## Managing accounts
1. Log in as `admin` with the `APP_PASSWORD` key.
+11
View File
@@ -29,6 +29,15 @@ in {
description = "File containing APP_PASSWORD (use agenix/sops-nix, never store in the store).";
};
ollamaHost = lib.mkOption { type = lib.types.str; default = "http://localhost:11434"; };
postgresqlUrl = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Postgres connection URL (e.g. postgres://user:pass@localhost:5432/kisassistant,
typically a docker container). Null (default) keeps the local SQLite file.
Prefer a credential file + extraEnv for secrets in production.
'';
};
domain = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
@@ -93,6 +102,8 @@ in {
CDN_DIR = "${cfg.dataDir}/cdn";
DRIVER_DEBUG = "false";
OLLAMA_HOST = cfg.ollamaHost;
} // lib.optionalAttrs (cfg.postgresqlUrl != null) {
POSTGRESQL_URL = cfg.postgresqlUrl;
} // lib.optionalAttrs cfg.enableBrowserDriver {
ARCAIC_ENABLED = "true";
ARCAIC_HEADLESS = "true";
+77 -60
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { getDb } from "./db.ts";
import { db } from "./db.ts";
/**
* Access-request wishlist (registration approval queue) + minimal ticket
@@ -33,8 +33,40 @@ export interface AccessMessage {
created_at: number;
}
export function initAccessTables(): void {
const d = getDb();
export async function initAccessTables(): Promise<void> {
const d = db();
const autoId = d.kind === "postgres" ? "BIGSERIAL PRIMARY KEY" : "INTEGER PRIMARY KEY AUTOINCREMENT";
await d.exec(`CREATE TABLE IF NOT EXISTS access_requests (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
email TEXT NOT NULL,
message TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
reason TEXT NOT NULL DEFAULT '',
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
)`);
if (d.kind === "sqlite") {
await repairSqliteAccessTables();
}
await d.exec(`CREATE TABLE IF NOT EXISTS access_messages (
id ${autoId},
request_id TEXT NOT NULL REFERENCES access_requests(id) ON DELETE CASCADE,
author TEXT NOT NULL,
body TEXT NOT NULL,
created_at BIGINT NOT NULL
)`);
await d.exec(`CREATE INDEX IF NOT EXISTS idx_access_requests_status ON access_requests(status, updated_at DESC)`);
await d.exec(`CREATE INDEX IF NOT EXISTS idx_access_messages_req ON access_messages(request_id, id)`);
}
/**
* SQLite-only repairs for DBs shaped by older builds (PRAGMA / sqlite_master
* introspection + DROP COLUMN don't exist on Postgres; fresh Postgres tables
* are created clean above).
*/
async function repairSqliteAccessTables(): Promise<void> {
const d = db();
const createRequests = `CREATE TABLE access_requests (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
@@ -52,16 +84,6 @@ export function initAccessTables(): void {
body TEXT NOT NULL,
created_at INTEGER NOT NULL
)`;
d.query(`CREATE TABLE IF NOT EXISTS access_requests (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
email TEXT NOT NULL,
message TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
reason TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`).run();
// Idempotent migration (must run BEFORE access_messages exists with an FK
// on access_requests — SQLite rewrites dependent FKs on DROP COLUMN and
// would otherwise poison access_messages with a dangling reference).
@@ -70,22 +92,22 @@ export function initAccessTables(): void {
// CREATE TABLE IF NOT EXISTS won't fix existing DBs, and inserts without
// token_hash then fail with SQLITE_CONSTRAINT_NOTNULL.
try {
const cols = d.query("PRAGMA table_info(access_requests)").all() as { name: string }[];
const cols = await d.all<{ name: string }>("PRAGMA table_info(access_requests)");
if (cols.some((c) => c.name === "token_hash")) {
try {
d.query("PRAGMA foreign_keys=OFF").run();
await d.exec("PRAGMA foreign_keys=OFF");
try {
d.query("ALTER TABLE access_requests DROP COLUMN token_hash").run();
await d.exec("ALTER TABLE access_requests DROP COLUMN token_hash");
} catch {
// SQLite builds without DROP COLUMN support: rebuild table.
d.query("ALTER TABLE access_requests RENAME TO access_requests_old").run();
d.query(createRequests).run();
d.query(`INSERT INTO access_requests (id, username, email, message, status, reason, created_at, updated_at)
SELECT id, username, email, message, status, reason, created_at, updated_at FROM access_requests_old`).run();
d.query("DROP TABLE access_requests_old").run();
await d.exec("ALTER TABLE access_requests RENAME TO access_requests_old");
await d.exec(createRequests);
await d.exec(`INSERT INTO access_requests (id, username, email, message, status, reason, created_at, updated_at)
SELECT id, username, email, message, status, reason, created_at, updated_at FROM access_requests_old`);
await d.exec("DROP TABLE access_requests_old");
}
} finally {
d.query("PRAGMA foreign_keys=ON").run();
await d.exec("PRAGMA foreign_keys=ON");
}
}
} catch {
@@ -95,39 +117,30 @@ export function initAccessTables(): void {
// created before the DROP COLUMN), leaving access_messages with
// REFERENCES "access_requests_old". Rebuild it with the correct FK.
try {
const row = d.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'access_messages'").get() as { sql: string } | null;
const row = await d.get<{ sql: string }>("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'access_messages'");
if (row?.sql?.includes("access_requests_old")) {
d.query("PRAGMA foreign_keys=OFF").run();
await d.exec("PRAGMA foreign_keys=OFF");
try {
d.query("DROP TABLE IF EXISTS access_messages_old").run();
d.query("ALTER TABLE access_messages RENAME TO access_messages_old").run();
d.query(createMessages).run();
d.query(`INSERT INTO access_messages (id, request_id, author, body, created_at)
SELECT id, request_id, author, body, created_at FROM access_messages_old`).run();
d.query("DROP TABLE access_messages_old").run();
await d.exec("DROP TABLE IF EXISTS access_messages_old");
await d.exec("ALTER TABLE access_messages RENAME TO access_messages_old");
await d.exec(createMessages);
await d.exec(`INSERT INTO access_messages (id, request_id, author, body, created_at)
SELECT id, request_id, author, body, created_at FROM access_messages_old`);
await d.exec("DROP TABLE access_messages_old");
} finally {
d.query("PRAGMA foreign_keys=ON").run();
await d.exec("PRAGMA foreign_keys=ON");
}
}
} catch {
// Leave schema as-is; errors will surface on write.
}
d.query(`CREATE TABLE IF NOT EXISTS access_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT NOT NULL REFERENCES access_requests(id) ON DELETE CASCADE,
author TEXT NOT NULL,
body TEXT NOT NULL,
created_at INTEGER NOT NULL
)`).run();
d.query(`CREATE INDEX IF NOT EXISTS idx_access_requests_status ON access_requests(status, updated_at DESC)`).run();
d.query(`CREATE INDEX IF NOT EXISTS idx_access_messages_req ON access_messages(request_id, id)`).run();
}
export function createAccessRequest(opts: {
export async function createAccessRequest(opts: {
username: string;
email: string;
message: string;
}): AccessRequest {
}): Promise<AccessRequest> {
const now = Date.now();
const row: AccessRequest = {
id: randomUUID(),
@@ -139,43 +152,47 @@ export function createAccessRequest(opts: {
created_at: now,
updated_at: now,
};
getDb().query(
await db().run(
"INSERT INTO access_requests (id, username, email, message, status, reason, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
).run(row.id, row.username, row.email, row.message, row.status, row.reason, row.created_at, row.updated_at);
row.id, row.username, row.email, row.message, row.status, row.reason, row.created_at, row.updated_at,
);
return row;
}
export function getAccessRequest(id: string): AccessRequest | null {
return getDb().query("SELECT * FROM access_requests WHERE id = ?").get(id) as AccessRequest | null;
export async function getAccessRequest(id: string): Promise<AccessRequest | null> {
return db().get<AccessRequest>("SELECT * FROM access_requests WHERE id = ?", id);
}
/** Open (non-terminal) request already reserving this username or email. */
export function findOpenAccessRequest(username: string, email: string): AccessRequest | null {
return getDb().query(
export async function findOpenAccessRequest(username: string, email: string): Promise<AccessRequest | null> {
return db().get<AccessRequest>(
"SELECT * FROM access_requests WHERE status IN ('pending','reviewing') AND (username = ? OR email = ?) ORDER BY created_at DESC LIMIT 1",
).get(username, email) as AccessRequest | null;
username, email,
);
}
export function listAccessRequests(): (AccessRequest & { message_count: number })[] {
return getDb().query(
export async function listAccessRequests(): Promise<(AccessRequest & { message_count: number })[]> {
return db().all<AccessRequest & { message_count: number }>(
`SELECT r.*, (SELECT COUNT(*) FROM access_messages m WHERE m.request_id = r.id) AS message_count
FROM access_requests r ORDER BY r.updated_at DESC`,
).all() as (AccessRequest & { message_count: number })[];
);
}
export function setAccessStatus(id: string, status: AccessStatus, reason: string): AccessRequest | null {
getDb().query("UPDATE access_requests SET status = ?, reason = ?, updated_at = ? WHERE id = ?").run(status, reason, Date.now(), id);
export async function setAccessStatus(id: string, status: AccessStatus, reason: string): Promise<AccessRequest | null> {
await db().run("UPDATE access_requests SET status = ?, reason = ?, updated_at = ? WHERE id = ?", status, reason, Date.now(), id);
return getAccessRequest(id);
}
export function listAccessMessages(requestId: string): AccessMessage[] {
return getDb().query("SELECT * FROM access_messages WHERE request_id = ? ORDER BY id ASC").all(requestId) as AccessMessage[];
export async function listAccessMessages(requestId: string): Promise<AccessMessage[]> {
return db().all<AccessMessage>("SELECT * FROM access_messages WHERE request_id = ? ORDER BY id ASC", requestId);
}
export function addAccessMessage(requestId: string, author: "user" | "admin", body: string): AccessMessage {
export async function addAccessMessage(requestId: string, author: "user" | "admin", body: string): Promise<AccessMessage> {
const now = Date.now();
getDb().query("INSERT INTO access_messages (request_id, author, body, created_at) VALUES (?, ?, ?, ?)").run(requestId, author, body, now);
getDb().query("UPDATE access_requests SET updated_at = ? WHERE id = ?").run(now, requestId);
const id = (getDb().query("SELECT last_insert_rowid() AS id").get() as { id: number }).id;
const id = await db().insertReturningId(
"INSERT INTO access_messages (request_id, author, body, created_at) VALUES (?, ?, ?, ?)",
requestId, author, body, now,
);
await db().run("UPDATE access_requests SET updated_at = ? WHERE id = ?", now, requestId);
return { id, request_id: requestId, author, body, created_at: now };
}
+12 -16
View File
@@ -3,7 +3,6 @@ import { config } from "./config.ts";
import {
createSession,
deleteSession,
getDb,
getSession,
getUserById,
getUserByUsername,
@@ -11,9 +10,6 @@ import {
type PublicUser,
} from "./db.ts";
// Ensure DB + admin row exist at startup.
getDb();
export type { PublicUser };
function sha256hex(s: string): string {
@@ -49,40 +45,40 @@ function checkAdminKey(candidate: string): boolean {
* - username "admin" authenticates against APP_PASSWORD (.env).
* - other usernames authenticate against their admin-issued access key.
*/
export function login(username: string, key: string): PublicUser | null {
export async function login(username: string, key: string): Promise<PublicUser | null> {
if (username === "admin") {
if (!checkAdminKey(key)) return null;
const admin = getUserByUsername("admin");
const admin = await getUserByUsername("admin");
if (!admin) return null;
return toPublicUser(admin);
}
const u = getUserByUsername(username);
const u = await getUserByUsername(username);
if (!u || !u.key_hash) return null;
if (!secretsEqual(key, u.key_hash)) return null;
return toPublicUser(u);
}
/** Issue a fresh one-time Bearer token per successful login (stored hashed in SQLite). */
export function issueToken(user: PublicUser): { token: string; expiresAt: number } {
/** Issue a fresh one-time Bearer token per successful login (stored hashed server-side). */
export async function issueToken(user: PublicUser): Promise<{ token: string; expiresAt: number }> {
const token = randomBytes(32).toString("hex"); // 256-bit
const expiresAt = Date.now() + config.sessionTtlMs;
createSession(sha256hex(token), user.id, expiresAt);
await createSession(sha256hex(token), user.id, expiresAt);
return { token, expiresAt };
}
export function verifyToken(token: string): PublicUser | null {
const s = getSession(sha256hex(token));
export async function verifyToken(token: string): Promise<PublicUser | null> {
const s = await getSession(sha256hex(token));
if (!s) return null;
const u = getUserById(s.user_id);
const u = await getUserById(s.user_id);
if (!u) {
deleteSession(sha256hex(token));
await deleteSession(sha256hex(token));
return null;
}
return toPublicUser(u);
}
export function revokeToken(token: string): void {
deleteSession(sha256hex(token));
export async function revokeToken(token: string): Promise<void> {
await deleteSession(sha256hex(token));
}
export function extractBearer(req: Request): string | null {
+5
View File
@@ -24,6 +24,11 @@ export const config = {
sessionTtlMs: envInt("SESSION_TTL_HOURS", 720) * 3600_000,
dataDir: env("DATA_DIR", "./data"),
// Database: empty POSTGRESQL_URL = local SQLite in DATA_DIR (default).
// Set POSTGRESQL_URL=postgres://user:pass@host:5432/db to run on Postgres
// (typically a docker container) via Bun's native SQL driver instead.
postgresqlUrl: env("POSTGRESQL_URL", ""),
rateLimitMax: envInt("RATE_LIMIT_MAX", 5),
rateLimitWindowMs: envInt("RATE_LIMIT_WINDOW_MIN", 10) * 60_000,
+113 -198
View File
@@ -1,8 +1,9 @@
import { Database } from "bun:sqlite";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { randomBytes, randomUUID } from "node:crypto";
import { config } from "./config.ts";
import { db, initDb, closeDb, dbKind } from "./db/client.ts";
import type { DbKind } from "./db/client.ts";
export { db, initDb, closeDb, dbKind };
export type { DbKind };
// --- row types ---
@@ -54,94 +55,6 @@ export interface PublicUser {
isAdmin: boolean;
}
// --- db handle ---
let db: Database | null = null;
export function getDb(): Database {
if (db) return db;
mkdirSync(config.dataDir, { recursive: true });
db = new Database(join(config.dataDir, "kisassistant.db"), { create: true });
db.exec("PRAGMA journal_mode = WAL;");
db.exec("PRAGMA foreign_keys = ON;");
db.exec(`
CREATE TABLE IF NOT EXISTS users(
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
avatar_url TEXT NOT NULL DEFAULT '',
theme TEXT NOT NULL DEFAULT 'auto',
key_hash TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions(
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS conversations(
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT 'New chat',
topic TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_conv_user ON conversations(user_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS messages(
id INTEGER PRIMARY KEY AUTOINCREMENT,
conv_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_msg_conv ON messages(conv_id, id);
CREATE TABLE IF NOT EXISTS shares(
conv_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE,
public_id TEXT UNIQUE NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS resets(
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS settings(
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_provider_keys(
user_id TEXT NOT NULL,
provider TEXT NOT NULL,
api_key TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, provider)
);
`);
// Email column added after the initial schema — keep idempotent.
const cols = db.query("PRAGMA table_info(users)").all() as { name: string }[];
if (!cols.some((c) => c.name === "email")) {
db.exec("ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''");
}
// Archive flag added after the initial schema — keep idempotent.
const convCols = db.query("PRAGMA table_info(conversations)").all() as { name: string }[];
if (!convCols.some((c) => c.name === "archived_at")) {
db.exec("ALTER TABLE conversations ADD COLUMN archived_at INTEGER NOT NULL DEFAULT 0");
}
// Admin pseudo-account row (auth still goes through APP_PASSWORD from .env).
const admin = db.query("SELECT id FROM users WHERE username = 'admin'").get() as { id: string } | null;
if (!admin) {
db.query(
"INSERT INTO users (id, username, display_name, avatar_url, theme, key_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
).run(randomUUID(), "admin", "Administrator", "", "auto", "", Date.now());
}
return db;
}
export function toPublicUser(u: UserRow): PublicUser {
return {
id: u.id,
@@ -157,20 +70,19 @@ export function toPublicUser(u: UserRow): PublicUser {
// --- users ---
export function getUserByUsername(username: string): UserRow | null {
return getDb().query("SELECT * FROM users WHERE username = ?").get(username) as UserRow | null;
export async function getUserByUsername(username: string): Promise<UserRow | null> {
return db().get<UserRow>("SELECT * FROM users WHERE username = ?", username);
}
export function getUserById(id: string): UserRow | null {
return getDb().query("SELECT * FROM users WHERE id = ?").get(id) as UserRow | null;
export async function getUserById(id: string): Promise<UserRow | null> {
return db().get<UserRow>("SELECT * FROM users WHERE id = ?", id);
}
export function listUsers(): UserRow[] {
return getDb().query("SELECT * FROM users ORDER BY created_at ASC").all() as UserRow[];
export async function listUsers(): Promise<UserRow[]> {
return db().all<UserRow>("SELECT * FROM users ORDER BY created_at ASC");
}
export function createUser(opts: { username: string; displayName: string; avatarUrl: string; theme: string; email: string; keyHash: string }): UserRow {
const d = getDb();
export async function createUser(opts: { username: string; displayName: string; avatarUrl: string; theme: string; email: string; keyHash: string }): Promise<UserRow> {
const row: UserRow = {
id: randomUUID(),
username: opts.username,
@@ -181,14 +93,14 @@ export function createUser(opts: { username: string; displayName: string; avatar
key_hash: opts.keyHash,
created_at: Date.now(),
};
d.query("INSERT INTO users (id, username, display_name, avatar_url, theme, email, key_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(
await db().run(
"INSERT INTO users (id, username, display_name, avatar_url, theme, email, key_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
row.id, row.username, row.display_name, row.avatar_url, row.theme, row.email, row.key_hash, row.created_at,
);
return row;
}
export function updateUser(id: string, patch: { username?: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string; keyHash?: string }): UserRow | null {
const d = getDb();
export async function updateUser(id: string, patch: { username?: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string; keyHash?: string }): Promise<UserRow | null> {
const sets: string[] = [];
const vals: (string | number)[] = [];
if (patch.username !== undefined) { sets.push("username = ?"); vals.push(patch.username); }
@@ -198,62 +110,62 @@ export function updateUser(id: string, patch: { username?: string; displayName?:
if (patch.email !== undefined) { sets.push("email = ?"); vals.push(patch.email); }
if (patch.keyHash !== undefined) { sets.push("key_hash = ?"); vals.push(patch.keyHash); }
if (sets.length > 0) {
d.query(`UPDATE users SET ${sets.join(", ")} WHERE id = ?`).run(...vals, id);
await db().run(`UPDATE users SET ${sets.join(", ")} WHERE id = ?`, ...vals, id);
}
if (patch.username !== undefined) {
// Username is denormalized onto past reports — keep the triage list consistent.
d.query("UPDATE reports SET reporter_name = ? WHERE reporter_id = ?").run(patch.username, id);
await db().run("UPDATE reports SET reporter_name = ? WHERE reporter_id = ?", patch.username, id);
}
return getUserById(id);
}
export function deleteUser(id: string): void {
const d = getDb();
const convs = d.query("SELECT id FROM conversations WHERE user_id = ?").all(id) as { id: string }[];
const delMsg = d.query("DELETE FROM messages WHERE conv_id = ?");
const delShare = d.query("DELETE FROM shares WHERE conv_id = ?");
const delConv = d.query("DELETE FROM conversations WHERE id = ?");
const txn = d.transaction((cid: string) => {
delMsg.run(cid);
delShare.run(cid);
delConv.run(cid);
export async function deleteUser(id: string): Promise<void> {
const d = db();
const convs = await d.all<{ id: string }>("SELECT id FROM conversations WHERE user_id = ?", id);
await d.transaction(async (tx) => {
for (const c of convs) {
await tx.run("DELETE FROM messages WHERE conv_id = ?", c.id);
await tx.run("DELETE FROM shares WHERE conv_id = ?", c.id);
await tx.run("DELETE FROM conversations WHERE id = ?", c.id);
}
await tx.run("DELETE FROM sessions WHERE user_id = ?", id);
await tx.run("DELETE FROM resets WHERE user_id = ?", id);
await tx.run("DELETE FROM user_provider_keys WHERE user_id = ?", id);
await tx.run("DELETE FROM users WHERE id = ?", id);
});
for (const c of convs) txn(c.id);
d.query("DELETE FROM sessions WHERE user_id = ?").run(id);
d.query("DELETE FROM resets WHERE user_id = ?").run(id);
d.query("DELETE FROM user_provider_keys WHERE user_id = ?").run(id);
d.query("DELETE FROM users WHERE id = ?").run(id);
}
// --- sessions ---
export function createSession(tokenHash: string, userId: string, expiresAt: number): void {
export async function createSession(tokenHash: string, userId: string, expiresAt: number): Promise<void> {
const now = Date.now();
getDb().query("INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(tokenHash, userId, now, expiresAt);
await db().run(
"INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
tokenHash, userId, now, expiresAt,
);
}
export function getSession(tokenHash: string): SessionRow | null {
const d = getDb();
const s = d.query("SELECT * FROM sessions WHERE token_hash = ?").get(tokenHash) as SessionRow | null;
export async function getSession(tokenHash: string): Promise<SessionRow | null> {
const s = await db().get<SessionRow>("SELECT * FROM sessions WHERE token_hash = ?", tokenHash);
if (!s) return null;
if (s.expires_at <= Date.now()) {
d.query("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash);
await db().run("DELETE FROM sessions WHERE token_hash = ?", tokenHash);
return null;
}
return s;
}
export function deleteSession(tokenHash: string): void {
getDb().query("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash);
export async function deleteSession(tokenHash: string): Promise<void> {
await db().run("DELETE FROM sessions WHERE token_hash = ?", tokenHash);
}
export function deleteUserSessions(userId: string): void {
getDb().query("DELETE FROM sessions WHERE user_id = ?").run(userId);
export async function deleteUserSessions(userId: string): Promise<void> {
await db().run("DELETE FROM sessions WHERE user_id = ?", userId);
}
// --- conversations ---
export function createConversation(userId: string, opts: { title?: string; topic?: string; model?: string }): ConversationRow {
export async function createConversation(userId: string, opts: { title?: string; topic?: string; model?: string }): Promise<ConversationRow> {
const now = Date.now();
const row: ConversationRow = {
id: randomUUID(),
@@ -265,69 +177,74 @@ export function createConversation(userId: string, opts: { title?: string; topic
created_at: now,
updated_at: now,
};
getDb().query(
await db().run(
"INSERT INTO conversations (id, user_id, title, topic, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
).run(row.id, row.user_id, row.title, row.topic, row.model, row.created_at, row.updated_at);
row.id, row.user_id, row.title, row.topic, row.model, row.created_at, row.updated_at,
);
return row;
}
export function listConversations(userId: string): ConversationRow[] {
return getDb().query("SELECT * FROM conversations WHERE user_id = ? AND archived_at = 0 ORDER BY updated_at DESC").all(userId) as ConversationRow[];
export async function listConversations(userId: string): Promise<ConversationRow[]> {
return db().all<ConversationRow>(
"SELECT * FROM conversations WHERE user_id = ? AND archived_at = 0 ORDER BY updated_at DESC", userId,
);
}
/** Archived chats: hidden from the sidebar, managed in settings. */
export function listArchivedConversations(userId: string): ConversationRow[] {
return getDb().query("SELECT * FROM conversations WHERE user_id = ? AND archived_at != 0 ORDER BY updated_at DESC").all(userId) as ConversationRow[];
export async function listArchivedConversations(userId: string): Promise<ConversationRow[]> {
return db().all<ConversationRow>(
"SELECT * FROM conversations WHERE user_id = ? AND archived_at != 0 ORDER BY updated_at DESC", userId,
);
}
/**
* (Un)archive a conversation. archived_at is left out of updated_at on
* purpose so an unarchived chat returns to its chronological place.
*/
export function setConversationArchived(id: string, archived: boolean): ConversationRow | null {
getDb().query("UPDATE conversations SET archived_at = ? WHERE id = ?").run(archived ? Date.now() : 0, id);
export async function setConversationArchived(id: string, archived: boolean): Promise<ConversationRow | null> {
await db().run("UPDATE conversations SET archived_at = ? WHERE id = ?", archived ? Date.now() : 0, id);
return getConversation(id);
}
export function getConversation(id: string): ConversationRow | null {
return getDb().query("SELECT * FROM conversations WHERE id = ?").get(id) as ConversationRow | null;
export async function getConversation(id: string): Promise<ConversationRow | null> {
return db().get<ConversationRow>("SELECT * FROM conversations WHERE id = ?", id);
}
export function updateConversation(id: string, patch: { title?: string; topic?: string; model?: string }): ConversationRow | null {
const d = getDb();
export async function updateConversation(id: string, patch: { title?: string; topic?: string; model?: string }): Promise<ConversationRow | null> {
const sets: string[] = ["updated_at = ?"];
const vals: (string | number)[] = [Date.now()];
if (patch.title !== undefined) { sets.push("title = ?"); vals.push(patch.title.slice(0, 120) || "New chat"); }
if (patch.topic !== undefined) { sets.push("topic = ?"); vals.push(patch.topic.slice(0, 120)); }
if (patch.model !== undefined) { sets.push("model = ?"); vals.push(patch.model.slice(0, 160)); }
d.query(`UPDATE conversations SET ${sets.join(", ")} WHERE id = ?`).run(...vals, id);
await db().run(`UPDATE conversations SET ${sets.join(", ")} WHERE id = ?`, ...vals, id);
return getConversation(id);
}
export function touchConversation(id: string, model?: string): void {
export async function touchConversation(id: string, model?: string): Promise<void> {
if (model) {
getDb().query("UPDATE conversations SET updated_at = ?, model = ? WHERE id = ?").run(Date.now(), model.slice(0, 160), id);
await db().run("UPDATE conversations SET updated_at = ?, model = ? WHERE id = ?", Date.now(), model.slice(0, 160), id);
} else {
getDb().query("UPDATE conversations SET updated_at = ? WHERE id = ?").run(Date.now(), id);
await db().run("UPDATE conversations SET updated_at = ? WHERE id = ?", Date.now(), id);
}
}
export function deleteConversation(id: string): void {
const d = getDb();
const txn = d.transaction(() => {
d.query("DELETE FROM messages WHERE conv_id = ?").run(id);
d.query("DELETE FROM shares WHERE conv_id = ?").run(id);
d.query("DELETE FROM conversations WHERE id = ?").run(id);
export async function deleteConversation(id: string): Promise<void> {
await db().transaction(async (tx) => {
await tx.run("DELETE FROM messages WHERE conv_id = ?", id);
await tx.run("DELETE FROM shares WHERE conv_id = ?", id);
await tx.run("DELETE FROM conversations WHERE id = ?", id);
});
txn();
}
export function addMessage(convId: string, role: string, content: string): void {
getDb().query("INSERT INTO messages (conv_id, role, content, created_at) VALUES (?, ?, ?, ?)").run(convId, role, content, Date.now());
export async function addMessage(convId: string, role: string, content: string): Promise<void> {
await db().run(
"INSERT INTO messages (conv_id, role, content, created_at) VALUES (?, ?, ?, ?)",
convId, role, content, Date.now(),
);
}
export function getMessages(convId: string): MessageRow[] {
return getDb().query("SELECT * FROM messages WHERE conv_id = ? ORDER BY id ASC LIMIT 500").all(convId) as MessageRow[];
export async function getMessages(convId: string): Promise<MessageRow[]> {
return db().all<MessageRow>("SELECT * FROM messages WHERE conv_id = ? ORDER BY id ASC LIMIT 500", convId);
}
// --- password-recovery resets (one-time, hashed, expiring) ---
@@ -339,81 +256,80 @@ export interface ResetRow {
expires_at: number;
}
export function createReset(tokenHash: string, userId: string, expiresAt: number): void {
const d = getDb();
export async function createReset(tokenHash: string, userId: string, expiresAt: number): Promise<void> {
// One active reset per account: replace any previous one.
d.query("DELETE FROM resets WHERE user_id = ?").run(userId);
d.query("INSERT INTO resets (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(
await db().run("DELETE FROM resets WHERE user_id = ?", userId);
await db().run(
"INSERT INTO resets (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
tokenHash, userId, Date.now(), expiresAt,
);
}
export function peekReset(tokenHash: string): ResetRow | null {
const d = getDb();
const r = d.query("SELECT * FROM resets WHERE token_hash = ?").get(tokenHash) as ResetRow | null;
export async function peekReset(tokenHash: string): Promise<ResetRow | null> {
const r = await db().get<ResetRow>("SELECT * FROM resets WHERE token_hash = ?", tokenHash);
if (!r) return null;
if (r.expires_at <= Date.now()) {
d.query("DELETE FROM resets WHERE token_hash = ?").run(tokenHash);
await db().run("DELETE FROM resets WHERE token_hash = ?", tokenHash);
return null;
}
return r;
}
/** Consume a reset token (single use). Returns the row, or null if invalid/expired. */
export function consumeReset(tokenHash: string): ResetRow | null {
const r = peekReset(tokenHash);
export async function consumeReset(tokenHash: string): Promise<ResetRow | null> {
const r = await peekReset(tokenHash);
if (!r) return null;
getDb().query("DELETE FROM resets WHERE token_hash = ?").run(tokenHash);
await db().run("DELETE FROM resets WHERE token_hash = ?", tokenHash);
return r;
}
export function deleteUserResets(userId: string): void {
getDb().query("DELETE FROM resets WHERE user_id = ?").run(userId);
export async function deleteUserResets(userId: string): Promise<void> {
await db().run("DELETE FROM resets WHERE user_id = ?", userId);
}
// --- platform settings (admin-controlled feature flags) ---
export function getSetting(key: string, fallback: string): string {
const r = getDb().query("SELECT value FROM settings WHERE key = ?").get(key) as { value: string } | null;
export async function getSetting(key: string, fallback: string): Promise<string> {
const r = await db().get<{ value: string }>("SELECT value FROM settings WHERE key = ?", key);
return r?.value ?? fallback;
}
export function setSetting(key: string, value: string): void {
getDb().query(
export async function setSetting(key: string, value: string): Promise<void> {
await db().run(
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
).run(key, value, Date.now());
key, value, Date.now(),
);
}
/** Public self-registration switch (default off = admin-created accounts only). */
export function isRegistrationEnabled(): boolean {
return getSetting("registration_enabled", "0") === "1";
export async function isRegistrationEnabled(): Promise<boolean> {
return (await getSetting("registration_enabled", "0")) === "1";
}
/** Admin kill-switch for the OCR platform tool (default on). */
export function isOcrToolEnabled(): boolean {
return getSetting("tools_ocr_enabled", "1") === "1";
export async function isOcrToolEnabled(): Promise<boolean> {
return (await getSetting("tools_ocr_enabled", "1")) === "1";
}
/** Admin kill-switch for message reports / flag button (default on). */
export function isReportsEnabled(): boolean {
return getSetting("reports_enabled", "1") === "1";
export async function isReportsEnabled(): Promise<boolean> {
return (await getSetting("reports_enabled", "1")) === "1";
}
/** Admin kill-switch for self-service username changes (default on; admins bypass). */
export function isUsernameChangeEnabled(): boolean {
return getSetting("username_change_enabled", "1") === "1";
export async function isUsernameChangeEnabled(): Promise<boolean> {
return (await getSetting("username_change_enabled", "1")) === "1";
}
// --- public shares ---
export function ensureShare(convId: string): string {
const d = getDb();
const existing = d.query("SELECT public_id FROM shares WHERE conv_id = ?").get(convId) as { public_id: string } | null;
export async function ensureShare(convId: string): Promise<string> {
const existing = await db().get<{ public_id: string }>("SELECT public_id FROM shares WHERE conv_id = ?", convId);
if (existing) return existing.public_id;
for (let i = 0; i < 5; i++) {
const publicId = randomBytes(9).toString("base64url");
try {
d.query("INSERT INTO shares (conv_id, public_id, created_at) VALUES (?, ?, ?)").run(convId, publicId, Date.now());
await db().run("INSERT INTO shares (conv_id, public_id, created_at) VALUES (?, ?, ?)", convId, publicId, Date.now());
return publicId;
} catch {
// collision — retry
@@ -422,25 +338,24 @@ export function ensureShare(convId: string): string {
throw new Error("could not generate share id");
}
export function getShareByConv(convId: string): string | null {
const r = getDb().query("SELECT public_id FROM shares WHERE conv_id = ?").get(convId) as { public_id: string } | null;
export async function getShareByConv(convId: string): Promise<string | null> {
const r = await db().get<{ public_id: string }>("SELECT public_id FROM shares WHERE conv_id = ?", convId);
return r?.public_id ?? null;
}
export function deleteShare(convId: string): void {
getDb().query("DELETE FROM shares WHERE conv_id = ?").run(convId);
export async function deleteShare(convId: string): Promise<void> {
await db().run("DELETE FROM shares WHERE conv_id = ?", convId);
}
export function getShareByPublic(publicId: string): { conv: ConversationRow; messages: MessageRow[]; authorName: string; authorAvatarUrl: string; archived: boolean; sharedAt: number } | null {
const d = getDb();
const s = d.query("SELECT * FROM shares WHERE public_id = ?").get(publicId) as { conv_id: string; created_at: number } | null;
export async function getShareByPublic(publicId: string): Promise<{ conv: ConversationRow; messages: MessageRow[]; authorName: string; authorAvatarUrl: string; archived: boolean; sharedAt: number } | null> {
const s = await db().get<{ conv_id: string; created_at: number }>("SELECT * FROM shares WHERE public_id = ?", publicId);
if (!s) return null;
const conv = getConversation(s.conv_id);
const conv = await getConversation(s.conv_id);
if (!conv) return null;
const author = getUserById(conv.user_id);
const author = await getUserById(conv.user_id);
return {
conv,
messages: getMessages(conv.id).filter((m) => m.role !== "system"),
messages: (await getMessages(conv.id)).filter((m) => m.role !== "system"),
authorName: author?.display_name || "Someone",
authorAvatarUrl: author?.avatar_url || "",
archived: conv.archived_at !== 0,
+445
View File
@@ -0,0 +1,445 @@
import { Database } from "bun:sqlite";
import { SQL } from "bun";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { config } from "../config.ts";
/**
* Database driver abstraction (SQLite default, Postgres opt-in).
*
* - SQLite: Bun's native `bun:sqlite` (synchronous underneath, single file
* in DATA_DIR). Zero setup, the historical default.
* - Postgres: Bun's native `bun:sql` driver (`new SQL(url)`), typically a
* docker container pointed at by POSTGRESQL_URL.
*
* All queries in the codebase use `?` placeholders (SQLite style); the
* Postgres client rewrites them to `$1, $2, …` (quote/comment aware).
* Postgres returns BIGINT cells as JS `bigint` — the client normalizes them
* to `number` (every integer column fits: ms timestamps, counts, ids).
*/
export type DbKind = "sqlite" | "postgres";
export type SqlParam = string | number | bigint | null;
export type SqlParams = SqlParam[];
export interface DbRunResult {
changes: number;
lastInsertId: number | null;
}
export interface DbTx {
get<T>(sql: string, ...params: SqlParams): Promise<T | null>;
all<T>(sql: string, ...params: SqlParams): Promise<T[]>;
run(sql: string, ...params: SqlParams): Promise<DbRunResult>;
exec(sql: string): Promise<void>;
}
export interface DbClient extends DbTx {
readonly kind: DbKind;
/** INSERT a single row and get its auto id back (RETURNING on pg). */
insertReturningId(sql: string, ...params: SqlParams): Promise<number>;
transaction<T>(fn: (tx: DbTx) => Promise<T>): Promise<T>;
close(): Promise<void>;
}
/** Rewrite `?` placeholders to `$n` for Postgres (skips quoted literals). */
export function sqliteToPgPlaceholders(sql: string): string {
let out = "";
let n = 0;
let i = 0;
const len = sql.length;
while (i < len) {
const c = sql[i]!;
// Single-quoted string literal ('' = escaped quote).
if (c === "'") {
out += c;
i++;
while (i < len) {
out += sql[i]!;
if (sql[i] === "'") {
if (sql[i + 1] === "'") { out += sql[i + 1]!; i += 2; continue; }
i++;
break;
}
i++;
}
continue;
}
// Double-quoted identifier ("..." with "" escape).
if (c === '"') {
out += c;
i++;
while (i < len) {
out += sql[i]!;
if (sql[i] === '"') {
if (sql[i + 1] === '"') { out += sql[i + 1]!; i += 2; continue; }
i++;
break;
}
i++;
}
continue;
}
// Line comment.
if (c === "-" && sql[i + 1] === "-") {
while (i < len && sql[i] !== "\n") out += sql[i++]!;
continue;
}
// Block comment.
if (c === "/" && sql[i + 1] === "*") {
out += "/*";
i += 2;
while (i < len && !(sql[i] === "*" && sql[i + 1] === "/")) out += sql[i++]!;
out += "*/";
i += 2;
continue;
}
if (c === "?") {
n++;
out += `$${n}`;
i++;
continue;
}
out += c;
i++;
}
return out;
}
/** Recursively normalize driver row values (bigint -> number). */
export function normalizeValue<T>(v: T): T {
if (typeof v === "bigint") return Number(v) as T;
if (Array.isArray(v)) return v.map(normalizeValue) as T;
if (v !== null && typeof v === "object") {
const o = v as Record<string, unknown>;
for (const k of Object.keys(o)) o[k] = normalizeValue(o[k]);
return v;
}
return v;
}
/** Redacted one-liner for logs (never print credentials). */
export function redactedUrl(url: string): string {
try {
const u = new URL(url);
return `${u.protocol}//${u.host}${u.pathname}`;
} catch {
return "(unparseable url)";
}
}
// --- SQLite implementation (bun:sqlite, sync underneath) ---
class SqliteTx implements DbTx {
constructor(private d: Database) {}
async get<T>(sql: string, ...params: SqlParams): Promise<T | null> {
return (this.d.query(sql).get(...params) as T | null) ?? null;
}
async all<T>(sql: string, ...params: SqlParams): Promise<T[]> {
return this.d.query(sql).all(...params) as T[];
}
async run(sql: string, ...params: SqlParams): Promise<DbRunResult> {
const r = this.d.query(sql).run(...params) as { changes: number; lastInsertRowid: number | bigint };
return { changes: Number(r.changes), lastInsertId: Number(r.lastInsertRowid) };
}
async exec(sql: string): Promise<void> {
this.d.exec(sql);
}
}
class SqliteClient extends SqliteTx implements DbClient {
readonly kind: DbKind = "sqlite";
constructor(private db: Database) {
super(db);
}
async insertReturningId(sql: string, ...params: SqlParams): Promise<number> {
const r = await this.run(sql, ...params);
return r.lastInsertId ?? 0;
}
async transaction<T>(fn: (tx: DbTx) => Promise<T>): Promise<T> {
// Manual BEGIN/COMMIT (bun:sqlite's transaction() only takes sync
// callbacks, so it can't wrap our async bodies — this is equivalent).
this.db.exec("BEGIN IMMEDIATE");
try {
const out = await fn(this);
this.db.exec("COMMIT");
return out;
} catch (e) {
try {
this.db.exec("ROLLBACK");
} catch {
// already rolled back / never began — report the original error
}
throw e;
}
}
async close(): Promise<void> {
this.db.close();
}
}
// --- Postgres implementation (Bun native bun:sql) ---
type PgRunner = {
unsafe: (sql: string, params?: SqlParams) => Promise<unknown>;
};
class PgTx implements DbTx {
constructor(private runner: PgRunner) {}
async get<T>(sql: string, ...params: SqlParams): Promise<T | null> {
const rows = (await this.runner.unsafe(sqliteToPgPlaceholders(sql), params)) as T[];
return rows.length > 0 ? normalizeValue(rows[0]!) : null;
}
async all<T>(sql: string, ...params: SqlParams): Promise<T[]> {
const rows = (await this.runner.unsafe(sqliteToPgPlaceholders(sql), params)) as T[];
return normalizeValue(rows);
}
async run(sql: string, ...params: SqlParams): Promise<DbRunResult> {
const res = (await this.runner.unsafe(sqliteToPgPlaceholders(sql), params)) as { count?: unknown } | null;
const changes = typeof res?.count === "number" ? res.count : 0;
return { changes, lastInsertId: null };
}
async exec(sql: string): Promise<void> {
await this.runner.unsafe(sql);
}
}
class PgClient extends PgTx implements DbClient {
readonly kind: DbKind = "postgres";
constructor(private sql: SQL) {
super(sql as unknown as PgRunner);
}
async insertReturningId(sql: string, ...params: SqlParams): Promise<number> {
const row = await this.get<{ id: number | bigint }>(`${sql} RETURNING id`, ...params);
return row ? Number(row.id) : 0;
}
async transaction<T>(fn: (tx: DbTx) => Promise<T>): Promise<T> {
return this.sql.begin(async (tx) => fn(new PgTx(tx as unknown as PgRunner)));
}
async close(): Promise<void> {
await this.sql.close();
}
}
// --- singleton ---
let client: DbClient | null = null;
export function db(): DbClient {
if (!client) throw new Error("database not initialized — call initDb() first");
return client;
}
export function dbKind(): DbKind {
return client?.kind ?? (config.postgresqlUrl ? "postgres" : "sqlite");
}
/** Connect + create schema idempotently. Fails fast when unreachable. */
export async function initDb(): Promise<DbClient> {
if (client) return client;
const url = config.postgresqlUrl.trim();
if (url) {
if (!/^postgres(ql)?:\/\//i.test(url)) {
throw new Error(`POSTGRESQL_URL must start with postgres:// or postgresql:// (got ${redactedUrl(url)})`);
}
const sql = new SQL(url);
try {
await sql`SELECT 1 AS ok`;
} catch (e) {
await sql.close().catch(() => {});
throw new Error(`cannot reach Postgres at ${redactedUrl(url)}: ${(e as Error).message}`);
}
client = new PgClient(sql);
await initPgSchema(client);
console.log(`[db] using postgres (${redactedUrl(url)})`);
return client;
}
mkdirSync(config.dataDir, { recursive: true });
const dbo = new Database(join(config.dataDir, "kisassistant.db"), { create: true });
dbo.exec("PRAGMA journal_mode = WAL;");
dbo.exec("PRAGMA foreign_keys = ON;");
client = new SqliteClient(dbo);
await initSqliteSchema(client);
console.log(`[db] using sqlite (${join(config.dataDir, "kisassistant.db")})`);
return client;
}
export async function closeDb(): Promise<void> {
if (client) {
await client.close();
client = null;
}
}
// --- core schema (users/sessions/conversations/messages/shares/resets/settings/providers) ---
const CORE_TABLES_SQLITE = `
CREATE TABLE IF NOT EXISTS users(
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
avatar_url TEXT NOT NULL DEFAULT '',
theme TEXT NOT NULL DEFAULT 'auto',
key_hash TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions(
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS conversations(
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT 'New chat',
topic TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_conv_user ON conversations(user_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS messages(
id INTEGER PRIMARY KEY AUTOINCREMENT,
conv_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_msg_conv ON messages(conv_id, id);
CREATE TABLE IF NOT EXISTS shares(
conv_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE,
public_id TEXT UNIQUE NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS resets(
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS settings(
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_provider_keys(
user_id TEXT NOT NULL,
provider TEXT NOT NULL,
api_key TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, provider)
);
`;
const CORE_TABLES_PG = `
CREATE TABLE IF NOT EXISTS users(
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
avatar_url TEXT NOT NULL DEFAULT '',
theme TEXT NOT NULL DEFAULT 'auto',
key_hash TEXT NOT NULL DEFAULT '',
created_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions(
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS conversations(
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT 'New chat',
topic TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_conv_user ON conversations(user_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS messages(
id BIGSERIAL PRIMARY KEY,
conv_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_msg_conv ON messages(conv_id, id);
CREATE TABLE IF NOT EXISTS shares(
conv_id TEXT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE,
public_id TEXT UNIQUE NOT NULL,
created_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS resets(
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings(
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_provider_keys(
user_id TEXT NOT NULL,
provider TEXT NOT NULL,
api_key TEXT NOT NULL,
updated_at BIGINT NOT NULL,
PRIMARY KEY (user_id, provider)
);
`;
/** Columns added after the initial schema — idempotent on both drivers. */
const LATER_COLUMNS = [
"ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''",
"ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''",
"ALTER TABLE users ADD COLUMN report_shadowbanned INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE conversations ADD COLUMN archived_at INTEGER NOT NULL DEFAULT 0",
];
/** Split a multi-statement DDL string into single statements. */
function splitStatements(ddl: string): string[] {
return ddl
.split(";")
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
async function ensureAdminRow(c: DbClient): Promise<void> {
const admin = await c.get<{ id: string }>("SELECT id FROM users WHERE username = 'admin'");
if (!admin) {
const { randomUUID } = await import("node:crypto");
await c.run(
"INSERT INTO users (id, username, display_name, avatar_url, theme, key_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
randomUUID(), "admin", "Administrator", "", "auto", "", Date.now(),
);
}
}
async function initSqliteSchema(c: DbClient): Promise<void> {
await c.exec(CORE_TABLES_SQLITE);
// Column guards via PRAGMA (kept from the single-driver era).
const cols = await c.all<{ name: string }>("PRAGMA table_info(users)");
const names = new Set(cols.map((col) => col.name));
if (!names.has("email")) await c.exec("ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''");
if (!names.has("totp_secret")) await c.exec("ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''");
if (!names.has("report_shadowbanned")) await c.exec("ALTER TABLE users ADD COLUMN report_shadowbanned INTEGER NOT NULL DEFAULT 0");
const convCols = await c.all<{ name: string }>("PRAGMA table_info(conversations)");
if (!convCols.some((col) => col.name === "archived_at")) {
await c.exec("ALTER TABLE conversations ADD COLUMN archived_at INTEGER NOT NULL DEFAULT 0");
}
await ensureAdminRow(c);
}
async function initPgSchema(c: DbClient): Promise<void> {
for (const stmt of splitStatements(CORE_TABLES_PG)) await c.exec(stmt);
// ADD COLUMN IF NOT EXISTS is natively idempotent on Postgres.
for (const ddl of LATER_COLUMNS) {
const m = ddl.match(/^ALTER TABLE (\S+) ADD COLUMN (\S+) (.+)$/);
if (!m) continue;
await c.exec(`ALTER TABLE ${m[1]} ADD COLUMN IF NOT EXISTS ${m[2]} ${m[3]}`);
}
await ensureAdminRow(c);
}
+150 -137
View File
@@ -25,13 +25,14 @@ import {
deleteUserSessions,
ensureShare,
getConversation,
getDb,
db,
getMessages,
getShareByConv,
getShareByPublic,
getSetting,
getUserById,
getUserByUsername,
initDb,
isRegistrationEnabled,
isReportsEnabled,
isUsernameChangeEnabled,
@@ -50,6 +51,7 @@ import type { ChatMessage } from "./drivers/types.ts";
import {
consumeTotpChallenge,
createTotpChallenge,
deleteTotpChallenges,
getTotpSecret,
initTotpTables,
newTotpSecret,
@@ -123,14 +125,15 @@ import { OCR_MAX_BYTES, ocrAllowed, recordOcrJob } from "./tools/ocr.ts";
import { tools } from "./tools/registry.ts";
assertConfig();
await initDb();
const registry = new DriverRegistry();
// Ollama handles admin model pulls/deletes (host is loopback/LAN, no auth).
const ollamaDriver = registry.get("ollama") as OllamaDriver;
await tools.init();
initAccessTables();
initReportsTables();
initModelUsageTables();
initTotpTables();
await initAccessTables();
await initReportsTables();
await initModelUsageTables();
await initTotpTables();
function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
@@ -149,7 +152,7 @@ function clientIp(req: Request, server: { requestIP?: (r: Request) => { address:
return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
}
function requireAuth(req: Request): PublicUser | null {
async function requireAuth(req: Request): Promise<PublicUser | null> {
const token = extractBearer(req);
if (!token) return null;
return verifyToken(token);
@@ -273,7 +276,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
// --- public: shared chat page data (no auth) ---
const shareMatch = path.match(/^\/api\/share\/([A-Za-z0-9_-]{6,64})$/);
if (shareMatch && req.method === "GET") {
const s = getShareByPublic(shareMatch[1]!);
const s = await getShareByPublic(shareMatch[1]!);
if (!s) return json({ error: "share not found" }, 404);
return json({
title: s.conv.title,
@@ -338,7 +341,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
// extension always comes from magic-byte detection, never the client.
const cdnPut = path.match(/^\/cdn\/([a-z0-9]{1,16})\/([A-Za-z0-9_-]{1,64})$/);
if (cdnPut && req.method === "PUT") {
const user = requireAuth(req);
const user = await requireAuth(req);
if (!user) return json({ error: "unauthorized" }, 401);
const [, ns, key] = cdnPut as [string, string, string];
if (!validNamespace(ns) || !validKey(key)) return json({ error: "unknown namespace or key" }, 404);
@@ -385,7 +388,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
recordAttempt(ip);
return json({ error: 'expected { username, key }' }, 400);
}
const user = login(body.username.trim().toLowerCase(), body.key);
const user = await login(body.username.trim().toLowerCase(), body.key);
if (!user) {
recordAttempt(ip);
await Bun.sleep(400);
@@ -394,11 +397,11 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
clearAttempts(ip);
// Second factor: correct key but TOTP enabled -> short-lived challenge,
// the session is only issued after POST /api/auth/totp verifies a code.
if (getTotpSecret(user.id)) {
const totpToken = createTotpChallenge(user.id);
if (await getTotpSecret(user.id)) {
const totpToken = await createTotpChallenge(user.id);
return json({ totpRequired: true, totpToken, username: user.username });
}
const { token, expiresAt } = issueToken(user);
const { token, expiresAt } = await issueToken(user);
return json({ token, expiresAt, user });
}
@@ -408,27 +411,27 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (isRateLimited(ip)) return json({ error: "too many attempts, try again later" }, 429);
const { ok, body } = await readJson(req);
const userId =
ok && typeof body.totpToken === "string" ? consumeTotpChallenge(body.totpToken) : null;
ok && typeof body.totpToken === "string" ? await consumeTotpChallenge(body.totpToken) : null;
const code = ok && typeof body.code === "string" ? body.code : "";
const target = userId ? getUserById(userId) : null;
if (!target || !verifyTotp(getTotpSecret(target.id), code)) {
const target = userId ? await getUserById(userId) : null;
if (!target || !verifyTotp(await getTotpSecret(target.id), code)) {
recordAttempt(ip);
await Bun.sleep(400);
return json({ error: "invalid code" }, 401);
}
clearAttempts(ip);
const { token, expiresAt } = issueToken(toPublicUser(target));
const { token, expiresAt } = await issueToken(toPublicUser(target));
return json({ token, expiresAt, user: toPublicUser(target) });
}
if (path === "/api/auth/verify" && req.method === "GET") {
const user = requireAuth(req);
const user = await requireAuth(req);
return user ? json({ ok: true, user }) : json({ error: "unauthorized" }, 401);
}
if (path === "/api/auth/logout" && req.method === "POST") {
const token = extractBearer(req);
if (token) revokeToken(token);
if (token) await revokeToken(token);
return json({ ok: true });
}
@@ -437,8 +440,8 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
return json({
recovery: mailEnabled(),
from: mailEnabled() ? config.smtpFrom : undefined,
registration: isRegistrationEnabled(),
accessRequest: getSetting("registration_request_enabled", "1") === "1",
registration: await isRegistrationEnabled(),
accessRequest: (await getSetting("registration_request_enabled", "1")) === "1",
});
}
@@ -446,7 +449,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (path === "/api/auth/register" && req.method === "POST") {
const ip = clientIp(req, server);
if (isRateLimited(ip)) return json({ error: "too many attempts, try again later" }, 429);
if (!isRegistrationEnabled()) {
if (!await isRegistrationEnabled()) {
recordAttempt(ip);
return json({ error: "registration is currently disabled on this platform" }, 403);
}
@@ -460,7 +463,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
recordAttempt(ip);
return json({ error: "username: 2-32 chars [a-z0-9._-], 'admin' reserved" }, 400);
}
if (getUserByUsername(username)) {
if (await getUserByUsername(username)) {
recordAttempt(ip);
return json({ error: "username taken" }, 409);
}
@@ -475,7 +478,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
return json({ error: "invalid email address" }, 400);
}
const key = newAccessKey();
const created = createUser({ username, displayName, avatarUrl: "", theme: "auto", email, keyHash: hashSecret(key) });
const created = await createUser({ username, displayName, avatarUrl: "", theme: "auto", email, keyHash: hashSecret(key) });
clearAttempts(ip);
// Show the raw key exactly once.
return json({ user: toPublicUser(created), key }, 201);
@@ -488,10 +491,10 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
// Never enumerate accounts: always answer ok.
if (ok && typeof body.username === "string") {
const name = body.username.trim().toLowerCase();
const target = name === "admin" ? null : getUserByUsername(name);
const target = name === "admin" ? null : await getUserByUsername(name);
if (target && target.email && mailEnabled()) {
const token = "rt_" + randomBytes(24).toString("base64url");
createReset(createHash("sha256").update(token).digest("hex"), target.id, Date.now() + config.resetTtlMs);
await createReset(createHash("sha256").update(token).digest("hex"), target.id, Date.now() + config.resetTtlMs);
sendRecoveryEmail(target.email, target.username, `${config.appUrl}/reset/${token}`).catch(() => {});
clearAttempts(ip);
return json({ ok: true });
@@ -506,19 +509,19 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (resetMatch) {
const tokenHash = createHash("sha256").update(resetMatch[1]!).digest("hex");
if (req.method === "GET") {
const r = peekReset(tokenHash);
const u = r ? getUserById(r.user_id) : null;
const r = await peekReset(tokenHash);
const u = r ? await getUserById(r.user_id) : null;
if (!r || !u) return json({ error: "invalid or expired link" }, 404);
return json({ ok: true, username: u.username });
}
if (req.method === "POST") {
const r = consumeReset(tokenHash);
const u = r ? getUserById(r.user_id) : null;
const r = await consumeReset(tokenHash);
const u = r ? await getUserById(r.user_id) : null;
if (!r || !u || u.username === "admin") return json({ error: "invalid or expired link" }, 404);
// Recovery = instant key rotation; the new key is shown exactly once.
const key = newAccessKey();
updateUser(u.id, { keyHash: hashSecret(key) });
deleteUserSessions(u.id);
await updateUser(u.id, { keyHash: hashSecret(key) });
await deleteUserSessions(u.id);
return json({ key });
}
}
@@ -530,7 +533,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (isRateLimited(ip)) return json({ error: "too many attempts, try again later" }, 429);
// Wishlist is mutually exclusive with open registration: when anyone
// can register, there is nothing to request.
if (isRegistrationEnabled() || getSetting("registration_request_enabled", "1") !== "1") {
if ((await isRegistrationEnabled()) || (await getSetting("registration_request_enabled", "1")) !== "1") {
recordAttempt(ip);
return json({ error: "access requests are currently disabled on this platform" }, 403);
}
@@ -546,16 +549,16 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
recordAttempt(ip);
return json({ error: "username (2-32 chars), valid email and a motivation (10+ chars) are required" }, 400);
}
if (getUserByUsername(username)) {
if (await getUserByUsername(username)) {
recordAttempt(ip);
return json({ error: "username taken" }, 409);
}
if (findOpenAccessRequest(username, email)) {
if (await findOpenAccessRequest(username, email)) {
recordAttempt(ip);
return json({ error: "a request for this username or email is already open" }, 409);
}
const created = createAccessRequest({ username, email, message });
const firstMsg = addAccessMessage(created.id, "user", message);
const created = await createAccessRequest({ username, email, message });
const firstMsg = await addAccessMessage(created.id, "user", message);
clearAttempts(ip);
broadcastAccessLive({ type: "access_created", request_id: created.id, request: created });
broadcastAccessLive({ type: "access_message", request_id: created.id, message: firstMsg });
@@ -567,10 +570,10 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
const ticketMatch = path.match(/^\/api\/access\/ticket\/([0-9a-f-]{36})(\/message)?$/);
if (ticketMatch) {
const r = getAccessRequest(ticketMatch[1]!);
const r = await getAccessRequest(ticketMatch[1]!);
if (!r) return json({ error: "invalid ticket link" }, 404);
if (req.method === "GET" && !ticketMatch[2]) {
return json({ request: r, messages: listAccessMessages(r.id) });
return json({ request: r, messages: await listAccessMessages(r.id) });
}
if (req.method === "POST" && ticketMatch[2] === "/message") {
const ip = clientIp(req, server);
@@ -584,7 +587,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
recordAttempt(ip);
return json({ error: "message: 1-2000 chars" }, 400);
}
const msg = addAccessMessage(r.id, "user", text);
const msg = await addAccessMessage(r.id, "user", text);
clearAttempts(ip);
broadcastAccessLive({ type: "access_message", request_id: r.id, message: msg });
return json({ message: msg }, 201);
@@ -598,14 +601,14 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
// ?token= (browsers can't set WS headers) or Authorization header.
const ticketWsMatch = path.match(/^\/api\/access\/ws\/([0-9a-f-]{36})$/);
if (ticketWsMatch && req.method === "GET") {
const t = getAccessRequest(ticketWsMatch[1]!);
const t = await getAccessRequest(ticketWsMatch[1]!);
if (!t) return json({ error: "invalid ticket link" }, 404);
if (server.upgrade(req, { data: { ticketId: t.id, isAdmin: false } })) return;
return json({ error: "websocket upgrade failed" }, 500);
}
if (path === "/api/admin/ws" && req.method === "GET") {
const token = url.searchParams.get("token") ?? extractBearer(req);
const u = token ? verifyToken(token) : null;
const u = token ? await verifyToken(token) : null;
if (!u?.isAdmin) return json({ error: "unauthorized" }, 401);
if (server.upgrade(req, { data: { ticketId: null, isAdmin: true } })) return;
return json({ error: "websocket upgrade failed" }, 500);
@@ -613,7 +616,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
// --- everything below requires auth ---
if (path.startsWith("/api/")) {
const user = requireAuth(req);
const user = await requireAuth(req);
if (!user) return json({ error: "unauthorized" }, 401);
const admin = user.isAdmin;
@@ -626,10 +629,10 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
const patch: { username?: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string } = {};
if (body.username !== undefined) {
if (user.username === "admin") return json({ error: "the admin account cannot be renamed" }, 403);
if (!isUsernameChangeEnabled()) return json({ error: "username changes are disabled" }, 403);
if (!await isUsernameChangeEnabled()) return json({ error: "username changes are disabled" }, 403);
const u = validUsername(body.username);
if (!u) return json({ error: "username: 2-32 chars [a-z0-9._-], 'admin' reserved" }, 400);
const taken = getUserByUsername(u);
const taken = await getUserByUsername(u);
if (taken && taken.id !== user.id) return json({ error: "username taken" }, 409);
patch.username = u;
}
@@ -652,7 +655,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (e === null) return json({ error: "invalid email address" }, 400);
patch.email = e;
}
const updated = updateUser(user.id, patch);
const updated = await updateUser(user.id, patch);
if (!updated) return json({ error: "user not found" }, 404);
return json({ user: toPublicUser(updated) });
}
@@ -660,25 +663,25 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
// --- self-service security: rotate access key (old sessions revoked) ---
if (path === "/api/me/key/rotate" && req.method === "POST") {
const key = newAccessKey();
updateUser(user.id, { keyHash: hashSecret(key) });
deleteUserSessions(user.id);
await updateUser(user.id, { keyHash: hashSecret(key) });
await deleteUserSessions(user.id);
return json({ key });
}
// --- self-service: delete your own account (chats, shares, sessions gone) ---
if (path === "/api/me" && req.method === "DELETE") {
if (user.username === "admin") return json({ error: "the admin account cannot be deleted" }, 403);
deleteUser(user.id);
getDb().query("DELETE FROM totp_challenges WHERE user_id = ?").run(user.id);
await deleteUser(user.id);
await deleteTotpChallenges(user.id);
return json({ ok: true });
}
// --- self-service security: TOTP two-factor ---
if (path === "/api/me/totp" && req.method === "GET") {
return json({ enabled: getTotpSecret(user.id).length > 0 });
return json({ enabled: (await getTotpSecret(user.id)).length > 0 });
}
if (path === "/api/me/totp/setup" && req.method === "POST") {
if (getTotpSecret(user.id)) return json({ error: "TOTP already enabled — disable it first" }, 409);
if (await getTotpSecret(user.id)) return json({ error: "TOTP already enabled — disable it first" }, 409);
const secret = newTotpSecret();
return json({ secret, otpauthUrl: totpAuthUrl(secret, user.username) });
}
@@ -688,14 +691,14 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
const code = ok && typeof body.code === "string" ? body.code : "";
if (!/^[A-Z2-7]{16,64}$/.test(secret.trim().toUpperCase())) return json({ error: "invalid secret" }, 400);
if (!verifyTotp(secret, code)) return json({ error: "invalid code" }, 401);
setTotpSecret(user.id, secret.trim().toUpperCase());
await setTotpSecret(user.id, secret.trim().toUpperCase());
return json({ enabled: true });
}
if (path === "/api/me/totp" && req.method === "DELETE") {
const { ok, body } = await readJson(req);
const code = ok && typeof body.code === "string" ? body.code : "";
if (!verifyTotp(getTotpSecret(user.id), code)) return json({ error: "invalid code" }, 401);
setTotpSecret(user.id, "");
if (!verifyTotp(await getTotpSecret(user.id), code)) return json({ error: "invalid code" }, 401);
await setTotpSecret(user.id, "");
return json({ enabled: false });
}
@@ -704,7 +707,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
// presence + last4. Connected providers add their models to this
// user's /api/models list and /api/chat honors the personal key.
if (path === "/api/me/providers" && req.method === "GET") {
return json({ providers: listUserProviders(user.id) });
return json({ providers: await listUserProviders(user.id) });
}
const providerMatch = path.match(/^\/api\/me\/providers\/([a-z]{3,12})$/);
@@ -718,12 +721,12 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
const { ok, body } = await readJson(req);
const key = ok ? validProviderKey(body.apiKey) : null;
if (!key) return json({ error: "apiKey: 8-256 chars, no whitespace" }, 400);
setUserProviderKey(user.id, provider, key);
return json({ providers: listUserProviders(user.id) });
await setUserProviderKey(user.id, provider, key);
return json({ providers: await listUserProviders(user.id) });
}
if (req.method === "DELETE") {
deleteUserProviderKey(user.id, provider);
return json({ providers: listUserProviders(user.id) });
await deleteUserProviderKey(user.id, provider);
return json({ providers: await listUserProviders(user.id) });
}
return json({ error: "not found" }, 404);
}
@@ -737,7 +740,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
const filter = url.searchParams.get("filter") || "all"; // all | with-email | no-email
const per = Math.min(Math.max(Number(url.searchParams.get("per")) || 10, 1), 50);
const page = Math.max(Number(url.searchParams.get("page")) || 1, 1);
let users = listUsers();
let users = await listUsers();
if (q) {
users = users.filter(
(u) =>
@@ -768,7 +771,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (!ok) return json({ error: "invalid JSON" }, 400);
const username = validUsername(body.username);
if (!username) return json({ error: "username: 2-32 chars [a-z0-9._-], 'admin' reserved" }, 400);
if (getUserByUsername(username)) return json({ error: "username taken" }, 409);
if (await getUserByUsername(username)) return json({ error: "username taken" }, 409);
const displayName = body.displayName === undefined ? username : cleanStr(body.displayName, 60);
if (displayName === null) return json({ error: "displayName: 1-60 chars" }, 400);
const avatarUrl = body.avatarUrl === undefined ? "" : validAvatar(body.avatarUrl);
@@ -778,7 +781,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
const email = body.email === undefined ? "" : validEmail(body.email);
if (email === null) return json({ error: "invalid email address" }, 400);
const key = newAccessKey();
const created = createUser({ username, displayName, avatarUrl, theme, email, keyHash: hashSecret(key) });
const created = await createUser({ username, displayName, avatarUrl, theme, email, keyHash: hashSecret(key) });
// Show the raw key exactly once.
return json({ user: toPublicUser(created), key }, 201);
}
@@ -786,16 +789,16 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
const adminUserMatch = path.match(/^\/api\/admin\/users\/([0-9a-f-]{10,50})(\/regenerate|\/shadowban)?$/);
if (adminUserMatch) {
if (!admin) return json({ error: "forbidden" }, 403);
const target = getUserById(adminUserMatch[1]!);
const target = await getUserById(adminUserMatch[1]!);
if (!target || target.username === "admin") return json({ error: "user not found" }, 404);
if (req.method === "DELETE" && !adminUserMatch[2]) {
deleteUser(target.id);
await deleteUser(target.id);
return json({ ok: true });
}
if (req.method === "POST" && adminUserMatch[2] === "/regenerate") {
const key = newAccessKey();
updateUser(target.id, { keyHash: hashSecret(key) });
deleteUserSessions(target.id);
await updateUser(target.id, { keyHash: hashSecret(key) });
await deleteUserSessions(target.id);
return json({ key });
}
if (req.method === "POST" && adminUserMatch[2] === "/shadowban") {
@@ -803,7 +806,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (!ok || typeof body.shadowbanned !== "boolean") {
return json({ error: "expected { shadowbanned: boolean }" }, 400);
}
return json({ shadowbanned: setReportShadowbanned(target.id, body.shadowbanned) === 1 });
return json({ shadowbanned: (await setReportShadowbanned(target.id, body.shadowbanned)) === 1 });
}
if (req.method === "PATCH" && !adminUserMatch[2]) {
const { ok, body } = await readJson(req);
@@ -813,7 +816,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (target.username === "admin") return json({ error: "the admin account cannot be renamed" }, 403);
const u = validUsername(body.username);
if (!u) return json({ error: "username: 2-32 chars [a-z0-9._-], 'admin' reserved" }, 400);
const taken = getUserByUsername(u);
const taken = await getUserByUsername(u);
if (taken && taken.id !== target.id) return json({ error: "username taken" }, 409);
patch.username = u;
}
@@ -836,7 +839,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (e === null) return json({ error: "invalid email address" }, 400);
patch.email = e;
}
const updated = updateUser(target.id, patch);
const updated = await updateUser(target.id, patch);
return json({ user: toPublicUser(updated!) });
}
return json({ error: "not found" }, 404);
@@ -847,11 +850,11 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (!admin) return json({ error: "forbidden" }, 403);
return json({
settings: {
registrationEnabled: isRegistrationEnabled(),
accessRequestEnabled: getSetting("registration_request_enabled", "1") === "1",
ocrEnabled: getSetting("tools_ocr_enabled", "1") === "1",
reportsEnabled: isReportsEnabled(),
usernameChangeEnabled: isUsernameChangeEnabled(),
registrationEnabled: await isRegistrationEnabled(),
accessRequestEnabled: (await getSetting("registration_request_enabled", "1")) === "1",
ocrEnabled: (await getSetting("tools_ocr_enabled", "1")) === "1",
reportsEnabled: await isReportsEnabled(),
usernameChangeEnabled: await isUsernameChangeEnabled(),
},
});
}
@@ -862,38 +865,38 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (!ok) return json({ error: "invalid JSON" }, 400);
if (body.registrationEnabled !== undefined) {
if (typeof body.registrationEnabled !== "boolean") return json({ error: "registrationEnabled must be boolean" }, 400);
setSetting("registration_enabled", body.registrationEnabled ? "1" : "0");
await setSetting("registration_enabled", body.registrationEnabled ? "1" : "0");
// Opening registration retires the wishlist automatically.
if (body.registrationEnabled) setSetting("registration_request_enabled", "0");
if (body.registrationEnabled) await setSetting("registration_request_enabled", "0");
}
if (body.accessRequestEnabled !== undefined) {
if (typeof body.accessRequestEnabled !== "boolean") return json({ error: "accessRequestEnabled must be boolean" }, 400);
// Mutually exclusive with open registration: the wishlist only
// makes sense when anyone-can-register is off.
if (body.accessRequestEnabled && (body.registrationEnabled ?? isRegistrationEnabled())) {
if (body.accessRequestEnabled && (body.registrationEnabled ?? await isRegistrationEnabled())) {
return json({ error: "disable public registration first — the wishlist replaces it" }, 409);
}
setSetting("registration_request_enabled", body.accessRequestEnabled ? "1" : "0");
await setSetting("registration_request_enabled", body.accessRequestEnabled ? "1" : "0");
}
if (body.ocrEnabled !== undefined) {
if (typeof body.ocrEnabled !== "boolean") return json({ error: "ocrEnabled must be boolean" }, 400);
setSetting("tools_ocr_enabled", body.ocrEnabled ? "1" : "0");
await setSetting("tools_ocr_enabled", body.ocrEnabled ? "1" : "0");
}
if (body.reportsEnabled !== undefined) {
if (typeof body.reportsEnabled !== "boolean") return json({ error: "reportsEnabled must be boolean" }, 400);
setSetting("reports_enabled", body.reportsEnabled ? "1" : "0");
await setSetting("reports_enabled", body.reportsEnabled ? "1" : "0");
}
if (body.usernameChangeEnabled !== undefined) {
if (typeof body.usernameChangeEnabled !== "boolean") return json({ error: "usernameChangeEnabled must be boolean" }, 400);
setSetting("username_change_enabled", body.usernameChangeEnabled ? "1" : "0");
await setSetting("username_change_enabled", body.usernameChangeEnabled ? "1" : "0");
}
return json({
settings: {
registrationEnabled: isRegistrationEnabled(),
accessRequestEnabled: getSetting("registration_request_enabled", "1") === "1",
ocrEnabled: getSetting("tools_ocr_enabled", "1") === "1",
reportsEnabled: isReportsEnabled(),
usernameChangeEnabled: isUsernameChangeEnabled(),
registrationEnabled: await isRegistrationEnabled(),
accessRequestEnabled: (await getSetting("registration_request_enabled", "1")) === "1",
ocrEnabled: (await getSetting("tools_ocr_enabled", "1")) === "1",
reportsEnabled: await isReportsEnabled(),
usernameChangeEnabled: await isUsernameChangeEnabled(),
},
});
}
@@ -1041,7 +1044,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
// --- content reports (flagged AI responses) ---
if (path === "/api/reports" && req.method === "POST") {
if (!isReportsEnabled()) return json({ error: "reports are disabled" }, 403);
if (!await isReportsEnabled()) return json({ error: "reports are disabled" }, 403);
const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400);
const reason = body.reason as ReportReason | undefined;
@@ -1059,7 +1062,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (typeof body.conversationId !== "string" || !body.conversationId) {
return json({ error: "bad conversationId" }, 400);
}
const conv = getConversation(body.conversationId);
const conv = await getConversation(body.conversationId);
if (!conv || conv.user_id !== user.id) return json({ error: "conversation not found" }, 404);
conversationId = conv.id;
if (body.messageIndex !== undefined) {
@@ -1070,7 +1073,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
}
}
try {
const report = createReport({
const report = await createReport({
reporterId: user.id,
reporterName: user.displayName,
conversationId,
@@ -1093,13 +1096,13 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (path === "/api/admin/reports" && req.method === "GET") {
if (!admin) return json({ error: "forbidden" }, 403);
return json({ reports: listReports() });
return json({ reports: await listReports() });
}
const reportMatch = path.match(/^\/api\/admin\/reports\/([0-9a-f-]{36})$/);
if (reportMatch) {
if (!admin) return json({ error: "forbidden" }, 403);
const target = getReport(reportMatch[1]!);
const target = await getReport(reportMatch[1]!);
if (!target) return json({ error: "report not found" }, 404);
if (req.method === "PATCH") {
const { ok, body } = await readJson(req);
@@ -1110,7 +1113,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
}
const adminNote = cleanOptionalStr(body.adminNote, 1000);
if (adminNote === null) return json({ error: "adminNote: max 1000 chars" }, 400);
return json({ report: setReportStatus(target.id, status, adminNote) });
return json({ report: await setReportStatus(target.id, status, adminNote) });
}
}
@@ -1122,7 +1125,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
}
// Full list (policy never hides models from admins) merged with policy.
const models = await registry.listAllModelsCached();
const policy = getModelPolicy();
const policy = await getModelPolicy();
return json({
models: models.map((m) => ({ ...m, ...policyFor(policy, m.id) })),
});
@@ -1140,7 +1143,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
return json({ error: "nothing to update (enabled, hourly, daily)" }, 400);
}
try {
const entry = setModelPolicyEntry(b.model, {
const entry = await setModelPolicyEntry(b.model, {
enabled: b.enabled as boolean | undefined,
hourly: b.hourly as number | undefined,
daily: b.daily as number | undefined,
@@ -1154,23 +1157,23 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
// --- admin: access-request wishlist triage ---
if (path === "/api/admin/access" && req.method === "GET") {
if (!admin) return json({ error: "forbidden" }, 403);
return json({ requests: listAccessRequests() });
return json({ requests: await listAccessRequests() });
}
const accessMatch = path.match(/^\/api\/admin\/access\/([0-9a-f-]{36})(\/message)?$/);
if (accessMatch) {
if (!admin) return json({ error: "forbidden" }, 403);
const target = getAccessRequest(accessMatch[1]!);
const target = await getAccessRequest(accessMatch[1]!);
if (!target) return json({ error: "request not found" }, 404);
const reviewUrl = `${config.appUrl}/review/${target.id}`;
if (req.method === "GET" && !accessMatch[2]) {
return json({ request: target, messages: listAccessMessages(target.id) });
return json({ request: target, messages: await listAccessMessages(target.id) });
}
if (req.method === "POST" && accessMatch[2] === "/message") {
const { ok, body } = await readJson(req);
const text = ok ? cleanStr(body.body, 2000) : null;
if (!text) return json({ error: "message: 1-2000 chars" }, 400);
const msg = addAccessMessage(target.id, "admin", text);
const msg = await addAccessMessage(target.id, "admin", text);
// Every admin reply is forwarded to the requester (notification-only).
sendAccessMessageEmail(target.email, target.username, true, text, reviewUrl).catch(() => {});
broadcastAccessLive({ type: "access_message", request_id: target.id, message: msg });
@@ -1190,11 +1193,11 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
}
let key: string | undefined;
if (status === "accepted" && target.status !== "accepted") {
if (getUserByUsername(target.username)) return json({ error: "username taken" }, 409);
if (await getUserByUsername(target.username)) return json({ error: "username taken" }, 409);
key = newAccessKey();
createUser({ username: target.username, displayName: target.username, avatarUrl: "", theme: "auto", email: target.email, keyHash: hashSecret(key) });
await createUser({ username: target.username, displayName: target.username, avatarUrl: "", theme: "auto", email: target.email, keyHash: hashSecret(key) });
}
const updated = setAccessStatus(target.id, status, reason);
const updated = await setAccessStatus(target.id, status, reason);
if (status !== target.status && status !== "pending") {
sendAccessStatusEmail(target.email, target.username, status, reason, reviewUrl, key).catch(() => {});
}
@@ -1242,28 +1245,34 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
// --- conversations (server-persisted, per account) ---
if (path === "/api/conversations" && req.method === "GET") {
return json({ conversations: listConversations(user.id) });
return json({ conversations: await listConversations(user.id) });
}
// Archived chats (hidden from the sidebar, managed in settings).
if (path === "/api/conversations/archived" && req.method === "GET") {
return json({ conversations: listArchivedConversations(user.id) });
return json({ conversations: await listArchivedConversations(user.id) });
}
// Full-text-ish search over your own chats: titles, topics AND old
// prompts/replies. Returns the matching conversations, most recent
// first, with a short snippet of the first hit.
// Correlated subqueries (not GROUP BY c.id + bare m.* columns): the
// loose form runs on SQLite but Postgres rejects it.
if (path === "/api/conversations/search" && req.method === "GET") {
const q = (url.searchParams.get("q") ?? "").trim().slice(0, 120);
if (q.length < 2) return json({ conversations: [] });
const like = `%${q.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
const rows = getDb().query(
`SELECT c.*, m.content AS hit, m.role AS hit_role FROM conversations c
LEFT JOIN messages m ON m.conv_id = c.id
AND m.content LIKE ? ESCAPE '\\'
WHERE c.user_id = ? AND c.archived_at = 0 AND (c.title LIKE ? ESCAPE '\\' OR c.topic LIKE ? ESCAPE '\\' OR m.id IS NOT NULL)
GROUP BY c.id ORDER BY c.updated_at DESC LIMIT 30`,
).all(like, user.id, like, like) as (ConversationRow & { hit: string | null; hit_role: string | null })[];
const rows = await db().all<ConversationRow & { hit: string | null; hit_role: string | null }>(
`SELECT c.*,
(SELECT m.content FROM messages m WHERE m.conv_id = c.id AND m.content LIKE ? ESCAPE '\\' ORDER BY m.id ASC LIMIT 1) AS hit,
(SELECT m.role FROM messages m WHERE m.conv_id = c.id AND m.content LIKE ? ESCAPE '\\' ORDER BY m.id ASC LIMIT 1) AS hit_role
FROM conversations c
WHERE c.user_id = ? AND c.archived_at = 0
AND (c.title LIKE ? ESCAPE '\\' OR c.topic LIKE ? ESCAPE '\\'
OR EXISTS (SELECT 1 FROM messages m WHERE m.conv_id = c.id AND m.content LIKE ? ESCAPE '\\'))
ORDER BY c.updated_at DESC LIMIT 30`,
like, like, user.id, like, like, like,
);
return json({
conversations: rows.map(({ hit, hit_role, ...c }) => {
let snippet: string | null = null;
@@ -1284,7 +1293,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (path === "/api/conversations" && req.method === "POST") {
const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400);
const conv = createConversation(user.id, {
const conv = await createConversation(user.id, {
title: typeof body.title === "string" ? body.title : undefined,
topic: typeof body.topic === "string" ? body.topic : undefined,
model: typeof body.model === "string" ? body.model : undefined,
@@ -1294,35 +1303,35 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
const archiveMatch = path.match(/^\/api\/conversations\/([0-9a-f-]{10,50})\/(archive|unarchive)$/);
if (archiveMatch && req.method === "POST") {
const conv = getConversation(archiveMatch[1]!);
const conv = await getConversation(archiveMatch[1]!);
if (!conv || conv.user_id !== user.id) return json({ error: "conversation not found" }, 404);
return json({ conversation: setConversationArchived(conv.id, archiveMatch[2] === "archive") });
return json({ conversation: await setConversationArchived(conv.id, archiveMatch[2] === "archive") });
}
const convMatch = path.match(/^\/api\/conversations\/([0-9a-f-]{10,50})(\/(share))?$/);
if (convMatch) {
const conv = getConversation(convMatch[1]!);
const conv = await getConversation(convMatch[1]!);
if (!conv || conv.user_id !== user.id) return json({ error: "conversation not found" }, 404);
const isShare = convMatch[3] === "share";
if (isShare) {
if (req.method === "POST") {
const publicId = ensureShare(conv.id);
const publicId = await ensureShare(conv.id);
return json({ publicId, url: `/share/${publicId}` }, 201);
}
if (req.method === "GET") {
const publicId = getShareByConv(conv.id);
const publicId = await getShareByConv(conv.id);
return publicId ? json({ publicId, url: `/share/${publicId}` }) : json({ shared: false });
}
if (req.method === "DELETE") {
deleteShare(conv.id);
await deleteShare(conv.id);
return json({ ok: true });
}
return json({ error: "not found" }, 404);
}
if (req.method === "GET") {
return json({ conversation: conv, messages: getMessages(conv.id) });
return json({ conversation: conv, messages: await getMessages(conv.id) });
}
if (req.method === "PATCH") {
const { ok, body } = await readJson(req);
@@ -1340,10 +1349,10 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (typeof body.model !== "string" || body.model.length > 160) return json({ error: "model too long" }, 400);
patch.model = body.model;
}
return json({ conversation: updateConversation(conv.id, patch) });
return json({ conversation: await updateConversation(conv.id, patch) });
}
if (req.method === "DELETE") {
deleteConversation(conv.id);
await deleteConversation(conv.id);
return json({ ok: true });
}
return json({ error: "not found" }, 404);
@@ -1378,18 +1387,22 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (admin) return json({ models });
// Users never see admin-disabled models (nor select them — /api/chat
// enforces the same policy server-side).
const policy = getModelPolicy();
const policy = await getModelPolicy();
return json({ models: models.filter((m) => policyFor(policy, m.id).enabled) });
}
// --- platform tools (uploads always go through tools) ---
if (path === "/api/tools" && req.method === "GET") {
return json({ tools: tools.list(), reportsEnabled: isReportsEnabled(), usernameChangeEnabled: isUsernameChangeEnabled() });
return json({
tools: await tools.list(),
reportsEnabled: await isReportsEnabled(),
usernameChangeEnabled: await isUsernameChangeEnabled(),
});
}
if (path === "/api/tools/ocr" && req.method === "POST") {
const ocr = tools.ocr;
if (!ocr.isAvailable()) return json({ error: ocr.unavailableReason() ?? "ocr unavailable" }, 501);
if (!await ocr.isAvailable()) return json({ error: (await ocr.unavailableReason()) ?? "ocr unavailable" }, 501);
const declared = Number(req.headers.get("content-length") || "0");
if (declared > OCR_MAX_BYTES) return json({ error: "image too large (max 5MB)" }, 413);
let buf: Uint8Array;
@@ -1430,7 +1443,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
// BYOK precedence: when the caller connected this provider with
// their own key, the request is billed to them, not the platform.
if (isUserProviderId(driver.name)) {
const personalKey = getUserProviderKey(user.id, driver.name);
const personalKey = await getUserProviderKey(user.id, driver.name);
if (personalKey) driver = buildUserProviderDriver(driver.name, personalKey);
}
} catch (e) {
@@ -1441,7 +1454,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
const pname = idx === -1 ? "" : fullId.slice(0, idx);
const sub = idx === -1 ? "" : fullId.slice(idx + 1);
if (!isUserProviderId(pname)) return json({ error: (e as Error).message }, 400);
const personalKey = getUserProviderKey(user.id, pname);
const personalKey = await getUserProviderKey(user.id, pname);
if (!personalKey || !sub) return json({ error: (e as Error).message }, 400);
driver = buildUserProviderDriver(pname, personalKey);
model = sub;
@@ -1449,43 +1462,43 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
// Per-model access policy (kill-switch + per-user rate limits).
// Admins bypass; attempts count even when generation later fails.
if (!user.isAdmin) {
const access = checkModelAccess(user.id, body.model);
const access = await checkModelAccess(user.id, body.model);
if (!access.ok) return json({ error: access.message }, access.code);
recordModelUse(user.id, body.model);
await recordModelUse(user.id, body.model);
}
const messages = body.messages as ChatMessage[];
// Optional persistence into a server-side conversation (must belong to the user).
let conv = null;
if (body.conversationId !== undefined) {
if (typeof body.conversationId !== "string") return json({ error: "bad conversationId" }, 400);
conv = getConversation(body.conversationId);
conv = await getConversation(body.conversationId);
if (!conv || conv.user_id !== user.id) return json({ error: "conversation not found" }, 404);
// Archived chats are read-only: no new messages may be written.
if (conv.archived_at) return json({ error: "conversation is archived (read-only) — unarchive it to write again" }, 403);
// Persist the new user message (dedupe against last stored one).
const lastUser = [...messages].reverse().find((m) => m.role === "user");
const stored = getMessages(conv.id);
const stored = await getMessages(conv.id);
const lastStored = stored[stored.length - 1];
if (lastUser && (!lastStored || lastStored.content !== lastUser.content || lastStored.role !== "user")) {
addMessage(conv.id, "user", lastUser.content);
await addMessage(conv.id, "user", lastUser.content);
if (conv.title === "New chat") {
updateConversation(conv.id, { title: lastUser.content.slice(0, 50) || "New chat" });
await updateConversation(conv.id, { title: lastUser.content.slice(0, 50) || "New chat" });
}
}
}
const wantStream = body.stream === true || req.headers.get("accept") === "text/event-stream";
const persistReply = (text: string) => {
const persistReply = async (text: string): Promise<void> => {
if (conv && text) {
addMessage(conv.id, "assistant", text);
touchConversation(conv.id, body.model as string);
await addMessage(conv.id, "assistant", text);
await touchConversation(conv.id, body.model as string);
}
};
if (!wantStream) {
try {
const text = await driver.chat(messages, { model, signal: req.signal });
persistReply(text);
await persistReply(text);
return json({ model: body.model, message: { role: "assistant", content: text } });
} catch (e) {
return json({ error: (e as Error).message }, 502);
@@ -1523,12 +1536,12 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
full += token;
if (!safeSend("token", JSON.stringify({ token }))) break;
}
persistReply(full);
await persistReply(full);
safeSend("done", "{}");
} catch (e) {
// Genuine driver error: keep the partial text as-is (no internal
// markers persisted) and notify the client if still connected.
if (full) persistReply(full);
if (full) await persistReply(full);
if (!req.signal.aborted && !clientGone) {
safeSend("error", JSON.stringify({ error: (e as Error).message }));
}
+31 -28
View File
@@ -1,4 +1,4 @@
import { getDb, getSetting, setSetting } from "./db.ts";
import { db, getSetting, setSetting } from "./db.ts";
/**
* Per-model access policy (admin kill-switch + per-user rate limits).
@@ -41,8 +41,8 @@ function isDefault(e: ModelPolicyEntry): boolean {
return e.enabled && e.hourly === 0 && e.daily === 0;
}
export function getModelPolicy(): ModelPolicy {
const raw = getSetting(SETTINGS_KEY, "");
export async function getModelPolicy(): Promise<ModelPolicy> {
const raw = await getSetting(SETTINGS_KEY, "");
if (!raw) return {};
try {
const obj = JSON.parse(raw) as Record<string, unknown>;
@@ -61,14 +61,14 @@ export function policyFor(policy: ModelPolicy, modelId: string): ModelPolicyEntr
return policy[modelId] ?? { ...DEFAULT_POLICY };
}
export function setModelPolicyEntry(
export async function setModelPolicyEntry(
modelId: string,
patch: { enabled?: boolean; hourly?: number; daily?: number },
): ModelPolicyEntry {
): Promise<ModelPolicyEntry> {
if (patch.enabled !== undefined && typeof patch.enabled !== "boolean") throw new Error("BAD_POLICY");
if (patch.hourly !== undefined && !Number.isInteger(patch.hourly)) throw new Error("BAD_POLICY");
if (patch.daily !== undefined && !Number.isInteger(patch.daily)) throw new Error("BAD_POLICY");
const policy = getModelPolicy();
const policy = await getModelPolicy();
const cur = policy[modelId] ?? { ...DEFAULT_POLICY };
const next: ModelPolicyEntry = {
enabled: patch.enabled ?? cur.enabled,
@@ -80,53 +80,56 @@ export function setModelPolicyEntry(
}
if (isDefault(next)) delete policy[modelId];
else policy[modelId] = next;
setSetting(SETTINGS_KEY, JSON.stringify(policy));
await setSetting(SETTINGS_KEY, JSON.stringify(policy));
return { ...DEFAULT_POLICY, ...policy[modelId] };
}
export function initModelUsageTables(): void {
getDb().query(`CREATE TABLE IF NOT EXISTS model_usage (
export async function initModelUsageTables(): Promise<void> {
// "window" is quoted: WINDOW is a reserved keyword on Postgres (and the
// quotes are harmless on SQLite).
await db().exec(`CREATE TABLE IF NOT EXISTS model_usage (
user_id TEXT NOT NULL,
model TEXT NOT NULL,
window TEXT NOT NULL,
bucket INTEGER NOT NULL,
"window" TEXT NOT NULL,
bucket BIGINT NOT NULL,
count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, model, window, bucket)
)`).run();
PRIMARY KEY (user_id, model, "window", bucket)
)`);
}
function buckets(now = Date.now()): { h: number; d: number } {
return { h: Math.floor(now / 3600_000), d: Math.floor(now / 86400_000) };
}
function windowCount(userId: string, modelId: string, window: "h" | "d", bucket: number): number {
const row = getDb().query(
"SELECT count AS n FROM model_usage WHERE user_id = ? AND model = ? AND window = ? AND bucket = ?",
).get(userId, modelId, window, bucket) as { n: number } | null;
async function windowCount(userId: string, modelId: string, window: "h" | "d", bucket: number): Promise<number> {
const row = await db().get<{ n: number }>(
'SELECT count AS n FROM model_usage WHERE user_id = ? AND model = ? AND "window" = ? AND bucket = ?',
userId, modelId, window, bucket,
);
return row?.n ?? 0;
}
export type ModelAccess = { ok: true } | { ok: false; code: 403 | 429; message: string };
export function checkModelAccess(userId: string, modelId: string): ModelAccess {
const p = policyFor(getModelPolicy(), modelId);
export async function checkModelAccess(userId: string, modelId: string): Promise<ModelAccess> {
const p = policyFor(await getModelPolicy(), modelId);
if (!p.enabled) return { ok: false, code: 403, message: "model disabled by administrator" };
const { h, d } = buckets();
if (p.hourly > 0 && windowCount(userId, modelId, "h", h) >= p.hourly) {
if (p.hourly > 0 && (await windowCount(userId, modelId, "h", h)) >= p.hourly) {
return { ok: false, code: 429, message: "hourly limit reached for this model, try again later" };
}
if (p.daily > 0 && windowCount(userId, modelId, "d", d) >= p.daily) {
if (p.daily > 0 && (await windowCount(userId, modelId, "d", d)) >= p.daily) {
return { ok: false, code: 429, message: "daily limit reached for this model, try again tomorrow" };
}
return { ok: true };
}
export function recordModelUse(userId: string, modelId: string): void {
const d = getDb();
export async function recordModelUse(userId: string, modelId: string): Promise<void> {
const d = db();
const { h, d: day } = buckets();
d.query(`INSERT INTO model_usage (user_id, model, window, bucket, count) VALUES (?, ?, 'h', ?, 1)
ON CONFLICT(user_id, model, window, bucket) DO UPDATE SET count = count + 1`).run(userId, modelId, h);
d.query(`INSERT INTO model_usage (user_id, model, window, bucket, count) VALUES (?, ?, 'd', ?, 1)
ON CONFLICT(user_id, model, window, bucket) DO UPDATE SET count = count + 1`).run(userId, modelId, day);
d.query("DELETE FROM model_usage WHERE (window = 'h' AND bucket < ?) OR (window = 'd' AND bucket < ?)").run(h - 1, day - 1);
await d.run(`INSERT INTO model_usage (user_id, model, "window", bucket, count) VALUES (?, ?, 'h', ?, 1)
ON CONFLICT(user_id, model, "window", bucket) DO UPDATE SET count = count + 1`, userId, modelId, h);
await d.run(`INSERT INTO model_usage (user_id, model, "window", bucket, count) VALUES (?, ?, 'd', ?, 1)
ON CONFLICT(user_id, model, "window", bucket) DO UPDATE SET count = count + 1`, userId, modelId, day);
await d.run('DELETE FROM model_usage WHERE ("window" = \'h\' AND bucket < ?) OR ("window" = \'d\' AND bucket < ?)', h - 1, day - 1);
}
+47 -40
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { getConversation, getDb, getMessages } from "./db.ts";
import { getConversation, getMessages, db } from "./db.ts";
/**
* Content reports (user-flagged AI responses). Tables are created
@@ -38,14 +38,14 @@ export interface Report {
updated_at: number;
}
export function initReportsTables(): void {
const d = getDb();
d.query(`CREATE TABLE IF NOT EXISTS reports (
export async function initReportsTables(): Promise<void> {
const d = db();
await d.exec(`CREATE TABLE IF NOT EXISTS reports (
id TEXT PRIMARY KEY,
reporter_id TEXT NOT NULL,
reporter_name TEXT NOT NULL DEFAULT '',
conversation_id TEXT NOT NULL DEFAULT '',
message_index INTEGER NOT NULL DEFAULT -1,
message_index BIGINT NOT NULL DEFAULT -1,
content TEXT NOT NULL DEFAULT '',
prompt TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
@@ -53,21 +53,27 @@ export function initReportsTables(): void {
details TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'open',
admin_note TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`).run();
d.query(`CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status, updated_at DESC)`).run();
d.query(`CREATE INDEX IF NOT EXISTS idx_reports_reporter ON reports(reporter_id, created_at DESC)`).run();
d.query(`CREATE INDEX IF NOT EXISTS idx_reports_message ON reports(conversation_id, message_index)`).run();
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
)`);
await d.exec(`CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status, updated_at DESC)`);
await d.exec(`CREATE INDEX IF NOT EXISTS idx_reports_reporter ON reports(reporter_id, created_at DESC)`);
await d.exec(`CREATE INDEX IF NOT EXISTS idx_reports_message ON reports(conversation_id, message_index)`);
if (d.kind === "sqlite") {
// Prompt snapshot added after the initial schema — keep idempotent.
const rcols = d.query("PRAGMA table_info(reports)").all() as { name: string }[];
const rcols = await d.all<{ name: string }>("PRAGMA table_info(reports)");
if (!rcols.some((c) => c.name === "prompt")) {
d.query("ALTER TABLE reports ADD COLUMN prompt TEXT NOT NULL DEFAULT ''").run();
await d.exec("ALTER TABLE reports ADD COLUMN prompt TEXT NOT NULL DEFAULT ''");
}
// Per-user report shadow-ban (unreliable reporters) — keep idempotent.
const ucols = d.query("PRAGMA table_info(users)").all() as { name: string }[];
const ucols = await d.all<{ name: string }>("PRAGMA table_info(users)");
if (!ucols.some((c) => c.name === "report_shadowbanned")) {
d.query("ALTER TABLE users ADD COLUMN report_shadowbanned INTEGER NOT NULL DEFAULT 0").run();
await d.exec("ALTER TABLE users ADD COLUMN report_shadowbanned INTEGER NOT NULL DEFAULT 0");
}
} else {
// Postgres: ADD COLUMN IF NOT EXISTS is natively idempotent.
await d.exec("ALTER TABLE reports ADD COLUMN IF NOT EXISTS prompt TEXT NOT NULL DEFAULT ''");
await d.exec("ALTER TABLE users ADD COLUMN IF NOT EXISTS report_shadowbanned INTEGER NOT NULL DEFAULT 0");
}
}
@@ -75,14 +81,15 @@ export function initReportsTables(): void {
export const MAX_OPEN_REPORTS = 20;
/** Still-triageable report already covering this message, if any. */
export function findOpenReport(conversationId: string, messageIndex: number): Report | null {
export async function findOpenReport(conversationId: string, messageIndex: number): Promise<Report | null> {
if (!conversationId || messageIndex < 0) return null;
return getDb().query(
return db().get<Report>(
"SELECT r.*, COALESCE(u.report_shadowbanned, 0) AS reporter_shadowbanned FROM reports r LEFT JOIN users u ON u.id = r.reporter_id WHERE r.conversation_id = ? AND r.message_index = ? AND r.status IN ('open','reviewing') ORDER BY r.created_at DESC LIMIT 1",
).get(conversationId, messageIndex) as Report | null;
conversationId, messageIndex,
);
}
export function createReport(opts: {
export async function createReport(opts: {
reporterId: string;
reporterName: string;
conversationId: string;
@@ -92,28 +99,28 @@ export function createReport(opts: {
clientContent: string;
clientPrompt: string;
clientModel: string;
}): Report {
const d = getDb();
}): Promise<Report> {
const dup = opts.conversationId && opts.messageIndex >= 0
? findOpenReport(opts.conversationId, opts.messageIndex)
? await findOpenReport(opts.conversationId, opts.messageIndex)
: null;
if (dup) {
const err = new Error("ALREADY_REPORTED") as Error & { report: Report };
err.report = dup;
throw err;
}
const open = d.query(
const open = await db().get<{ n: number }>(
"SELECT COUNT(*) AS n FROM reports WHERE reporter_id = ? AND status IN ('open','reviewing')",
).get(opts.reporterId) as { n: number };
if (open.n >= MAX_OPEN_REPORTS) throw new Error("TOO_MANY_OPEN");
opts.reporterId,
);
if ((open?.n ?? 0) >= MAX_OPEN_REPORTS) throw new Error("TOO_MANY_OPEN");
let content = opts.clientContent;
let prompt = opts.clientPrompt;
let model = opts.clientModel;
if (opts.conversationId) {
const conv = getConversation(opts.conversationId);
const conv = await getConversation(opts.conversationId);
if (conv && conv.user_id === opts.reporterId) {
if (conv.model) model = conv.model;
const msgs = getMessages(conv.id).filter((m) => m.role !== "system");
const msgs = (await getMessages(conv.id)).filter((m) => m.role !== "system");
if (opts.messageIndex >= 0) {
const at = msgs[opts.messageIndex];
if (at) content = at.content;
@@ -140,9 +147,8 @@ export function createReport(opts: {
created_at: now,
updated_at: now,
};
d.query(
await db().run(
"INSERT INTO reports (id, reporter_id, reporter_name, conversation_id, message_index, content, prompt, model, reason, details, status, admin_note, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
).run(
row.id, row.reporter_id, row.reporter_name, row.conversation_id, row.message_index,
row.content, row.prompt, row.model, row.reason, row.details, row.status, row.admin_note,
row.created_at, row.updated_at,
@@ -150,20 +156,21 @@ export function createReport(opts: {
return row;
}
export function listReports(): Report[] {
return getDb().query(
export async function listReports(): Promise<Report[]> {
return db().all<Report>(
"SELECT r.*, COALESCE(u.report_shadowbanned, 0) AS reporter_shadowbanned FROM reports r LEFT JOIN users u ON u.id = r.reporter_id ORDER BY r.updated_at DESC LIMIT 500",
).all() as Report[];
);
}
export function getReport(id: string): Report | null {
return getDb().query(
export async function getReport(id: string): Promise<Report | null> {
return db().get<Report>(
"SELECT r.*, COALESCE(u.report_shadowbanned, 0) AS reporter_shadowbanned FROM reports r LEFT JOIN users u ON u.id = r.reporter_id WHERE r.id = ?",
).get(id) as Report | null;
id,
);
}
export function setReportStatus(id: string, status: ReportStatus, adminNote: string): Report | null {
getDb().query("UPDATE reports SET status = ?, admin_note = ?, updated_at = ? WHERE id = ?").run(
export async function setReportStatus(id: string, status: ReportStatus, adminNote: string): Promise<Report | null> {
await db().run("UPDATE reports SET status = ?, admin_note = ?, updated_at = ? WHERE id = ?",
status, adminNote, Date.now(), id,
);
return getReport(id);
@@ -173,8 +180,8 @@ export function setReportStatus(id: string, status: ReportStatus, adminNote: str
* Shadow-ban a reporter: their reports are still accepted but flagged
* unreliable (excluded from the admin badge). Returns the new flag.
*/
export function setReportShadowbanned(userId: string, shadowbanned: boolean): number {
getDb().query("UPDATE users SET report_shadowbanned = ? WHERE id = ?").run(shadowbanned ? 1 : 0, userId);
const row = getDb().query("SELECT report_shadowbanned AS v FROM users WHERE id = ?").get(userId) as { v: number } | null;
export async function setReportShadowbanned(userId: string, shadowbanned: boolean): Promise<number> {
await db().run("UPDATE users SET report_shadowbanned = ? WHERE id = ?", shadowbanned ? 1 : 0, userId);
const row = await db().get<{ v: number }>("SELECT report_shadowbanned AS v FROM users WHERE id = ?", userId);
return row?.v ?? 0;
}
+5 -5
View File
@@ -62,20 +62,20 @@ export class OcrTool implements PlatformTool {
this.available = availableCache;
}
isAvailable(): boolean {
return (this.available ?? false) && isOcrToolEnabled();
async isAvailable(): Promise<boolean> {
return (this.available ?? false) && (await isOcrToolEnabled());
}
unavailableReason(): string | null {
async unavailableReason(): Promise<string | null> {
if (!(this.available ?? false)) {
return `tesseract binary not found (looked for "${tessBin()}"). Install tesseract or set TESSERACT_BIN.`;
}
if (!isOcrToolEnabled()) return "OCR is disabled by the administrator (Admin Center → Features).";
if (!(await isOcrToolEnabled())) return "OCR is disabled by the administrator (Admin Center → Features).";
return null;
}
async transcribe(image: Uint8Array): Promise<{ text: string; truncated: boolean }> {
if (!this.isAvailable()) throw new Error(this.unavailableReason() ?? "ocr unavailable");
if (!(await this.isAvailable())) throw new Error((await this.unavailableReason()) ?? "ocr unavailable");
const t0 = Date.now();
const maxChars = config.ocrMaxChars;
let proc;
+7 -5
View File
@@ -10,13 +10,15 @@ class ToolRegistry {
await this.ocr.init();
}
list(): { name: string; description: string; available: boolean; reason: string | null }[] {
return this.tools.map((t) => ({
async list(): Promise<{ name: string; description: string; available: boolean; reason: string | null }[]> {
return Promise.all(
this.tools.map(async (t) => ({
name: t.name,
description: t.description,
available: t.isAvailable(),
reason: t.unavailableReason(),
}));
available: await t.isAvailable(),
reason: await t.unavailableReason(),
})),
);
}
}
+4 -3
View File
@@ -1,8 +1,9 @@
/** Platform tool: a server-side capability the frontend invokes explicitly
* (uploads always go through tools — never raw to the model). */
* (uploads always go through tools — never raw to the model). Availability
* is async: feature flags live in the database now. */
export interface PlatformTool {
readonly name: string;
readonly description: string;
isAvailable(): boolean;
unavailableReason(): string | null;
isAvailable(): Promise<boolean>;
unavailableReason(): Promise<string | null>;
}
+30 -21
View File
@@ -1,5 +1,5 @@
import { createHash, createHmac, randomBytes } from "node:crypto";
import { getDb } from "./db.ts";
import { db } from "./db.ts";
/**
* TOTP two-factor (RFC 6238, SHA-1, 30s step, 6 digits, ±1 window).
@@ -13,19 +13,23 @@ const DIGITS = 6;
const WINDOW = 1;
const CHALLENGE_TTL_MS = 5 * 60_000;
export function initTotpTables(): void {
const d = getDb();
export async function initTotpTables(): Promise<void> {
const d = db();
if (d.kind === "sqlite") {
try {
d.query("ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''").run();
await d.exec("ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''");
} catch {
// column already exists — fine
}
d.query(`CREATE TABLE IF NOT EXISTS totp_challenges (
} else {
await d.exec("ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret TEXT NOT NULL DEFAULT ''");
}
await d.exec(`CREATE TABLE IF NOT EXISTS totp_challenges (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
)`).run();
created_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL
)`);
}
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
@@ -104,36 +108,41 @@ export function verifyTotp(secret: string, code: string): boolean {
return ok;
}
export function getTotpSecret(userId: string): string {
const row = getDb().query("SELECT totp_secret AS s FROM users WHERE id = ?").get(userId) as { s: string } | null;
export async function getTotpSecret(userId: string): Promise<string> {
const row = await db().get<{ s: string }>("SELECT totp_secret AS s FROM users WHERE id = ?", userId);
return row?.s ?? "";
}
export function setTotpSecret(userId: string, secret: string): void {
getDb().query("UPDATE users SET totp_secret = ? WHERE id = ?").run(secret, userId);
export async function setTotpSecret(userId: string, secret: string): Promise<void> {
await db().run("UPDATE users SET totp_secret = ? WHERE id = ?", secret, userId);
}
/** Short-lived login challenge after a correct key; returns the raw token. */
export function createTotpChallenge(userId: string): string {
export async function createTotpChallenge(userId: string): Promise<string> {
const token = "tc_" + randomBytes(24).toString("base64url");
const hash = createHash("sha256").update(token).digest("hex");
const now = Date.now();
getDb().query("INSERT INTO totp_challenges (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(
await db().run(
"INSERT INTO totp_challenges (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
hash, userId, now, now + CHALLENGE_TTL_MS,
);
// Opportunistic cleanup.
getDb().query("DELETE FROM totp_challenges WHERE expires_at < ?").run(now);
await db().run("DELETE FROM totp_challenges WHERE expires_at < ?", now);
return token;
}
export function consumeTotpChallenge(token: string): string | null {
export async function consumeTotpChallenge(token: string): Promise<string | null> {
const hash = createHash("sha256").update(token).digest("hex");
const row = getDb().query("SELECT user_id, expires_at FROM totp_challenges WHERE token_hash = ?").get(hash) as {
user_id: string;
expires_at: number;
} | null;
const row = await db().get<{ user_id: string; expires_at: number }>(
"SELECT user_id, expires_at FROM totp_challenges WHERE token_hash = ?", hash,
);
if (!row) return null;
getDb().query("DELETE FROM totp_challenges WHERE token_hash = ?").run(hash);
await db().run("DELETE FROM totp_challenges WHERE token_hash = ?", hash);
if (row.expires_at < Date.now()) return null;
return row.user_id;
}
/** Housekeeping for account deletion (no other caller needs it). */
export async function deleteTotpChallenges(userId: string): Promise<void> {
await db().run("DELETE FROM totp_challenges WHERE user_id = ?", userId);
}
+19 -18
View File
@@ -1,4 +1,4 @@
import { getDb } from "./db.ts";
import { db } from "./db.ts";
import { AnthropicDriver, DeepSeekDriver, GeminiDriver, OpenAIDriver } from "./drivers/apis.ts";
import type { DriverModel, LLMDriver } from "./drivers/types.ts";
@@ -7,7 +7,7 @@ import type { DriverModel, LLMDriver } from "./drivers/types.ts";
* settings, and the matching models show up in their model menu.
*
* Security notes (do not weaken):
* - Keys are stored reversibly in SQLite (they must be sent upstream) and
* - Keys are stored reversibly server-side (they must be sent upstream) and
* are NEVER returned to any client — GET only exposes presence + last4.
* - Never log or print keys. Key material only travels server -> provider.
* - Scoping is per account: every helper takes userId and all chat/model
@@ -48,17 +48,19 @@ export interface UserProviderStatus {
updatedAt: number;
}
export function getUserProviderKey(userId: string, provider: UserProviderId): string | null {
const r = getDb()
.query("SELECT api_key FROM user_provider_keys WHERE user_id = ? AND provider = ?")
.get(userId, provider) as { api_key: string } | null;
export async function getUserProviderKey(userId: string, provider: UserProviderId): Promise<string | null> {
const r = await db().get<{ api_key: string }>(
"SELECT api_key FROM user_provider_keys WHERE user_id = ? AND provider = ?",
userId, provider,
);
return r?.api_key ?? null;
}
export function listUserProviders(userId: string): UserProviderStatus[] {
const rows = getDb()
.query("SELECT provider, api_key, updated_at FROM user_provider_keys WHERE user_id = ?")
.all(userId) as { provider: string; api_key: string; updated_at: number }[];
export async function listUserProviders(userId: string): Promise<UserProviderStatus[]> {
const rows = await db().all<{ provider: string; api_key: string; updated_at: number }>(
"SELECT provider, api_key, updated_at FROM user_provider_keys WHERE user_id = ?",
userId,
);
const byId = new Map(rows.map((r) => [r.provider, r]));
return USER_PROVIDER_IDS.map((p) => {
const r = byId.get(p);
@@ -72,16 +74,15 @@ export function listUserProviders(userId: string): UserProviderStatus[] {
});
}
export function setUserProviderKey(userId: string, provider: UserProviderId, apiKey: string): void {
getDb()
.query(
export async function setUserProviderKey(userId: string, provider: UserProviderId, apiKey: string): Promise<void> {
await db().run(
"INSERT INTO user_provider_keys (user_id, provider, api_key, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(user_id, provider) DO UPDATE SET api_key = excluded.api_key, updated_at = excluded.updated_at",
)
.run(userId, provider, apiKey, Date.now());
userId, provider, apiKey, Date.now(),
);
}
export function deleteUserProviderKey(userId: string, provider: UserProviderId): void {
getDb().query("DELETE FROM user_provider_keys WHERE user_id = ? AND provider = ?").run(userId, provider);
export async function deleteUserProviderKey(userId: string, provider: UserProviderId): Promise<void> {
await db().run("DELETE FROM user_provider_keys WHERE user_id = ? AND provider = ?", userId, provider);
}
/** Build a per-user driver instance authenticated with the user's own key. */
@@ -107,7 +108,7 @@ export function buildUserProviderDriver(provider: UserProviderId, apiKey: string
export async function listUserProviderModels(userId: string): Promise<DriverModel[]> {
const out: DriverModel[] = [];
const jobs = USER_PROVIDER_IDS.map(async (p) => {
const key = getUserProviderKey(userId, p);
const key = await getUserProviderKey(userId, p);
if (!key) return;
try {
for (const m of await buildUserProviderDriver(p, key).listModels()) {